一、问题背景:一个"看起来很健康"的慢接口

事情起因很简单。我们有个订单查询接口 /api/v1/orders/summary,前端每次进首页都要调它,返回用户最近 30 天的订单聚合数据(订单数、总金额、最近 5 单)。上线初期 QPS 不到 50,没人关注。直到做活动,QPS 冲到 200 左右,监控开始报警:

  • P99 延迟:1.8s(SLO 要求 = datetime.now() - timedelta(days=30)
    ).all()

    result = []
    for order in orders: # 每次循环都查一次 items
    items = db.query(OrderItem).filter(
    OrderItem.order_id == order.id
    ).all()
    result.append({"order": order, "items": items})
    return result

30 天有 40 个订单,就是 1 + 40 = 41 次查询。改成一次性 JOIN + 预加载:

```python
# 优化后:使用 selectinload 批量加载
from sqlalchemy.orm import selectinload

def get_order_summary(user_id: int):
    thirty_days_ago = datetime.now() - timedelta(days=30)

    orders = (
        db.query(Order)
        .options(selectinload(Order.items))  # 一次 IN 查询搞定所有 items
        .filter(
            Order.user_id == user_id,
            Order.created_at >= thirty_days_ago
        )
        .order_by(Order.created_at.desc())
        .limit(100)
        .all()
    )
    return orders

selectinload 会生成 WHERE order_id IN (...) 的批量查询,从 41 次降到 2 次。同时补上复合索引:

CREATE INDEX CONCURRENTLY idx_orders_user_created 
ON orders (user_id, created_at DESC);

CONCURRENTLY 是关键,生产环境建索引不锁表。

4.2 缓存:Redis 三级策略

聚合数据不需要实时,5 分钟缓存完全可接受。用 Redis 做缓存,key 设计成 order:summary:{user_id}:{date},同时加随机 TTL 防雪崩:

import json
import random
import redis
from functools import wraps

r = redis.Redis(host="localhost", port=6379, db=0, 
                socket_timeout=0.1, decode_responses=True)

def cache_result(prefix: str, ttl: int = 300):
    def decorator(func):
        @wraps(func)
        def wrapper(user_id: int, *args, **kwargs):
            key = f"{prefix}:{user_id}"
            try:
                cached = r.get(key)
                if cached:
                    return json.loads(cached)
            except redis.RedisError:
                pass  # 缓存挂了降级走 DB,别让缓存拖垮服务

            result = func(user_id, *args, **kwargs)

            try:
                # TTL 加随机抖动,避免同一时刻集体失效
                jitter = random.randint(0, 60)
                r.setex(key, ttl + jitter, json.dumps(result, default=str))
            except redis.RedisError:
                pass
            return result
        return wrapper
    return decorator

@cache_result("order:summary", ttl=300)
def get_order_summary(user_id: int):
    ...

4.3 消除同步阻塞

profiling 还暴露了一个隐藏问题:代码里有个同步的 HTTP 调用(查用户等级),在 async 的 FastAPI 里直接 requests.get,会把事件循环堵死。改成 httpx.AsyncClient

import httpx

async def get_user_level(user_id: int) -> str:
    async with httpx.AsyncClient(timeout=0.5) as client:
        resp = await client.get(f"http://user-svc/level/{user_id}")
        return resp.json()["level"]

五、踩坑与优化

坑 1:selectinload 不是万能的。 一开始我对一个 10 万行的大表用了 selectinload,结果 IN 列表太长,PG 直接报错。后来改成 JOIN + 分页,或者限制 limit

坑 2:py-spy 在容器里要加 --nonblocking 否则采样会卡住进程。另外容器需要 SYS_PTRACE 权限:

# docker-compose.yml
services:
  api:
    cap_add:
      - SYS_PTRACE

坑 3:Flask 那边的线程池打满。 Flask 用 gthread,workers=4、threads=8,最大并发 32。压测到 QPS 300 就开始排队。后来把数据库连接池调大(pool_size=20, max_overflow=40),并把 pool_pre_ping=True 打开,避免连接失效。

坑 4:缓存穿透。 用户传了不存在的 user_id,每次都打到 DB。加了空值缓存(TTL 60s)+ 布隆过滤器。

六、效果数据

用 wrk 压测,脚本如下:

# 压测脚本
wrk -t8 -c200 -d60s --latency \
  -s post.lua \
  http://localhost:8000/api/v1/orders/summary

post.lua 里随机生成 user_id 避免全命中缓存:

wrk.method = "POST"
wrk.headers["Content-Type"] = "application/json"
request = function()
    local uid = math.random(1, 10000)
    return wrk.format(nil, nil, nil, string.format('{"user_id": %d}', uid))
end

优化前后对比(QPS 200 场景):

指标 优化前 优化后 提升
P50 380ms 45ms 88%↓
P99 1820ms 120ms 93%↓
QPS 210 1620 7.7x
DB QPS 4200 540 87%↓
缓存命中率 - 94.3% -

FastAPI 和 Flask 两套服务的优化后数据:

框架 P99 QPS 备注
FastAPI (uvicorn 4w) 120ms 1620 async 版本
Flask (gunicorn 4w×8t) 145ms 1180 同步版本,受 GIL 限制

Flask 因为同步模型,QPS 上限就是比 FastAPI 低一截,这是架构决定的,不是代码问题。

七、总结

这次调优最大的收获是:别急着优化,先让数据说话。我一开始以为是 Python 慢,差点去上 Cython,结果 profiling 一跑,全是 IO 等待。

几条可复用的经验:

  1. py-spy + cProfile 是标配,生产用 py-spy,本地复现用 cProfile。
  2. N+1 是 API 性能的头号杀手,SQLAlchemy 用 selectinloadjoinedload
  3. 缓存要加降级、抖动、空值保护,否则缓存本身会成为故障点。
  4. FastAPI 里别写同步阻塞代码,一个 requests.get 能堵死整个事件循环。
  5. 压测要模拟真实流量,全命中缓存的压测数据毫无意义。

最后提醒一句:优化完记得回归测试,我改完 selectinload 后有个订单状态字段的排序变了,差点上线出事故。性能优化,功能正确永远是第一位的。