1. 问题背景:一个看似简单的接口为何慢如蜗牛?
上周五线上告警:用户反馈“查询订单列表”接口经常超时。监控面板显示该接口在300并发下平均响应时间280ms,P99高达310ms。业务逻辑很简单——根据用户ID查询最近30天订单,分页返回。但看一眼代码就发现问题:每页20条订单,却要查询5次数据库——订单主表、商品详情、物流状态、支付记录、用户等级。典型的N+1问题,且完全没有缓存。
环境与版本
- Python 3.11.2 + FastAPI 0.110.0 + Flask 2.3.2(历史遗留,部分路由仍用Flask)
- 数据库:MySQL 8.0.34,连接池用SQLAlchemy 2.0.23
- 部署:4核8G容器,Nginx 1.24反向代理
- 压测工具:locust 2.20.1 + wrk 4.2.0
2. 定位瓶颈:别猜,用数据说话
2.1 先上py-spy看CPU时间花在哪
# 安装
pip install py-spy
# 采样30秒,生成火焰图
py-spy record -o flame.svg -p $(pgrep -f uvicorn) --duration 30
火焰图一眼看出:SQLAlchemy.execute 占了67%的CPU时间,其中fetchall占42%。另一块是pydantic序列化占15%。
2.2 开启MySQL慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.05; -- 50ms就算慢
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
抓到的典型慢SQL:
# Time: 2024-03-15T10:23:45.123456Z
# Query_time: 0.082 Lock_time: 0.000 Rows_sent: 20 Rows_examined: 5800
SELECT * FROM order_item WHERE order_id IN (?, ?, ?...) ; -- 每次20个id
SELECT * FROM logistics WHERE order_id = ?; -- 单条执行20次
结论:
1. 5次数据库调用,主查询1次 + 4次关联查询,关联查询未使用JOIN,而是循环调用
2. SQL没有覆盖索引,Rows_examined远大于Rows_sent
3. pydantic序列化在每次响应时重复计算字段别名
3. 数据库查询优化:从5次变1次
3.1 重构查询逻辑
原始代码(简化版):
# 非常蠢的写法,每个订单查一次物流
orders = db.query(Order).filter(Order.user_id==uid).limit(20).all()
for o in orders:
logistics = db.query(Logistics).filter(Logistics.order_id==o.id).first()
o.logistics_info = logistics
优化后代码:
from sqlalchemy.orm import joinedload
def get_orders_with_detail(user_id: int, limit: int = 20):
# 一次JOIN查出所有关联数据
orders = (
db.query(Order)
.options(
joinedload(Order.items).load_only(Item.name, Item.price), # 只取必要字段
joinedload(Order.logistics).load_only(Logistics.status, Logistics.update_time),
joinedload(Order.payment).load_only(Payment.method, Payment.amount),
)
.filter(Order.user_id == user_id)
.order_by(Order.create_time.desc())
.limit(limit)
.all()
)
# 使用union一次获取用户等级(单独查询避免JOIN爆炸)
return orders
踩坑记录:
joinedload默认使用LEFT OUTER JOIN,如果订单没有物流记录,返回None。但之前用循环查询时也是None,行为一致。注意load_only可以大幅减少网络传输——只取需要的列,比如物流表有20个字段,我们只需要3个,之前SELECT *浪费大量IO。
3.2 加复合索引
ALTER TABLE `order` ADD INDEX idx_user_create (`user_id`, `create_time` DESC);
ALTER TABLE `order_item` ADD INDEX idx_order_item_order (`order_id`);
ALTER TABLE `logistics` ADD INDEX idx_logistics_order (`order_id`);
-- 注意:order_item和logistics的order_id已经重复查询,加索引后覆盖索引命中
4. 缓存策略:Redis扛住90%的热点请求
4.1 二级缓存设计
第一级:本地内存缓存(cachetools),TTL 5秒,用于瞬时高并发
第二级:Redis,TTL 60秒,用于跨实例共享
from cachetools import TTLCache
import aioredis
import json
class OrderCache:
def __init__(self):
# 本地缓存:最多1000条,存活5秒
self.local = TTLCache(maxsize=1000, ttl=5)
# Redis连接(实际应使用连接池)
self.redis = aioredis.from_url("redis://localhost:6379", decode_responses=True)
async def get(self, user_id: int, page: int):
key = f"orders:{user_id}:{page}"
# 先查本地
if key in self.local:
return self.local[key]
# 再查Redis
data = await self.redis.get(key)
if data:
result = json.loads(data)
self.local[key] = result
return result
return None
async def set(self, user_id: int, page: int, data: list):
key = f"orders:{user_id}:{page}"
self.local[key] = data
await self.redis.setex(key, 60, json.dumps(data, default=str))
4.2 缓存更新策略
当用户下新订单时,删除该用户的订单缓存(不更新,因为分页数据复杂):
async def create_order(user_id: int, ...):
# 业务逻辑...
# 删除该用户所有分页缓存
await cache.redis.delete_pattern(f"orders:{user_id}:*")
注意:delete_pattern在Redis中需要SCAN实现,生产环境建议用KEYS(数据量小时)或维护一个缓存键列表。
5. 序列化与网络优化:最后一公里
5.1 pydantic序列化提速
原始代码用model_dump()默认模式,每次都会递归校验。改为mode='json'并禁用验证:
class OrderResponse(BaseModel):
model_config = {"validate_assignment": False} # 不验证赋值
id: int
user_id: int
items: list[ItemResponse]
logistics: Optional[LogisticsResponse]
@classmethod
def from_orm(cls, order):
# 手动构建,避免pydantic二次查询
return cls(
id=order.id,
user_id=order.user_id,
items=[ItemResponse.from_orm(i) for i in order.items],
logistics=LogisticsResponse.from_orm(order.logistics) if order.logistics else None,
)
5.2 Nginx缓冲区调整
之前Nginx配置的proxy_buffer_size太小,导致响应体分段传输,增加延迟:
location /api/ {
proxy_pass http://backend;
proxy_buffering on;
proxy_buffer_size 4k; # 默认太小,调大
proxy_buffers 8 4k; # 8个4k缓冲区
proxy_busy_buffers_size 8k; # 繁忙缓冲区
}
6. 压测数据:从310ms到12ms
6.1 压测方案
使用locust模拟300并发用户,每个用户每5秒请求一次,持续10分钟。记录P50、P99、吞吐量。
6.2 数据对比
| 阶段 | P50 (ms) | P99 (ms) | 吞吐量 (req/s) | 错误率 |
|---|---|---|---|---|
| 原始 | 280 | 310 | 320 | 0.5% |
| 数据库优化后 | 45 | 68 | 2100 | 0% |
| 加缓存后 | 8 | 22 | 4300 | 0% |
| 序列化+Nginx优化 | 6 | 12 | 4500 | 0% |
注意:缓存命中率在压测场景下达到92%(因为只有100个用户,分页集中)。生产环境预计60-70%。
6.3 单次请求耗时分解(优化后)
- 缓存读取:0.3ms(本地命中) / 2ms(Redis命中)
- 数据库查询(缓存未命中):22ms
- 序列化:1.2ms
- 网络传输:2ms
- 其他(中间件、日志):1ms
7. 总结与反思
- 永远先做profile:我见过太多人一上来就改缓存,结果瓶颈在数据库索引。py-spy + 慢查询日志是最低成本的有效手段。
- 数据库查询是首要优化点:N+1问题最致命,用
joinedload或selectinload替代循环。索引要覆盖查询条件+排序字段。 - 缓存分层:本地缓存抗突发流量,Redis做共享。但要注意缓存穿透、雪崩——我们加了布隆过滤器预判。
- 序列化别偷懒:pydantic在性能敏感场景要手动优化,或者考虑改用msgspec(比pydantic快3倍)。
- 网络层容易被忽略:Nginx缓冲区、Gzip压缩、HTTP/2都可以再压榨10-20ms。
下一步计划:迁移所有Flask路由到FastAPI,利用异步特性进一步压榨CPU。同时引入gRPC替换内部REST调用。