一、问题背景:一顿操作猛如虎,一看延迟两秒五
周三下午,监控群突然炸了。订单查询接口 /api/v1/orders 的P99延迟从平时的200ms直接飙到3370ms,数据库CPU打到85%。这个接口是给客户端订单列表页用的,一次请求需要返回用户近30天的订单概览,包括订单主表、商品快照、物流状态、优惠券分摊等六个维度的数据。
第一反应是数据库连接池不够,但加了连接数后毫无改善。于是开始正经排查——不做无头苍蝇,直接用工具说话。
二、环境与版本:别拿生产开玩笑
先交代现场版本,方便大家复现:
Python: 3.10.11
FastAPI: 0.104.1
Uvicorn: 0.24.0(后换Gunicorn 21.2.0 + UvicornWorker)
SQLAlchemy: 2.0.21
PostgreSQL: 14.5(连接池: SQLAlchemy QueuePool, pool_size=20, max_overflow=10)
Redis: 7.0.12(python-redis 5.0.1)
压测工具: wrk 4.2.0
部署: Docker容器,4C8G,单机
接口逻辑很典型:接收用户ID和分页参数,查订单主表,然后遍历订单ID去查关联表。听起来简单,但订单表和六个子表的关系就是性能陷阱的温床。
三、Profiling第一弹:别猜了,用py-spy看现场
我习惯先用 py-spy 抓生产现场的调用栈,比任何静态分析都真实。命令如下:
# 安装
pip install py-spy
# 抓取目标容器内进程的调用栈,持续5秒
py-spy dump --pid $(pgrep -f "uvicorn.*8000" | head -1) --duration 5 > profile.txt
抓到的栈顶分布让我非常扎心——超过60%的栈都卡在同一个位置:
Thread 0x7f... (idle):
File "sqlalchemy/orm/loading.py", line 1230, in _load_collect_from_loaded
...
File "app/services/order_service.py", line 87, in _fetch_shipment_status
shipment = db.query(Shipment).filter(Shipment.order_id == order_id).first()
果然,经典的N+1查询。循环里逐条查 shipment 表,每个订单多两次SQL(一次查物流,一次查优惠券分摊),30个订单就是额外60次往返。PostgreSQL本地往返延迟约0.5ms,但加上连接池竞争和上下文切换,单次能到5-10ms。
四、方案设计:三层突击,从根上砍开销
定位到瓶颈后,我不打算只做表面缝合,分三步走:
- 干掉N+1:用SQLAlchemy 2.0的
selectinload一次性批量加载所有关联对象。这是最硬核的优化——把60次查询压成1次主查询+4次IN查询。 - 加本地缓存:订单快照数据在30分钟内通常不变(状态流转除外),用
functools.lru_cache做进程内一级缓存,TTL设120秒,适合高并发下同一批热数据反复读取。 - Redis二级缓存:跨进程共享,承接多Worker场景。Key设计为
order:snapshot:{user_id}:{page},缓存JSON序列化后的响应体。
这里有个设计细节:一级缓存存的是ORM对象(未序列化),二级缓存存的是最终响应体。原因是一级缓存命中后可以跳过序列化逻辑吗?不行——ORM对象在请求结束后session关闭会detach,直接复用对象会报 DetachedInstanceError。所以一级缓存只缓存已经序列化好的dict,二级缓存直接缓存JSON字符串。
五、核心实现:代码说话
5.1 修复N+1查询
先看最丑的原始代码片段(节选):
# 优化前:orders.py
def get_orders_with_details(db: Session, user_id: int, page: int, size: int):
orders = db.query(Order).filter(Order.user_id == user_id) \
.order_by(Order.created_at.desc()).offset(page * size).limit(size).all()
result = []
for order in orders:
# 每行循环触发多次查询
items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
shipment = db.query(Shipment).filter(Shipment.order_id == order.id).first()
coupon = db.query(CouponAlloc).filter(CouponAlloc.order_id == order.id).first()
result.append({
"id": order.id,
"amount": order.amount,
"items": [{"sku": i.sku, "name": i.name} for i in items],
"ship_status": shipment.status if shipment else None,
"coupon_amount": coupon.amount if coupon else 0.0
})
return result
优化后,用 selectinload 预先加载所有关联:
# 优化后:orders.py
from sqlalchemy.orm import selectinload
def get_orders_with_details(db: Session, user_id: int, page: int, size: int):
# 一次性加载所有关联,避免N+1
orders = db.query(Order).filter(Order.user_id == user_id) \
.options(
selectinload(Order.items),
selectinload(Order.shipment),
selectinload(Order.coupon_alloc)
) \
.order_by(Order.created_at.desc()) \
.offset(page * size).limit(size).all()
# 直接访问内存中的关联对象,不再产生SQL
return [serialize_order(o) for o in orders]
5.2 缓存层:一级LRU + 二级Redis
缓存装饰器设计如下,注意用 hash 参数区分不同查询条件:
# cache.py
import json
import redis
from functools import lru_cache
from datetime import datetime, timedelta
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
CACHE_TTL_SECONDS = 300 # 订单快照5分钟有效
def order_cache_key(user_id: int, page: int, size: int) -> str:
return f"order:snapshot:v1:{user_id}:{page}:{size}"
@lru_cache(maxsize=1024)
def get_local_cache(user_id: int, page: int, size: int) -> str | None:
"""进程内一级缓存, 返回JSON字符串"""
return None # 占位,实际逻辑在下方调用
def get_cached_response(user_id: int, page: int, size: int) -> dict | None:
# 一级缓存:本地LRU
local_key = (user_id, page, size)
cached = get_local_cache(user_id, page, size)
if cached:
return json.loads(cached)
# 二级缓存:Redis
redis_key = order_cache_key(user_id, page, size)
cached_redis = redis_client.get(redis_key)
if cached_redis:
# 回填本地缓存
get_local_cache(user_id, page, size) # 注意:lru_cache不能直接赋值,需要函数内部逻辑
return json.loads(cached_redis)
return None
def set_cached_response(user_id: int, page: int, size: int, data: dict) -> None:
payload = json.dumps(data, ensure_ascii=False)
# 写Redis
redis_client.setex(order_cache_key(user_id, page, size), CACHE_TTL_SECONDS, payload)
# 写本地缓存
get_local_cache(user_id, page, size) # 需要改造为可赋值
# 实际使用中,对lru_cache做赋值需用函数内嵌,这里简化展示
踩坑提示:lru_cache 不能直接指定TTL,它只做LRU淘汰。我这里的做法是——Redis是唯一权威缓存,本地缓存只是加速,允许短暂失效。本地缓存用于高热Key的极速响应,如果本地过期而Redis还有数据,就回源Redis并刷新本地。
六、压测数据:不说废话,直接看数字
使用 wrk 压测,线程数4,连接数200,持续60秒:
wrk -t4 -c200 -d60s --latency http://localhost:8000/api/v1/orders?user_id=12345&page=0&size=20
优化前(N+1 + 无缓存)
Running 1m test @ http://localhost:8000
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.72s 0.98s 3.37s 68.00%
Req/Sec 0.11k 45.00 0.25k 70.00%
Latency Distribution
50% 1.45s
75% 2.20s
90% 2.95s
99% 3.37s
1250 requests in 1.00m, 1.5MB read
Requests/sec: 20.83
优化后(selectinload + 本地LRU + Redis)
Running 1m test @ http://localhost:8000
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 43.20ms 12.50ms 187.00ms 88.00%
Req/Sec 0.96k 120.00 1.10k 75.00%
Latency Distribution
50% 41.00ms
75% 52.00ms
90% 68.00ms
99% 89.00ms
5890 requests in 1.00m, 7.2MB read
Requests/sec: 98.17
数据对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P50 | 1450ms | 41ms | 35倍 |
| P99 | 3370ms | 89ms | 37.8倍 |
| QPS | 20.8 | 98.2 | 4.7倍 |
| DB连接数 | 峰值85% | 峰值32% | 降低53% |
七、踩坑与额外优化:你没踩过的坑我都帮你踩了
7.1 踩坑一:selectinload 对 limit 的坑
SQLAlchemy的 selectinload 会先查主表(带LIMIT),然后对结果集ID集合执行 WHERE order_id IN (...)。这里没问题,但如果你用 joinedload,LIMIT会被错误应用——它会先JOIN再LIMIT,导致分页数据错乱。强烈建议用 selectinload,它是先查子表再合并,对分页安全。
7.2 踩坑二:Redis序列化超大JSON导致响应慢
一开始我把整个订单列表塞进Redis,单Key值可能超过50KB。在高并发下,反序列化大JSON会吃掉不少CPU。后来我拆成两个Key:主列表存轻量字段(订单ID、金额、状态),详情数据再单独缓存。但这样又增加一次Redis往返。最终折中:在 size20 时关闭缓存走数据库。实测业务中90%请求都是page_size=20,所以缓存命中率依然很高。
7.3 踩坑三:多Worker进程导致本地缓存重复计算
Gunicorn起了4个Worker,每个进程都有独立的 lru_cache,内存重复存储相同Key。解决方案:调大Redis命中率,本地缓存TTL设短一点(比如30秒),避免进程间数据不一致(比如订单状态更新后,某个Worker本地还存着旧状态)。最终参数:本地LRU maxsize=512,Redis TTL=300秒,本地TTL=30秒(通过装饰器内部时间戳判断)。
7.4 额外优化:Gunicorn + UvicornWorker 代替裸Uvicorn
裸Uvicorn单进程跑不满4核。换成Gunicorn管理多进程:
# gunicorn.conf.py
workers = 4
worker_class = "uvicorn.workers.UvicornWorker"
bind = "0.0.0.0:8000"
keepalive = 5
配合 --preload 预加载模型,减少子进程fork开销。但注意:--preload 和 lru_cache 有冲突——预加载后进程fork,缓存被复制,但子进程写入不会同步。所以我没有用 --preload,直接让每个Worker独立初始化。
八、总结:调优不是玄学,是找证据链
这次调优的经验总结成一句话:先profile,再改代码,最后用数据验证。流程如下:
- 用
py-spy抓生产栈,定位是I/O、CPU还是锁竞争。 - 如果是数据库问题,看慢日志和SQL执行计划,N+1是头号嫌疑。
- 用
selectinload批量加载,减少往返次数。 - 加缓存时想清楚缓存一致性:本地缓存加速但不保证强一致,Redis做权威。
- 压测要分阶段做,每改一步都跑一次wrk,用P99和QPS说话。
最后提醒一句:缓存不是银弹。如果你的数据更新频率极高(秒级),那么Redis TTL要设短,或者干脆走数据库。我们这次订单快照属于“读多写极少”场景,缓存才有效。别拿缓存去套所有接口,会出事的。
如果你们也有类似接口延迟问题,建议先跑一遍上述流程,大概率能找到惊喜。