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

上个月接手一个订单服务,核心接口GET /api/v1/orders/{user_id},逻辑很简单:查用户订单列表,关联商品和商家信息。代码写得挺规范,用了FastAPI、SQLAlchemy 2.0异步ORM、Pydantic v2。但线上监控显示这个接口P95延迟接近200ms,高峰期CPU飙升到85%,用户投诉“页面转圈”。

第一反应是“是不是数据库慢?”,查了RDS监控,CPU不到20%,慢查询日志也干净。那就奇怪了——数据库没压力,接口却慢。用wrk本地压测,结果更扎心:并发100,QPS只有320,P95 198ms。这性能,连个内部管理系统都嫌慢。

2. 环境与版本

先交代环境,方便复现:

  • Python 3.11.4
  • FastAPI 0.104.1
  • uvicorn 0.24.0(worker=4,--loop uvloop
  • SQLAlchemy 2.0.23 + asyncpg 0.28.0
  • Redis 7.0(部署在单独容器)
  • 压测工具:wrk 4.2.0
  • 机器:4C8G云主机(应用和DB分离,Redis单独一台)

压测命令统一用:

wrk -t4 -c100 -d30s --latency http://localhost:8000/api/v1/orders/U12345

3. 第一步:用py-spy定位瓶颈,别靠猜

很多同学上来就优化SQL,但先得确认瓶颈在哪。我用py-spy对运行中的服务做火焰图采样,命令很简单:

# 先找到uvicorn主进程PID
pgrep -f "uvicorn main:app" | head -1
# 采样30秒生成火焰图
py-spy record -o profile.svg --pid $(pgrep -f "uvicorn main:app" | head -1) --duration 30

火焰图结果非常直观:91.3%的时间花在SQLAlchemy的await session.execute(),展开后看到是lazy load——每个订单循环查商品表、商家表,典型的N+1查询。剩下8%左右在Pydantic的model_validate和JSON序列化上。

补充确认:SQLAlchemy 2.0虽然默认lazy="select",但如果你在查询时没加selectinloadjoinedload,异步环境下懒加载会在每次await时发一次额外查询。我写的代码确实是:

# 优化前:触发N+1
async def get_orders(user_id: str):
    result = await db.execute(
        select(Order).where(Order.user_id == user_id)
    )
    orders = result.scalars().all()
    # 下面这两行会各触发N次查询
    for order in orders:
        _ = order.product  # lazy load
        _ = order.shop     # lazy load
    return orders

一个用户30个订单,就是1+30+30=61次数据库往返。异步情况下虽然不阻塞,但每次往返都有网络开销(约0.3~0.5ms本地,线上约1ms),加上asyncpg解析,累计就到150ms+了。

4. 第二步:消灭N+1,用selectinload批量加载

优化方案很简单:查询时直接加载关联对象,用selectinload(比joinedload更适合一对多,避免笛卡尔积)。同时用orjson替换默认JSON解析器,这能省掉约10%的序列化时间。

改动后核心代码:

# 优化后:selectinload + orjson
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from sqlalchemy.orm import selectinload
from sqlalchemy import select

app = FastAPI(default_response_class=ORJSONResponse)  # 换json解析器

@app.get("/api/v1/orders/{user_id}")
async def get_orders(user_id: str):
    result = await db.execute(
        select(Order)
        .options(
            selectinload(Order.product),
            selectinload(Order.shop)
        )
        .where(Order.user_id == user_id)
    )
    orders = result.scalars().all()
    # 现在orders.product和orders.shop已经批量加载,无额外查询
    return [order_to_schema(o) for o in orders]

注意:selectinload会生成第二条SQL用WHERE product_id IN (...)批量查,总共2次查询搞定。加上ORJSONResponse,序列化速度比默认json快3~5倍(官方benchmark,实测小对象差异不大,但列表场景明显)。

踩坑记录:改完发现selectinloadorder_by有坑——批量加载子查询不会保留排序。比如商品表如果有ORDER BY,需要在关联定义上用order_by参数,或者接受默认顺序。我的场景不关心商品排序,所以没处理。

5. 第三步:加缓存,Redis + aiocache

数据库查询从61次降到2次后,P95从198ms降到了62ms——已经很不错了,但还不够。因为订单数据是“读多写少”且对一致性要求不高(允许秒级延迟),决定加Redis缓存。

引入aiocache库(版本0.12.0),用装饰器方式缓存。注意我不用FastAPI自带的缓存工具,因为aiocache天然支持async和TTL,配置也简单:

from aiocache import cached
from aiocache.serializers import JsonSerializer

# 缓存键用user_id,TTL 60秒
@cached(
    ttl=60,
    key_builder=lambda f, user_id: f"user_orders:{user_id}",
    serializer=JsonSerializer()
)
async def get_orders_cached(user_id: str):
    # 这里的逻辑就是优化后的查询代码
    return await query_orders(user_id)

修改接口调用:

@app.get("/api/v1/orders/{user_id}")
async def get_orders(user_id: str):
    return await get_orders_cached(user_id)

坑点JsonSerializer会序列化Pydantic模型吗?不行,需要先转成dict。我在query_orders里返回前加了一句return [order_to_dict(o) for o in orders],否则缓存会报TypeError。另外,缓存穿透问题——如果用户没有订单,返回空列表也会被缓存,但这是可接受的。更关键的是缓存击穿,高并发下第一个请求没命中,后面请求全打到DB。我的缓解措施:在query_orders里加了个简单的async with锁(用aiocache.lock),实际场景中订单接口并发量不大,所以够用了。

6. 压测数据对比:优化前后差距有多大?

同一台机器,同一命令,跑三次取中位数:

阶段 QPS P50(ms) P95(ms) CPU占用
优化前(N+1+json) 320 45 198 85%
优化后(selectinload+orjson) 1240 18 62 40%
加Redis缓存后 2100 8 32 25%

第三次压测时,因为缓存命中率接近100%(60秒TTL内同一个user_id反复请求),实际数据库QPS只有个位数。CPU掉到25%,主要消耗在uvicorn和Redis通信上。

补充说明:数字不是线性关系,QPS从320到2100是6.5倍提升,P95从198降到32是6倍。但如果你的接口写操作多,缓存策略要调整——写后失效或者双写,得根据业务来,别盲目套用。

7. 总结与建议

这次调优的核心思路就三条,按性价比排序:

  1. 先profiling再动手。py-spy 30秒采样,比瞎猜“可能是SQL慢”高效十倍。如果当时直接加索引,可能白忙活一场。
  2. 消灭N+1是最大红利。从61次查询降到2次,收益最明显。任何ORM框架下都该检查懒加载。
  3. 缓存是最后手段。DB查询已经很快了,再用缓存进一步扛高并发。如果一开始就上缓存,N+1的隐患还在,只是被掩盖了。

另外提一句,orjson替换JSON解析器是个低风险高收益的小优化,FastAPI用户可以直接用ORJSONResponse,一行代码的事。

最后,性能调优没有银弹。我这个场景是“读多写少、数据量小”,如果你的接口是复杂聚合查询或大结果集,优化方向完全不同(可能要上分页、异步任务、甚至CQRS)。关键还是先定位瓶颈,用数据说话。

如果这篇对你有帮助,欢迎交流你的调优经历。踩过的坑,才记得最牢。