1. 问题背景:一个“看起来没问题”的API

事情是这样的,我们有个订单查询接口,逻辑不复杂:根据用户ID查订单列表,关联商品信息、店铺信息、物流状态。开发环境跑得欢,部署到生产后,一到下午高峰期,前端反馈“转圈圈”,监控面板上P99延迟直接飙到2170ms,数据库连接池频繁报TimeoutError

第一反应是加机器,但看了下监控,四台4C8G的Pod CPU平均才35%,内存也没压力。典型的“假负载”——服务没吃饱,但活干不完。查了慢日志,发现单条SQL执行都在200ms以内,但接口整体要2秒多,直觉告诉我:问题不在数据库本身,而在代码怎么“用”数据库。

2. 环境与版本:别在错误版本上浪费时间

先交代下现场,方便你复现:

Python: 3.10.12
FastAPI: 0.104.1
uvicorn: 0.24.0 (worker_class="uvicorn.workers.UvicornWorker")
SQLAlchemy: 2.0.23
asyncpg: 0.28.0
Redis: 7.0 (redis-py 5.0.1)
压测工具: wrk 4.2.0 / locust 2.16.1
部署: Gunicorn 21.2.0 + UvicornWorker,4 workers,每worker 1个event loop

特别提醒:如果你还在用SQLAlchemy 1.4Query API,建议直接升2.0,selectinload的性能提升不是一点半点。

3. 方案设计:先Profile,别猜

很多人一上来就加缓存、加索引,结果往往事倍功半。我的流程是固定三步:

  1. 用cProfile抓函数级耗时,找出真正的时间黑洞
  2. 用SQLAlchemy的echo=True和数据库EXPLAIN看执行计划,确认查询是否是“批处理”
  3. 针对热点路径设计缓存,缓存粒度要细,别一把梭全量缓存

4. 核心实现:从2170ms到300ms的第一次优化

4.1 定位瓶颈:cProfile + snakeviz

在路由函数里临时加个装饰器,跑一次单请求profile:

import cProfile
import pstats
import io
from functools import wraps

def profile_endpoint(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        profiler = cProfile.Profile()
        profiler.enable()
        result = await func(*args, **kwargs)
        profiler.disable()
        s = io.StringIO()
        ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
        ps.print_stats(30)
        print(s.getvalue())
        return result
    return wrapper

输出结果一目了然,to_async(asyncpg的驱动方法)累计耗时1.9秒,其中有492次独立的SELECT调用——典型的N+1查询。原因是SQLAlchemy ORM的懒加载(lazy='select'),每次访问order.itemsorder.shop都会发一条新SQL。

4.2 修复N+1:selectinload批量加载

把查询从懒加载改成显式批量加载,这是SQLAlchemy 2.0的官方推荐做法:

# 优化前:懒加载,产生N+1
stmt = (
    select(Order)
    .where(Order.user_id == user_id)
    .order_by(Order.created_at.desc())
    .limit(20)
)

# 优化后:selectinload,一次性IN查询加载关联
from sqlalchemy.orm import selectinload

stmt = (
    select(Order)
    .options(
        selectinload(Order.items).selectinload(Item.sku),
        selectinload(Order.shop),
        selectinload(Order.logistics),
    )
    .where(Order.user_id == user_id)
    .order_by(Order.created_at.desc())
    .limit(20)
)

selectinload会生成WHERE id IN (..., ...)语句,把20笔订单的关联数据一次性拉回来。数据库查询次数从492次降到5次。改完本地压测,延迟已经从2170ms降至310ms左右,效果立竿见影。

4.3 数据库查询优化:索引和覆盖索引

看执行计划,orders表按user_id过滤后排序,走了全表扫描。加了个联合索引:

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

这个索引同时覆盖了WHEREORDER BY,避免filesort。注意别建(created_at, user_id),顺序错了索引就废了。另外把items表的外键order_id加上普通索引,因为selectinload依赖这个列的IN查询。

5. 踩坑与优化:缓存策略和隐藏的坑

5.1 缓存策略:Redis只缓存热数据

优化后接口延迟310ms,但QPS一高,数据库压力还是大。我们统计了用户行为,80%的请求集中在最近10%的活跃用户上。所以缓存策略定为:

  • 粒度用户ID + 页码 + 时间戳(分钟级)作为key,缓存整个列表的JSON序列化结果
  • 过期时间:60秒,避免数据太旧
  • 缓存穿透:布隆过滤器拦截不存在的用户ID(不过我们用户量小,直接查库兜底)
import json
import aioredis
from fastapi import APIRouter, Depends

router = APIRouter()
redis = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)

@router.get("/api/orders")
async def get_orders(user_id: int, page: int = 1):
    cache_key = f"orders:{user_id}:{page}"
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)

    # 数据库查询逻辑(见上文selectinload部分)
    orders = await fetch_orders(user_id, page)

    # 序列化后写入缓存,60秒过期
    result = [order_to_dict(o) for o in orders]
    await redis.set(cache_key, json.dumps(result), ex=60)
    return result
5.2 踩坑记录:三个真实的坑
  • 坑一selectinload配合limit时,如果关联表数据量大,IN子句可能超过PostgreSQL的参数上限(65535)。我们的items表单订单最多50条,20个订单也就1000个参数,安全。但如果你的业务关联数据量大,需要手动分批。
  • 坑二:Gunicorn + Uvicorn的worker数不能等于CPU核数,因为异步worker是单进程单线程,建议2*CPU核数+1。我们4核机器开了9个worker,QPS从180提到320。
  • 坑三:Redis缓存序列化用了json.dumps,但datetime对象不能直接序列化。需要自定义default=str参数,否则会抛TypeError。这个坑debug了我半小时。

6. 效果数据:最终压测结果对比

用wrk压测,命令如下:

wrk -t8 -c200 -d60s --script=post.lua http://localhost:8000/api/orders
指标 优化前 第一次优化(去N+1) 最终(+索引+缓存+多worker)
P50延迟 480ms 145ms 62ms
P99延迟 2170ms 310ms 89ms
QPS 15 87 320
数据库连接数 峰值45 峰值22 峰值8(缓存命中率78%)

内存占用从每worker 680MB降到420MB,因为ORM不再持有大量懒加载对象。数据库CPU使用率也从70%降到15%。

7. 总结:给后来者的三个建议

  1. Profile先行,性能优化不是玄学——cProfile + snakeviz五分钟就能定位90%的问题,别靠猜。
  2. ORM懒加载是生产事故的温床——任何async接口里出现同步懒加载,都会阻塞事件循环。要么全部用selectinload,要么干脆用Core API写SQL。
  3. 缓存是最后的手段,不是第一选择——先保证查询本身是高效的,再加缓存才不会掩盖问题。我们加了Redis后,数据库压力大减,但即使缓存全部失效,接口也能扛住(P99 < 200ms)。

这次优化总共花了一个下午,收益是接口快了24倍,数据库压力降了5倍。如果你也遇到类似情况,建议按这个顺序排查:N+1 → 缺失索引 → 缓存 → 部署配置。有问题欢迎评论区交流。