一、问题背景:一个“正常”接口的罪与罚

事情起因是线上监控告警——订单详情接口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.itemsorder.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 定位结果

两个瓶颈浮出水面:

  1. N+1查询:一个订单详情查询实际发了 1(主订单)+ N(商品)+ N(物流)条SQL
  2. 同步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

踩坑提醒

  1. 序列化类型Decimal不能直接json.dumps,用了str(total_amount)绕过去,否则会报TypeError
  2. 缓存穿透:如果查询不存在的订单ID,每次都会击穿到数据库。加一个空值缓存({"error": "not found"}也缓存60秒)
  3. 缓存雪崩:过期时间不要设成完全一样的值,加上随机偏移量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%

关键经验总结

  1. 先profiling再优化:我用cProfile花了10分钟定位问题,如果直接上缓存,虽然也能降延迟,但数据库压力不会减,治标不治本。
  2. 异步框架要警惕同步IO:FastAPI虽然快,但ORM的同步查询会阻塞event loop。如果追求极致性能,建议用asyncpg + SQLAlchemy 2.0的异步版本,或者直接上encode/databases
  3. 缓存不是银弹:缓存只适合读多写少、数据一致性要求不高的场景。订单状态变更时记得主动删缓存,否则会读到脏数据。
  4. 压测要模拟真实场景:我一开始全缓存命中,QPS到了8000+,但实际业务不可能100%命中。混合压测更接近线上表现。

调优后这个接口线上运行两周,P99稳定在50ms以内,数据库CPU从80%降到12%。如果你也在调API性能,建议按照这个顺序来:profiling → SQL优化 → 索引 → 缓存,每一步都有数据支撑,别跳步。


写在最后:性能调优是门手艺活,每个系统瓶颈不一样。这篇文章里的数据是基于我的环境,你的结果可能不同——但方法论是通用的。有问题欢迎评论区交流。