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. 总结与反思

  1. 永远先做profile:我见过太多人一上来就改缓存,结果瓶颈在数据库索引。py-spy + 慢查询日志是最低成本的有效手段。
  2. 数据库查询是首要优化点:N+1问题最致命,用joinedloadselectinload替代循环。索引要覆盖查询条件+排序字段。
  3. 缓存分层:本地缓存抗突发流量,Redis做共享。但要注意缓存穿透、雪崩——我们加了布隆过滤器预判。
  4. 序列化别偷懒:pydantic在性能敏感场景要手动优化,或者考虑改用msgspec(比pydantic快3倍)。
  5. 网络层容易被忽略:Nginx缓冲区、Gzip压缩、HTTP/2都可以再压榨10-20ms。

下一步计划:迁移所有Flask路由到FastAPI,利用异步特性进一步压榨CPU。同时引入gRPC替换内部REST调用。