一、问题背景:一个“看起来没问题”的接口

上个月接手一个物流订单查询服务,基于FastAPI + SQLAlchemy 2.0 + PostgreSQL 13。其中一个/api/v1/orders/summary接口,用于大屏展示当日订单统计。功能很简单:查订单表、关联用户表、聚合状态。

但压测结果让人崩溃:单线程循环调用100次,平均耗时4700ms,P95直接飙到5.2秒,而下游业务方要求P95 = date)
)
result = []
for order in orders.scalars().all():
# 每条订单都查询一次用户表!
user = await session.get(User, order.user_id)
result.append({...})
return result

3000条订单 = 3000次用户表查询。异步环境下虽然不阻塞,但每次查询都有网络RTT、事务开销、解析开销,累加起来就是灾难。

## 四、方案设计:ORM降级为原生SQL + 批量预加载

我的优化思路分三步:

1. **消除N+1**:用一次JOIN替代3000次单查
2. **去掉ORM对象映射开销**:SQLAlchemy ORM实例化对象有大量元数据操作,这里改用`text()`原生SQL直接返回字典
3. **后加Redis缓存**:统计口径是T+1的,完全适合缓存

### 核心实现:改写后的查询层

```python
# order_repository.py - 优化版
from sqlalchemy import text

ORDER_SUMMARY_SQL = """
SELECT 
    o.status,
    COUNT(*) AS cnt,
    COALESCE(SUM(o.amount), 0) AS total_amount,
    COUNT(DISTINCT u.vip_level) AS vip_levels
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.created_at >= :start_date 
  AND o.created_at  dict:
    """原生SQL聚合,单次查询返回所有统计"""
    result = await session.execute(
        text(ORDER_SUMMARY_SQL),
        {"start_date": start_date, "end_date": end_date}
    )
    rows = result.fetchall()

    # 直接映射为dict,避免ORM开销
    summary = {
        "total": 0,
        "amount": 0,
        "status_breakdown": {},
        "vip_levels": 0
    }
    for row in rows:
        summary["status_breakdown"][row.status] = row.cnt
        summary["total"] += row.cnt
        summary["amount"] += row.total_amount
        summary["vip_levels"] = max(summary["vip_levels"], row.vip_levels)

    return summary

同时加上Redis缓存层,TTL设为60秒:

# cache_service.py
import json
import redis.asyncio as aioredis

redis_client = aioredis.from_url(
    "redis://localhost:6379/0",
    max_connections=50,
    decode_responses=True
)

CACHE_KEY_PREFIX = "order_summary:"

async def get_cached_summary(date: str):
    key = f"{CACHE_KEY_PREFIX}{date}"
    cached = await redis_client.get(key)
    if cached:
        return json.loads(cached)
    return None

async def set_cached_summary(date: str, data: dict, ttl: int = 60):
    key = f"{CACHE_KEY_PREFIX}{date}"
    await redis_client.setex(key, ttl, json.dumps(data))

踩坑:PostgreSQL索引失效

第一次改写后测试,发现只快了1倍,还是1.9秒。用EXPLAIN ANALYZE查看执行计划:

Seq Scan on orders o  (cost=0.00..15432.10 rows=2950 width=18)
Filter: (created_at >= '2024-01-15'::date)

全表扫描! created_at上虽然有索引,但因为是date类型比较,PostgreSQL没有走索引。解决方案:

CREATE INDEX idx_orders_created_at_date 
ON orders ((created_at::date));

注意这里用表达式索引,并修改SQL为:

WHERE o.created_at::date = :target_date  -- 改为精确匹配

改完后看执行计划:

Bitmap Index Scan on idx_orders_created_at_date  (cost=0.00..6.43 rows=2960 width=0)

查询时间从1.9秒降至120ms。

五、缓存策略与压测对比

压测使用wrk,脚本如下:

wrk -t4 -c64 -d60s --latency \
  "http://localhost:8000/api/v1/orders/summary?date=2024-01-15"

优化前(无缓存、N+1查询)

Thread Stats   Avg      Stdev     Max   +/- Stdev
  Latency     4.72s     1.21s    6.89s    63.20%
  Req/Sec    12.34      4.56    21.00     70.00%
Latency Distribution
  50%    4.55s
  75%    5.67s
  90%    6.12s
  99%    6.83s
Requests/sec:     12.18

优化后(原生SQL + 表达式索引,无缓存)

Thread Stats   Avg      Stdev     Max   +/- Stdev
  Latency   185.42ms   56.21ms  412.00ms   76.50%
  Req/Sec   318.55     41.20    402.00     71.00%
Latency Distribution
  50%   172.00ms
  75%   215.00ms
  90%   256.00ms
  99%   388.00ms
Requests/sec:    318.27

加Redis缓存后(TTL=60s)

Thread Stats   Avg      Stdev     Max   +/- Stdev
  Latency    12.45ms    3.21ms   28.00ms   82.00%
  Req/Sec  3260.11    512.30  4200.00     68.00%
Latency Distribution
  50%    11.00ms
  75%    14.00ms
  90%    17.00ms
  99%    24.00ms
Requests/sec:   3260.45

结论:
- 数据库查询优化(去N+1 + 索引)让P95从5.2s → 388ms,提升13倍
- Redis缓存让P95从388ms → 24ms,再提升16倍
- 整体QPS从12提升到3260,翻了271倍

六、避坑指南与总结

这次调优踩的几个坑,值得记录:

  1. 不要相信ORM的“智能”:SQLAlchemy ORM在查询单条记录时确实方便,但批量场景下性能极差。复杂统计直接写原生SQL,但不代表抛弃ORM,业务CRUD还是用ORM。

  2. 索引不是万能的created_at >= ?created_at::date = ?在PostgreSQL中执行计划完全不同。调试时一定要用EXPLAIN ANALYZE看实际执行路径。

  3. 缓存一致性没那么可怕:对于T+1统计报表,60秒TTL完全够用。但要注意缓存穿透——如果有人频繁查询未来日期,需要加空值缓存或布隆过滤器。

  4. 压测工具选择:wrk适合测RPS,但要看Latency Distribution,不能只看平均值。P95才是用户真实体感。

  5. Py-spy的妙用:cProfile只能测同步阻塞,但FastAPI是异步的,用Py-spy attach到运行进程能看到真正的CPU热点。本次优化中Py-spy确认了GIL竞争不存在,从而放心使用asyncpg。

最终这个接口稳定运行在生产环境,监控显示P99稳定在30ms以内。性能调优没有魔法,就是找到瓶颈 → 量化验证 → 针对性优化 → 再验证的循环。

如果大家遇到类似的慢查询,建议先花10分钟做profiling,而不是盲目加索引或上缓存。方向对了,效率自然就上来了。