1. 问题背景:一个本不该慢的接口

上周五,运维同事在群里甩了个截图:/api/v1/orders 接口在高峰期P95耗时飙到1.2s,QPS只有可怜的230。客户投诉说订单列表加载像“幻灯片”。

我第一反应是“这接口逻辑不复杂啊,就查个订单表关联用户和商品,加个分页”。但线上数据不会撒谎。排查的第一步,不是看代码,而是先复现和测量。

环境信息:
- Python 3.10.12
- FastAPI 0.104.1(对比Flask 2.3.3)
- SQLAlchemy 2.0.23 + PostgreSQL 15.3
- Redis 7.0.12
- Gunicorn 21.2.0(4 workers, sync worker class)
- 压测工具:wrk 4.2.0

2. Profiling:别猜,用数据说话

我习惯先用cProfile跑一次单请求,看函数级耗时分布。但cProfile对异步代码支持一般,所以FastAPI应用我用Py-Spy(采样式,不阻塞业务):

# 安装
pip install py-spy

# 对运行中的FastAPI进程做10秒采样
sudo py-spy record --pid $(pgrep -f uvicorn) -o profile.svg --duration 10

生成的火焰图让我一眼看到问题:SQLAlchemy的_fetchall_with_flat_async占了63%的时间。再细看,是ORM懒加载导致的N+1查询——查了100个订单,每个订单又单独查用户和商品,共201条SQL。

Flask侧(同步版)用cProfile更直观:

import cProfile
import pstats
from app import app

def run_request():
    client = app.test_client()
    client.get('/api/v1/orders?page=1&size=50')

cProfile.run('run_request()', 'prof.out')
p = pstats.Stats('prof.out')
p.sort_stats('cumulative').print_stats(15)

输出明确显示:sqlalchemy.orm.loading._emit_load 累计耗时0.42s,占了总时间78%。

结论:瓶颈不在框架本身,而在数据库访问模式。

3. 数据库查询优化:干掉N+1

原代码(典型的懒加载陷阱):

# 问题代码:orders查询后,访问order.user和order.items才触发懒加载
async def get_orders(db: AsyncSession, page: int, size: int):
    stmt = select(Order).offset((page-1)*size).limit(size)
    result = await db.execute(stmt)
    orders = result.scalars().all()
    # 下面这行会触发2*N条额外SQL
    return [serialize_order(o) for o in orders]  # 内部访问o.user, o.items

重构为显式join + selectinload(一次SQL联合查,或分两次批量IN查询):

from sqlalchemy.orm import selectinload
from sqlalchemy import select

async def get_orders_optimized(db: AsyncSession, page: int, size: int):
    stmt = (
        select(Order)
        .options(
            selectinload(Order.user),       # 生成 IN 子查询批量加载
            selectinload(Order.items).selectinload(Item.product)
        )
        .order_by(Order.created_at.desc())
        .offset((page-1)*size)
        .limit(size)
    )
    result = await db.execute(stmt)
    orders = result.scalars().unique().all()  # 注意:unique()必须加,防止行重复
    return [serialize_order(o) for o in orders]

同时给orders.user_idorders.created_at加了复合索引:

CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);

效果对比(wrk压测,60秒,100并发):

指标 优化前 优化后 提升
SQL执行数 201 3 98.5%
平均耗时 180ms 74ms 59%
QPS 320 780 144%

数据库查询优化立竿见影,但还不够。74ms延迟对高并发场景还是偏高。

4. 缓存策略:Redis + 主动失效

订单列表是读多写少(读写比约15:1),非常适合加缓存。我的设计:

  • Cache-Aside模式:先读Redis,miss则查DB并回填。
  • TTL设为60秒,防止数据长时间脏读。
  • 主动失效:下单、修改订单状态时,删除对应用户的列表缓存。
import json
import redis.asyncio as aioredis

redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)

async def get_orders_cached(db, user_id, page, size):
    cache_key = f"orders:user:{user_id}:page:{page}:size:{size}"

    # 1. 读缓存
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. 缓存miss,查DB(用上面优化后的查询)
    orders = await get_orders_optimized(db, page, size)

    # 3. 回填缓存,TTL 60s
    await redis_client.set(
        cache_key, 
        json.dumps(orders, default=str), 
        ex=60
    )
    return orders

# 写操作时主动失效
async def create_order(db, user_id, order_data):
    # ... 业务逻辑 ...
    # 删除该用户所有页的缓存(用scan批量删)
    async for key in redis_client.scan_iter(f"orders:user:{user_id}:*"):
        await redis_client.delete(key)

注意: 别用flushall,生产环境会出事故。我用scan_iter批量删,避免阻塞Redis单线程。

加了缓存的压测结果:

指标 查询优化后 +Redis缓存
平均耗时 74ms 23ms
P95 112ms 38ms
QPS 780 2100
DB QPS 780 42(缓存命中率95%)

缓存命中率我用Redis的INFO stats命令确认:keyspace_hits:keyspace_misses = 15230:801,约95%。

5. 踩坑与细节:FastAPI vs Flask的隐藏差异

调优过程中踩了几个坑,值得记录:

坑1:Flask同步框架的线程阻塞
Flask + Gunicorn sync worker下,每个请求占一个线程,长DB查询会耗尽线程池。我对比了Flask版本(同样优化+缓存后),QPS只能到1450,比FastAPI低31%。原因在于FastAPI的异步SQLAlchemy能单线程处理更多并发请求。如果项目必须用Flask,建议换gevent worker

gunicorn -w 4 -k gevent --worker-connections 1000 app:app

但gevent对psycopg2需要补丁,得用psycogreen,麻烦。

坑2:selectinload导致的内存膨胀
如果订单列表要返回几千条,selectinload会把所有关联对象一次性载入内存。我在压测200并发时遇到内存从300MB涨到1.2GB。解决方案:**强制分页固定`size 100:
raise HTTPException(400, "size max is 100")

**坑3:Redis连接池耗尽**
刚开始用`redis.asyncio`每次请求新建连接,压测时报`ConnectionPoolError`。改为全局单例连接池:

```python
redis_client = aioredis.from_url(
    "redis://localhost:6379/0",
    max_connections=50,
    decode_responses=True
)

坑4:缓存序列化性能
json.dumps序列化ORM对象很慢(占缓存路径40%耗时)。改用pickle.dumps(快1.8倍),但要注意pickle安全性——只缓存内部数据,不暴露给客户端。

6. 最终效果与总结

完整优化链路后的最终数据(wrk -t8 -c100 -d60s):

指标 初始状态 最终状态 提升
平均延迟 180ms 23ms 87.2%
P95延迟 420ms 38ms 91.0%
QPS 320 2100 556%
CPU使用率 85% 48% -43%

调优优先级结论:
1. 先profiling,不然后续优化全是盲枪——Py-Spy火焰图20分钟定位问题。
2. 数据库优化收益最大(N+1修复带来144%QPS提升),且是缓存之前的基础。
3. 缓存是倍增器,但前提是查询本身不能烂——给烂查询加缓存只是把垃圾缓存住。
4. 框架选择:异步框架(FastAPI)在高并发IO密集型场景下比Flask(同步)有明确优势,但如果项目已用Flask,gevent + 合理索引也能救。

最后说句掏心窝的:性能调优最怕“我觉得”。每次改动,都跑一轮压测,记录数据,用事实说话。上面所有数据来自同一台测试机(8核16G),生产环境建议用gunicorn --preload预加载模型,避免worker内存翻倍。

有疑问欢迎留言,踩坑不易,互相救火。


(本文所有压测数据可复现,代码基于Python 3.10,SQLAlchemy 2.x,Redis 7.x,若有版本差异请调整对应API。)