1. 问题背景:线上监控告警,接口突然变慢
上周四下午,监控平台连续弹出3条告警:/api/v1/orders/detail 接口P95延迟突破2秒,错误率从0.2%飙到3.8%。查了Grafana,发现是数据库连接池被打满,大量请求在等连接。这个接口是订单详情,前端在用户点击“查看订单”时调用,日均请求量约200万,属于高频核心接口。
我的第一反应是“数据库慢查询”,但看了慢日志只有一条200ms的查询,不至于把连接池打满。于是开始系统排查。
2. 环境与版本
- Python 3.10.8
- FastAPI 0.95.1(Pydantic v2)
- SQLAlchemy 2.0.15(async模式)
- PostgreSQL 14.5(RDS,4核8G)
- Redis 6.2(阿里云,256M)
- 压测工具:wrk 4.2.0,单机8线程
- 部署:2台4C8G ECS,K8s滚动发布
代码结构大致是:
app/
api/v1/orders.py # 路由
services/order.py # 业务逻辑
models/order.py # ORM模型
3. 方案设计:三步定位法
3.1 第一阶段:排除网络和基础设施问题
先确认不是网络或负载均衡问题。用wrk直接打到Pod IP,延迟依然高。排除。
3.2 第二阶段:应用层profiling
用cProfile跑一遍接口调用:
# 在测试环境执行
python -m cProfile -o profile.out -m uvicorn app.main:app --port 8001
# 用pstat查看热点
python -c "import pstats; pstats.Stats('profile.out').sort_stats('cumulative').print_stats(30)"
输出关键部分:
ncalls tottime percall cumtime percall filename:lineno(function)
12 0.002 0.000 2.214 0.184 order.py:99(get_order_detail)
12 0.001 0.000 1.987 0.166 models/order.py:45(load_items)
86 0.004 0.000 1.521 0.018 sqlalchemy/orm/loading.py:112(_load)
看到了吧,load_items 占了近2秒,里面是循环查item表。典型的N+1查询:一个订单有10个商品,就查10次商品表,每次100ms,累计1秒。还有86次SQLAlchemy的加载调用,明显是懒加载导致的。
3.3 第三阶段:确认数据库侧表现
用pg_stat_statements看数据库侧,发现SELECT * FROM item WHERE id = $1被调用了上千次,单次最快8ms,但次数多。同时发现SELECT * FROM order_item WHERE order_id = $1只执行了一次,但返回了200行——单订单最多20个商品,怎么会返回200行?后来查了代码,发现联表查询没加distinct,笛卡尔积了。
4. 核心实现:三步修复
4.1 修复N+1查询:用joinedload替代懒加载
原代码(问题所在):
# services/order.py
async def get_order_detail(order_id: int, db: AsyncSession):
order = await db.get(Order, order_id)
# 懒加载:每次访问order.items都会触发SQL
items = []
for item in order.items: # 这里触发N次查询
item_detail = await db.get(Item, item.item_id)
items.append({
"item_id": item.item_id,
"name": item_detail.name,
"price": item_detail.price,
})
return {"order_id": order.id, "items": items}
修复后:
# services/order.py
from sqlalchemy.orm import joinedload
from sqlalchemy import select
async def get_order_detail(order_id: int, db: AsyncSession):
# 一次性join查询,同时加载order和items
result = await db.execute(
select(Order)
.options(joinedload(Order.items).joinedload(OrderItem.item))
.where(Order.id == order_id)
)
order = result.scalar_one_or_none()
if not order:
return None
items = [{
"item_id": oi.item_id,
"name": oi.item.name,
"price": oi.item.price,
} for oi in order.items]
return {"order_id": order.id, "items": items}
这里有个细节:joinedload(Order.items).joinedload(OrderItem.item) 是链式加载,一次SQL搞定三层关系。SQLAlchemy 2.0的select语法比旧版query更清晰。
4.2 加Redis缓存:热点数据不再打数据库
订单详情属于读多写少,90%的请求是查看已完成的订单(状态不会变)。所以加了一层缓存:
# services/order.py
import json
from redis import asyncio as aioredis
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
CACHE_TTL = 300 # 5分钟
async def get_order_detail_cached(order_id: int, db: AsyncSession):
cache_key = f"order:detail:{order_id}"
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
data = await get_order_detail(order_id, db)
if data:
# 只缓存已完成的订单,避免脏数据
await redis_client.setex(cache_key, CACHE_TTL, json.dumps(data))
return data
注意:缓存key要带版本号,防止代码升级后旧缓存结构不兼容。我踩过这个坑,后面详说。
4.3 序列化优化:Pydantic v2的model_dump_json
cProfile里还发现json.dumps占了不少时间。FastAPI默认用Pydantic v2,但如果你手写json.dumps,性能会差很多。改用Pydantic的序列化:
from pydantic import BaseModel
class OrderItemOut(BaseModel):
item_id: int
name: str
price: float
class OrderDetailOut(BaseModel):
order_id: int
items: list[OrderItemOut]
# 返回时直接返回模型,FastAPI自动用model_dump_json序列化
return OrderDetailOut(order_id=order.id, items=items)
实测这个改动让序列化时间从120ms降到30ms,因为Pydantic v2是基于Rust的。
5. 踩坑与优化:三个坑,个个要命
坑1:Redis缓存击穿——缓存雪崩
上线后第一个周末,突然又告警了。查日志发现缓存过期时间集中在同一秒(因为下单时间相同)。大量请求同时回源数据库,连接池又满了。修复:在TTL上加随机偏移,CACHE_TTL + random.randint(0, 60)。另外加了互斥锁(详见代码),同一时间只允许一个线程重建缓存。
# 防击穿:用Redis的setnx做互斥
import time
lock_key = f"order:lock:{order_id}"
if await redis_client.setnx(lock_key, "1"):
await redis_client.expire(lock_key, 5) # 5秒锁
try:
data = await get_order_detail(order_id, db)
await redis_client.setex(cache_key, CACHE_TTL, json.dumps(data))
finally:
await redis_client.delete(lock_key)
else:
# 稍微等一下再读缓存
await asyncio.sleep(0.1)
return await redis_client.get(cache_key)
坑2:SQLAlchemy 2.0的session scope问题
async模式下,如果session在请求结束后才关闭,懒加载会报MissingGreenlet错误。必须确保AsyncSession在依赖注入中正确管理生命周期:
# app/deps.py
async def get_db():
async with AsyncSession(engine) as session:
yield session
坑3:wrk压测结果不稳定
第一次压测数据忽高忽低,排查发现是wrk的线程数没设对。单机8核,wrk应该用8线程,但连接数也要同步调大。最终参数:
wrk -t8 -c256 -d30s --latency http://your-api/orders/123
6. 效果数据:压测对比
修完所有问题,重新压测。相同环境(2台ECS,4C8G),30秒压测,结果如下:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| QPS | 45 | 234 | 5.2x |
| P50延迟 | 420ms | 32ms | 13x |
| P95延迟 | 2100ms | 180ms | 11.6x |
| 错误率 | 3.8% | 0% | - |
数据库侧:慢查询数从每分钟1200次降到接近0,连接池使用率从95%降到30%。Redis命中率稳定在87%,缓存占内存约80MB(订单详情平均2KB,缓存5分钟,约4万订单)。
7. 总结:调优checklist
这次调优花了3天,核心收获:
- 先profile再优化,别凭感觉。cProfile和py-spy是黄金搭档,py-spy能看生产环境在线进程。
- N+1查询是最常见的性能杀手,ORM的懒加载一定要警惕。用
joinedload或selectinload。 - 缓存一定要考虑击穿/雪崩,随机TTL + 互斥锁是标配。
- 序列化别用json.dumps,Pydantic v2的model_dump_json性能好很多。
- 压测参数要标准,wrk的线程数要和CPU核数对齐,连接数要足够。
最后贴一个通用排查命令,遇到API慢先跑一遍:
# 生产环境用py-spy dump现场
py-spy dump --pid
# 或者用top看线程
top -H -p
# 配合slow日志
grep "slow" /var/log/app.log | tail -20
如果以上都做了还慢,那就看数据库慢查询日志和连接池监控。大部分性能问题最后都落在SQL或缓存策略上。希望这篇对你有用,有不同意见欢迎评论区交流。