一、问题背景

事情是这样的。我们有个商品详情接口 GET /api/v1/products/{id},跑在 FastAPI 上,2023 年下半年业务量涨起来之后,监控开始不对劲:

  • P99 延迟从 200ms 一路涨到 1.2s
  • 高峰期 CPU 使用率稳定在 92% 以上
  • PostgreSQL 连接池 pool_size=20, max_overflow=10 经常耗尽,日志里全是 TimeoutError: QueuePool limit of size 20 overflow 10 reached
  • 运维同事每天在群里 @ 我

接口逻辑本身不复杂:查商品主表、查 SKU 列表、查商品图片、查类目信息、查促销标签,五张表拼一个响应体。代码是典型的 ORM 写法,一个 db.query(Product).get(id) 之后跟着一堆懒加载。上线初期数据量小,没人管;数据量上来之后,问题就藏不住了。

这篇文章记录我从定位到优化的完整过程,包括中间踩的坑。不是教科书,是流水账,但数据都是真的。

二、环境与版本

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

组件 版本 / 配置
Python 3.11.6
FastAPI 0.109.0
Uvicorn 0.25.0,workers=4,loop="uvloop"
SQLAlchemy 2.0.25(同步 Session,没用 async)
PostgreSQL 15.4,8C16G,shared_buffers=4GB
Redis 7.2.3,单节点
压测工具 wrk 4.2.0
部署 4C8G 容器 × 2 副本,K8s

注意一个关键点:我们用的是同步 SQLAlchemy Session,跑在 FastAPI 的 def 路由里(FastAPI 会把同步路由丢到线程池执行)。这个选择在后期成了性能天花板之一,后面会讲。

三、方案设计:先定位,再动手

我给自己定的原则是:没有 profiling 数据之前,一行代码都不改。经验告诉我们,拍脑袋优化的结果通常是优化了不痛的地方。

整体分三步:

  1. 定位:cProfile 找函数级热点,py-spy 看线上真实调用栈(不重启进程)
  2. 分类:把耗时拆成「数据库查询」「序列化」「业务逻辑」「外部调用」四块
  3. 优化:按 ROI 排序,先干掉 N+1 和缺索引,再上缓存

3.1 用 cProfile 做函数级剖析

写一个脚本,直接调用接口对应的 service 函数,跑 100 次取统计:

# profile_api.py
import cProfile
import pstats
import io
from app.services.product_service import get_product_detail
from app.db.session import SessionLocal

def run():
    db = SessionLocal()
    try:
        for pid in range(1, 101):
            get_product_detail(db, product_id=pid)
    finally:
        db.close()

if __name__ == "__main__":
    pr = cProfile.Profile()
    pr.enable()
    run()
    pr.disable()

    s = io.StringIO()
    ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
    ps.print_stats(30)
    print(s.getvalue())

输出里有几个刺眼的数字(优化前,单次调用平均):

ncalls  tottime  cumtime  function
  501   0.012    0.980   sqlalchemy.orm.query.Query.all
  ...    0.310    0.310   psycopg2._psycopg.connection.commit  ← 每次请求一次 commit
  ...    0.240    0.240   app.serializers.product.to_dict       ← 序列化居然这么贵

501 次 Query.all——100 次请求调了 501 次查询,平均每个请求 5 次,但这是因为循环里还有 SKU 的逐条加载。实际单请求的 SQL 数量我用 echo=True 数过:1 次商品 + 1 次 SKU 列表 + N 次 SKU 图片 + 1 次类目 + 1 次标签,N 平均 8,总共约 11 条 SQL。典型的 N+1。

3.2 用 py-spy 看线上

cProfile 有 overhead,线上不敢开。py-spy 是采样型 profiler,attach 到进程就行:

# 找到 worker 进程
kubectl exec -it product-api-xxx -- py-spy top --pid 1
# 或者生成火焰图
py-spy record -o profile.svg --pid 1 --duration 30

火焰图上最宽的块是 psycopg2 的 execute 和 wait,占 68%,序列化占 19%,剩下是 FastAPI 框架开销。结论很清晰:瓶颈在数据库,不在框架。

四、核心实现

4.1 干掉 N+1:selectinload 预加载

原代码(简化):

# 优化前
product = db.query(Product).filter(Product.id == pid).first()
skus = db.query(Sku).filter(Sku.product_id == pid).all()
for sku in skus:
    sku.images  # 懒加载,每条 SKU 触发一次 SQL
    sku.promotions  # 又一次

改成 SQLAlchemy 2.0 的 selectinload,一条主查询 + 三条 IN 查询搞定:

# 优化后
from sqlalchemy import select
from sqlalchemy.orm import selectinload

stmt = (
    select(Product)
    .options(
        selectinload(Product.skus).selectinload(Sku.images),
        selectinload(Product.skus).selectinload(Sku.promotions),
        selectinload(Product.category),
    )
    .where(Product.id == pid)
)
product = db.execute(stmt).scalar_one_or_none()

SQL 数量从 11 条降到 4 条。这里有个细节:selectinload 会发 WHERE sku_id IN (...),SKU 数量超过 500 时建议换 joinedload 或者分批,我们单品 SKU 一般不超过 50,够用。

4.2 复合索引

EXPLAIN ANALYZE 看执行计划,skus 表 1200 万行,product_id 上有单列索引,但查询带了 status = 1 AND deleted_at IS NULL,回表过滤比例高:

Seq Scan on skus  (cost=0.00..245123.00 rows=1 width=...)  ← 优化前

建复合索引:

CREATE INDEX CONCURRENTLY idx_skus_product_status
ON skus (product_id, status)
WHERE deleted_at IS NULL;

注意用 CONCURRENTLY 避免锁表,部分索引 WHERE deleted_at IS NULL 把索引体积压了约 30%。建完之后 Index Scan,单条查询从 45ms 降到 2.1ms。

4.3 两级缓存

数据库优化完,单请求 DB 耗时从 320ms 降到 28ms,但 QPS 上来后 DB 还是压力大。上缓存:

  • L1:进程内 cachetools.TTLCache,容量 10000,TTL 30s,抗热点 key
  • L2:Redis,TTL 300s + 随机抖动 ±30s 防雪崩
  • 失效:商品更新走 MQ 消息,消费者删 Redis key,L1 靠短 TTL 自然过期
import json
import random
from cachetools import TTLCache
import redis

l1 = TTLCache(maxsize=10000, ttl=30)
r = redis.Redis(host="redis", port=6379, decode_responses=True)

def get_product_cached(db, pid: int) -> dict:
    if pid in l1:
        return l1[pid]

    key = f"product:v2:{pid}"
    raw = r.get(key)
    if raw:
        data = json.loads(raw)
        l1[pid] = data
        return data

    data = load_from_db(db, pid)          # 4.1 的查询
    if data is None:
        return None

    ttl = 300 + random.randint(-30, 30)   # 防雪崩
    r.setex(key, ttl, json.dumps(data))
    l1[pid] = data
    return data

缓存命中率上线一周后稳定在 L1 62% / L2 31% / 回源 7%,DB QPS 从峰值 1.8 万降到 1.2 千。

4.4 序列化优化

火焰图上序列化占 19%,我们原来用 Pydantic v1 的 from_orm 逐字段转换,商品详情嵌套三层。升级到 Pydantic v2(model_validate + from_attributes=True)后,这块耗时降了约 55%。这个改动成本极低,顺手做了。

五、踩坑与优化

坑 1:缓存 key 没带版本,字段新增后旧 JSON 反序列化炸了。 后来 key 统一加 v2 前缀,schema 变更就升版本,老 key 自然过期。

坑 2:selectinload 嵌套太深,SQL 反而变多。 一开始对 Product.skus.images.promotions 三层全预加载,结果一条请求发了 7 条 SQL。后来只预加载两层,第三层按需合并到业务查询里。

坑 3:同步 Session 在线程池里排队。 workers=4、线程池默认 40,DB 连接池只有 30,高并发下线程全在等连接。把 pool_size 调到 50、max_overflow=20,并给 pool_timeout=5 加超时保护,避免无限等待。根治方案是迁 asyncpg + async Session,但改动面大,排到下个迭代了。

坑 4:压测参数不对导致数据失真。 一开始 wrk 只开 2 个连接,测出来 QPS 虚高。后来固定 -c 200 -t 4 -d 60s,并且压测机和被测服务分开机器,才拿到可信数据。

六、效果数据

wrk 压测,wrk -t4 -c200 -d60s http://api/api/v1/products/12345,两副本:

指标 优化前 优化后 变化
QPS 420 3100 +638%
P50 380ms 21ms -94%
P99 1.21s 38ms -97%
单请求 SQL 数 11 4(缓存命中时 0) -64%
DB CPU 88% 23% —
错误率(超时) 2.3% 0% —

线上灰度一周,P99 稳定在 40ms 上下,连接池告警清零。

七、总结

回头看,这次优化没什么黑科技,就是老老实实走了「profiling → 定位 → 分类 → 按 ROI 排序」的流程。几个真实的体会:

  1. 先测量再动手。cProfile 和 py-spy 花了我半天,但省掉了至少一周的瞎改。
  2. N+1 是 ORM 项目的头号杀手,selectinload 是最低成本的解法,但要控制嵌套深度。
  3. 索引要看执行计划建,复合索引 + 部分索引的组合效果远好于堆单列索引。
  4. 缓存要设计失效和防雪崩,TTL 抖动和版本化 key 是必须的,不是可选项。
  5. 同步 ORM + 线程池有天花板,QPS 上 3000 之后连接池就是下一个瓶颈,async 化要提前规划。

如果你们接口也在几百毫秒徘徊,建议先跑一遍 cProfile,大概率能揪出几个 N+1。代码都在上面,拿去改改就能用。