一、问题背景:一个“正常”接口的罪与罚
事情起因是线上监控告警——订单详情接口P99延迟飙到850ms,数据库CPU使用率接近80%。这个接口是典型的“读多写少”场景:客户端每次请求需要返回订单基本信息、商品列表、用户收货地址以及最近三条物流轨迹。
最初我以为是网络问题,但排查后发现服务端耗时占大头。更诡异的是,同样的代码在测试环境只有120ms,一到生产就慢7倍。后来才明白:测试环境数据量小,生产库单表已经500万+订单,商品表关联查询走了全表扫描。
这个案例非常典型——90%的API性能问题不是代码逻辑复杂,而是数据量增长后,原来的“正确代码”变成了“性能陷阱”。
二、环境与版本:调优前的基线
先交代一下环境,方便大家复现:
Python 3.10.12
FastAPI 0.104.1 (Uvicorn 0.24.0)
Flask 3.0.0 (Gunicorn 21.2.0 + gevent 23.9.1)
SQLAlchemy 2.0.23
Redis 7.2.1
PostgreSQL 14.9
wrk 4.2.0 (压测工具)
业务模型简化后长这样:
# models.py(代码已简化)
class Order(Base):
__tablename__ = 'orders'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, index=True)
total_amount = Column(Numeric(10,2))
status = Column(String(20))
items = relationship("OrderItem", lazy="select") # 默认懒加载
logistics = relationship("Logistics", lazy="select")
class OrderItem(Base):
__tablename__ = 'order_items'
id = Column(Integer, primary_key=True)
order_id = Column(Integer, ForeignKey('orders.id'), index=True)
product_name = Column(String(100))
quantity = Column(Integer)
初始接口代码(FastAPI版本):
@app.get("/api/orders/{order_id}")
async def get_order(order_id: int, db: Session = Depends(get_db)):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
return {"error": "not found"}
# 每次访问order.items都会触发额外SQL查询
items = [{"name": i.product_name, "qty": i.quantity} for i in order.items]
logistics = [{"time": l.created_at} for l in order.logistics]
return {
"order_id": order.id,
"total": str(order.total_amount),
"items": items,
"logistics": logistics
}
基线压测数据(wrk -t8 -c100 -d30s):
| 框架 | 平均延迟 | P99 | QPS |
|---|---|---|---|
| FastAPI | 142ms | 850ms | 420 |
| Flask | 155ms | 890ms | 380 |
三、第一步:Profiling——到底慢在哪儿?
很多同学上来就加缓存,这是错的。先定位瓶颈再优化,否则可能白费功夫。
我用了两个工具:
3.1 cProfile(函数级分析)
python -m cProfile -s cumtime your_app.py
跑一次请求后,输出关键部分:
ncalls tottime percall cumtime percall filename:lineno
1 0.002 0.002 0.812 0.812 api.py:20(get_order)
11 0.003 0.000 0.785 0.071 sqlalchemy/orm/loading.py:120
3 0.001 0.000 0.624 0.208 sqlalchemy/dialects/postgresql/psycopg2.py:240
结论一目了然:85%的时间花在SQLAlchemy的懒加载上。order.items和order.logistics分别触发了额外的SELECT语句,而且每个SELECT都走了索引,但网络往返(RTT)累计起来要命。
3.2 py-spy(生产环境在线分析)
cProfile在本地好使,但生产环境不能停服。用py-spy直接attach到运行中的进程:
# 安装
pip install py-spy
# 导出火焰图
py-spy record --pid 12345 -o profile.svg --duration 30
火焰图显示:uvicorn的event loop被阻塞。原因在于SQLAlchemy的同步IO操作直接挡住了异步事件循环——虽然FastAPI是异步框架,但db.query()是同步阻塞调用,一旦遇到慢查询,整个worker就卡住了。
3.3 定位结果
两个瓶颈浮出水面:
- N+1查询:一个订单详情查询实际发了 1(主订单)+ N(商品)+ N(物流)条SQL
- 同步ORM在异步框架中的阻塞:SQLAlchemy 2.0的同步session在async环境下,每次IO都会阻塞event loop
四、第二步:数据库查询优化——干掉N+1
第一个优化方案很直接:用selectinload预加载关联对象,把多次查询合并为2次(一次查订单,一次批量查关联)。
同时,顺手加了两个复合索引(生产环境实测有效):
CREATE INDEX idx_order_user_status ON orders (user_id, status);
CREATE INDEX idx_item_order ON order_items (order_id) INCLUDE (product_name, quantity);
改造后的查询代码:
from sqlalchemy.orm import selectinload
@app.get("/api/orders/{order_id}")
async def get_order(order_id: int, db: Session = Depends(get_db)):
# 预加载,避免懒加载N+1
order = db.query(Order).options(
selectinload(Order.items),
selectinload(Order.logistics)
).filter(Order.id == order_id).first()
if not order:
return {"error": "not found"}
return {
"order_id": order.id,
"total": str(order.total_amount),
"items": [{"name": i.product_name, "qty": i.quantity} for i in order.items],
"logistics": [{"time": l.created_at} for l in order.logistics]
}
效果数据(同一台机器,wrk同样参数):
| 框架 | 平均延迟 | P99 | QPS |
|---|---|---|---|
| FastAPI(优化前) | 142ms | 850ms | 420 |
| FastAPI(优化后) | 38ms | 120ms | 1830 |
| Flask(优化后) | 42ms | 135ms | 1710 |
QPS翻了4.3倍,但这只是第一步。
五、第三步:缓存策略——Redis缓存热点数据
数据库查询优化后,QPS到了1800+,但数据库CPU依然偏高(45%左右)。考虑到接口是“读多写少”,且订单状态变更不频繁(通常只有“待支付→已支付→已发货→已完成”),我决定引入Redis缓存。
5.1 缓存设计
- Key设计:
order:{order_id}:v1(v1是版本号,将来业务变更时批量失效) - 过期时间:300秒(5分钟),因为订单状态最长5分钟内会更新
- 缓存策略:Cache-Aside模式——读时先查缓存,未命中再查库并回填;写操作(状态变更)时主动删除对应缓存
5.2 核心实现(FastAPI + Redis)
import json
import redis.asyncio as aioredis
from fastapi import Depends, FastAPI
app = FastAPI()
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
@app.get("/api/orders/{order_id}")
async def get_order_with_cache(order_id: int, db: Session = Depends(get_db)):
cache_key = f"order:{order_id}:v1"
# 先查缓存
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 缓存未命中,查数据库
order = db.query(Order).options(
selectinload(Order.items),
selectinload(Order.logistics)
).filter(Order.id == order_id).first()
if not order:
return {"error": "not found"}
result = {
"order_id": order.id,
"total": str(order.total_amount),
"items": [{"name": i.product_name, "qty": i.quantity} for i in order.items],
"logistics": [{"time": l.created_at} for l in order.logistics]
}
# 回填缓存,设置300秒过期
await redis_client.set(cache_key, json.dumps(result), ex=300)
return result
踩坑提醒:
- 序列化类型:
Decimal不能直接json.dumps,用了str(total_amount)绕过去,否则会报TypeError - 缓存穿透:如果查询不存在的订单ID,每次都会击穿到数据库。加一个空值缓存(
{"error": "not found"}也缓存60秒) - 缓存雪崩:过期时间不要设成完全一样的值,加上随机偏移量
random.randint(0, 60),避免大量key同时过期
5.3 Flask版本对比
Flask是同步框架,用Gunicorn多worker部署,Redis客户端用同步版本:
import redis, json
from flask import Flask, jsonify
app = Flask(__name__)
cache = redis.Redis(host='localhost', port=6379, db=0)
@app.route('/api/orders/')
def get_order_with_cache(order_id):
cache_key = f"order:{order_id}:v1"
cached = cache.get(cache_key)
if cached:
return jsonify(json.loads(cached))
# ... 数据库查询逻辑同前 ...
result = {...}
cache.set(cache_key, json.dumps(result), ex=300)
return jsonify(result)
注意:Flask默认是单进程单线程,必须配合Gunicorn多worker才能扛住并发。我的启动命令:
gunicorn -w 4 -k gevent --worker-connections 1000 app:app
六、最终压测数据与总结
最终压测(wrk -t8 -c100 -d60s,混合20%缓存未命中率):
| 框架 | 平均延迟 | P99 | QPS | 数据库CPU |
|---|---|---|---|---|
| FastAPI 原始 | 142ms | 850ms | 420 | 80% |
| FastAPI + SQL优化 | 38ms | 120ms | 1830 | 45% |
| FastAPI + Redis缓存 | 12ms | 47ms | 5100 | 12% |
| Flask + Redis缓存 | 15ms | 58ms | 4600 | 14% |
关键经验总结
- 先profiling再优化:我用cProfile花了10分钟定位问题,如果直接上缓存,虽然也能降延迟,但数据库压力不会减,治标不治本。
- 异步框架要警惕同步IO:FastAPI虽然快,但ORM的同步查询会阻塞event loop。如果追求极致性能,建议用
asyncpg+SQLAlchemy 2.0的异步版本,或者直接上encode/databases。 - 缓存不是银弹:缓存只适合读多写少、数据一致性要求不高的场景。订单状态变更时记得主动删缓存,否则会读到脏数据。
- 压测要模拟真实场景:我一开始全缓存命中,QPS到了8000+,但实际业务不可能100%命中。混合压测更接近线上表现。
调优后这个接口线上运行两周,P99稳定在50ms以内,数据库CPU从80%降到12%。如果你也在调API性能,建议按照这个顺序来:profiling → SQL优化 → 索引 → 缓存,每一步都有数据支撑,别跳步。
写在最后:性能调优是门手艺活,每个系统瓶颈不一样。这篇文章里的数据是基于我的环境,你的结果可能不同——但方法论是通用的。有问题欢迎评论区交流。