一、问题背景:凌晨2点的告警电话
我负责的订单分析服务在版本迭代后,监控系统突然告警:/api/v1/orders/summary 接口的P95延迟从平时的200ms飙升到3.2秒,同时RDS实例的CPU使用率持续在95%以上。这个接口被内部三个看板系统调用,每次请求会聚合近7天的订单数据。
第一反应是数据量涨了,但检查后发现订单表只增加了5%的数据。直觉告诉我,这更像是代码层面的问题——大概率是上周重构时引入了什么"性能炸弹"。
二、环境与版本:先亮家底
- 应用框架:FastAPI 0.104.0(Python 3.11.5)
- ORM:SQLAlchemy 2.0.23
- 数据库:PostgreSQL 15.3(规格:4C8G,SSD)
- 缓存:Redis 7.0.12(规格:2G内存)
- 部署:Docker容器,单实例,4核CPU限制
- 压测工具:wrk 4.2.0,本机直连(避免网络干扰)
三、方案设计:三管齐下的排查路径
拿到问题后,我列了一个排查清单,按照成本从低到高排列:
- Profiling定位 —— 先用无侵入的
py-spy看线程栈,再用cProfile采集具体函数耗时 - 数据库层面 —— 开启
pg_stat_statements和慢查询日志,找出高频慢SQL - 缓存策略 —— 对热点数据和计算结果分层缓存
这里踩过一个坑:最开始直接用cProfile跑生产环境,结果因为性能开销太大(约30%性能损失),反而影响了线上服务。后来改用py-spy的--duration参数只采样10秒,开销几乎为零。
四、核心实现:从定位到改造
4.1 第一步:py-spy采样,快速锁定热点
# 在容器外执行,找到目标PID
docker ps | grep fastapi
# 假设PID是 12345,采样10秒
py-spy dump --pid 12345 --duration 10
# 输出关键片段:
# Thread 0x7f... (idle)
# File "app/services/order_service.py", line 87, in get_summary
# for order in orders: # 这里在循环里发SQL
# File "app/services/order_service.py", line 92, in
# item_total = get_item_total(order.id) # N+1查询!
py-spy的输出直接指出了问题:order_service.py 第87行有个循环,循环体里又调用了get_item_total,这明显是N+1查询。同时注意到循环内部还有大量字符串格式化操作,这些都是CPU杀手。
4.2 第二步:cProfile精确到函数调用次数
既然定位到了服务层,再用cProfile做一次定向profiling:
import cProfile
import pstats
from io import StringIO
# 手动触发一次请求,用cProfile包裹
profiler = cProfile.Profile()
profiler.enable()
# 这里调用你的业务函数,比如:
result = order_service.get_summary(user_id=123, days=7)
profiler.disable()
# 输出耗时排名
s = StringIO()
ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
ps.print_stats(20)
print(s.getvalue())
# 结果摘录:
# ncalls tottime cumtime filename:lineno
# 1 0.002 2.847 order_service.py:86(get_summary)
# 3500 0.421 2.431 order_service.py:92(get_item_total)
# 3500 0.812 0.812 models.py:88(OrderItem.query) # 每行一次查询!
# 3500 0.356 0.356 utils.py:120(format_currency) # 字符串转换开销
数据触目惊心:3500次OrderItem.query调用,占了总耗时2.4秒。SQLAlchemy的ORM对象构造和session管理开销巨大,而且这个查询完全可以用一条JOIN搞定。
4.3 第三步:数据库慢查询日志确认
在PostgreSQL侧执行:
-- 先开启pg_stat_statements(需要超级用户或已加载扩展)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 查看最耗时的查询
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
-- 结果:
-- SELECT * FROM order_items WHERE order_id = $1 -- 被调用了3500次! 平均0.7ms
-- SELECT * FROM orders WHERE user_id = $1 AND created_at > $2 -- 1次,但扫描26万行
确认了:3000多次的order_items查询是DB CPU飙升的直接原因。每次单行查询虽然只有0.7ms,但3500次就是2.45秒,加上Python侧ORM对象构造,总耗时完全失控。
4.4 改造方案:三管齐下
第一招:SQLAlchemy 2.0的selectinload替代循环查询
原代码(罪魁祸首):
# 改造前:N+1查询
def get_summary(user_id: int, days: int):
orders = db.query(Order).filter(
Order.user_id == user_id,
Order.created_at > datetime.now() - timedelta(days=days)
).all()
total_items = 0
for order in orders: # 每次循环发起一次DB查询
items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
total_items += sum(item.quantity for item in items)
return {"total_items": total_items}
改造后代码(一次JOIN):
# 改造后:一次查询加载所有关联数据
from sqlalchemy.orm import selectinload
def get_summary(user_id: int, days: int):
# selectinload会生成第二条IN查询,一次性加载所有order_items
orders = db.query(Order).options(
selectinload(Order.items) # 关键优化点
).filter(
Order.user_id == user_id,
Order.created_at > datetime.now() - timedelta(days=days)
).all()
# 内存中聚合,不再访问数据库
total_items = sum(
item.quantity
for order in orders
for item in order.items # 此时items已预加载
)
return {"total_items": total_items}
第二招:Redis 7.0二级缓存,干掉重复计算
这个接口被3个看板调用,但数据每小时才变一次。在服务层加一层Redis缓存,缓存key设计为order_summary:{user_id}:{days},过期时间设为300秒(5分钟)。
第三招:functools.lru_cache本地缓存
对于高频的format_currency货币格式化函数,它不涉及IO,只是CPU计算。用lru_cache装饰器,maxsize设为1024,避免相同数字重复格式化:
from functools import lru_cache
@lru_cache(maxsize=1024)
def format_currency(amount: float) -> str:
# 原有的格式化逻辑
return f"${amount:,.2f}"
五、踩坑与优化:你以为完了?还有坑
坑1:selectinload的陷阱
用selectinload后,SQLAlchemy会生成WHERE order_id IN (..., ...)的查询。如果order数量超过500,PostgreSQL的IN列表长度会导致索引失效。解决方案是分批加载,但实测中我们的order数量在200以内,所以没触发这个问题。如果你的场景数据量大,建议用lazy='raise'强制开发阶段暴露N+1。
坑2:Redis缓存穿透
加了Redis缓存后,如果请求的user_id是无效的(比如恶意扫描),每次都会穿透缓存打DB。解决方案是缓存空结果:
# 缓存空结果防穿透
if not result:
redis.set(cache_key, json.dumps({"total_items": 0}), ex=60) # 短暂缓存
坑3:本地缓存导致内存泄漏
lru_cache如果maxsize设置太大,会撑爆内存。生产环境监控发现容器内存从512MB涨到2.1GB,排查后发现是lru_cache缓存了大量float参数组合。最终把maxsize从1024调到256,内存稳定在800MB内。
六、效果数据:用数字说话
改造完成后,用wrk做了三轮压测,每轮持续30秒,并发50个连接:
| 指标 | 改造前 | 改造后(Redis+selectinload) | 最终(含lru_cache) |
|---|---|---|---|
| P50延迟 | 1.2s | 210ms | 88ms |
| P95延迟 | 3.287s | 340ms | 89ms |
| P99延迟 | 5.1s | 520ms | 112ms |
| 吞吐量(QPS) | 35 | 210 | 580 |
| DB CPU使用率 | 97% | 38% | 12% |
| 每次请求DB查询数 | 3501 | 2 | 2 |
wrk压测命令参考:
wrk -t4 -c50 -d30s http://localhost:8000/api/v1/orders/summary?user_id=123&days=7
最终P95从3287ms降到89ms,提升了36.9倍。数据库CPU从97%降到12%,说明瓶颈已经完全从DB转移到应用层。
七、总结:调优的优先级思维
这次调优让我重新梳理了API性能优化的方法论:
- 先采样,不要猜 —— py-spy的10秒采样比看代码猜效率高十倍
- 数据库查询优化永远是第一优先 —— N+1问题要根治,ORM的懒加载要关闭
- 缓存是最后的手段 —— Redis和本地缓存能解决80%的重复计算,但要注意穿透和内存泄漏
- 压测数据要留档 —— 每次改动前后跑同一套wrk脚本,用数据说话
最后留个思考题:如果你的接口QPS超过2000,Redis缓存本身会成为瓶颈,这时候你会怎么做?欢迎在评论区讨论。我用的是多级缓存+异步批量查询,当然那是另一个故事了。