一、问题背景

事情是这样的。我们有个商品详情接口 /api/v1/product/{id},早期用Flask写的,一直跑得还行。最近运营搞活动,流量涨了三倍,接口直接崩了。

监控上看到的现象:

  • P99 响应时间:1200ms(正常应该在100ms以内)
  • 平均响应时间:380ms
  • QPS 到 200 左右开始出现 502
  • CPU 打满,但数据库连接池没打满

这就很诡异。CPU 高但DB连接没满,说明瓶颈要么在应用层的计算,要么在序列化,要么就是查询次数太多但每次很快。先别猜,上工具。

二、环境与版本

先把环境交代清楚,不然复现不了:

  • Python 3.10.13
  • Flask 2.3.3 + Gunicorn 21.2.0(4 workers,sync worker)
  • FastAPI 0.109.0 + Uvicorn 0.27.0
  • SQLAlchemy 2.0.25
  • PostgreSQL 14.10
  • Redis 7.2.3
  • 机器:4C8G,SSD

三、定位瓶颈:py-spy + 慢查询日志

3.1 用 py-spy 抓火焰图

线上不敢随便 attach debugger,py-spy 是最稳的选择,几乎零开销:

pip install py-spy==0.3.14

# 找到 gunicorn worker 的 pid
ps aux | grep gunicorn

# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 200

打开 profile.svg 一看,超过 60% 的采样时间堆在 SQLAlchemy 的 session.execute,而且调用栈里反复出现同一个函数 get_product_detail。再细看,这个函数里有个循环:

# 老代码,问题所在
def get_product_detail(product_id):
    product = Product.query.get(product_id)
    # N+1 元凶:循环里查 SKU,每个 SKU 又查库存
    for sku in product.skus:
        sku.stock = Inventory.query.filter_by(sku_id=sku.id).first().stock
    return product.to_dict()

一个商品平均 8 个 SKU,等于一次请求打 1 + 8 + 8 = 17 次 SQL。QPS 200 的时候,数据库每秒要扛 3400 次查询,PG 直接跪。

3.2 用 PG 慢查询日志确认

-- postgresql.conf
log_min_duration_statement = 100  -- 记录超过100ms的

慢日志里清一色是 SELECT * FROM inventory WHERE sku_id = $1,单次 2-5ms,但次数太密。

结论:瓶颈是 N+1 查询 + 无缓存,应用层 CPU 高是因为 ORM 反序列化开销大。

四、方案设计

方向定了:

  1. 消除 N+1:用 JOIN 一次把 SKU 和库存捞出来
  2. 加缓存:商品详情读多写少,Redis 缓存 60 秒,命中率能到 95%+
  3. 换框架:Flask 的 sync worker 在高并发下切换开销大,换成 FastAPI + Uvicorn(async),配合 asyncpg
  4. 加索引inventory.sku_id 加索引

整体架构:

Client → Nginx → Uvicorn(FastAPI) → Redis(命中直接返回)
                                  ↓ miss
                              PostgreSQL (JOIN 一次查询)

五、核心实现

5.1 优化后的查询:一次 JOIN 搞定

# models.py
from sqlalchemy import select, join
from sqlalchemy.ext.asyncio import AsyncSession

async def fetch_product_detail(session: AsyncSession, product_id: int):
    stmt = (
        select(Product, Sku, Inventory)
        .join(Sku, Sku.product_id == Product.id)
        .join(Inventory, Inventory.sku_id == Sku.id)
        .where(Product.id == product_id)
    )
    result = await session.execute(stmt)
    rows = result.all()
    if not rows:
        return None
    product = rows[0][0]
    product.skus = [
        {"id": s.id, "name": s.name, "stock": inv.stock}
        for _, s, inv in rows
    ]
    return product

17 次 SQL → 1 次 SQL。

5.2 FastAPI 接口 + Redis 缓存

# main.py
import json
from fastapi import FastAPI, HTTPException
from redis.asyncio import Redis

app = FastAPI()
redis = Redis(host="127.0.0.1", port=6379, decode_responses=True)

CACHE_KEY = "product:detail:{pid}"
CACHE_TTL = 60  # 秒

@app.get("/api/v1/product/{product_id}")
async def get_product(product_id: int):
    key = CACHE_KEY.format(pid=product_id)
    cached = await redis.get(key)
    if cached:
        return json.loads(cached)

    async with async_session() as session:
        product = await fetch_product_detail(session, product_id)
    if not product:
        raise HTTPException(status_code=404, detail="product not found")

    data = {
        "id": product.id,
        "name": product.name,
        "skus": product.skus,
    }
    await redis.setex(key, CACHE_TTL, json.dumps(data))
    return data

启动命令:

uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 --loop uvloop

注意 --loop uvloop,事件循环性能能提升 20% 左右。

5.3 索引

CREATE INDEX CONCURRENTLY idx_inventory_sku_id ON inventory(sku_id);
CREATE INDEX CONCURRENTLY idx_sku_product_id ON sku(product_id);

CONCURRENTLY 是为了不锁表,线上加索引必备。

六、踩坑与优化

坑1:缓存雪崩。 一开始 TTL 全设 60 秒,结果整点批量失效,DB 瞬间被打爆。后来在 TTL 上加了 ±10 秒的随机抖动:

import random
ttl = CACHE_TTL + random.randint(-10, 10)
await redis.setex(key, ttl, json.dumps(data))

坑2:asyncpg + SQLAlchemy 的坑。 AsyncSession 用完之后一定要 await session.close(),否则连接池很快耗尽。我用了 async with 上下文管理器才行。

坑3:序列化开销。 一开始用 Pydantic 模型返回,response_model 在数据量大时序列化开销明显。商品详情字段不多,直接返回 dict 反而更快,实测 P99 差了 15ms 左右。当然如果字段多、要做校验,还是老实上 Pydantic。

坑4:Flask 迁移成本。 其实不是所有接口都值得迁 FastAPI。我们只迁了 QPS 最高的 3 个接口,其他还跑 Flask,两套框架用 Nginx 按路径分流。

七、效果数据

wrk 压测,同一台机器,60 秒,200 并发连接:

wrk -t8 -c200 -d60s --latency http://127.0.0.1:8000/api/v1/product/1001
指标 优化前(Flask) 优化后(FastAPI+Redis)
QPS 180 1900
P50 320ms 12ms
P99 1200ms 80ms
错误率 4.7% 0%
CPU 使用率 98% 45%

Redis 缓存命中率 96.3%(redis-cli info stats 里看的 keyspace_hits / (hits+misses))。

八、总结

这次调优核心就三件事:先用 py-spy 找到真瓶颈,别瞎猜;消 N+1 是收益最高的;缓存是第二收益。框架迁移其实是锦上添花,如果只做前两步,QPS 也能到 800 左右。

几点经验:

  1. profiling 一定要上生产环境采样,本地复现不出真实流量特征
  2. N+1 是 API 性能第一杀手,ORM 用着爽,埋雷也深
  3. 缓存 TTL 加随机抖动,这条几乎是肌肉记忆了
  4. 别为了性能盲目上 async,IO 密集才有效,CPU 密集的接口换 async 反而更慢

后续打算把商品列表接口也照这个思路过一遍,那个接口的 N+1 更夸张,一个列表 20 个商品,能打出 300+ SQL。有进展再写一篇。