一、问题背景

事情是这样的。我们有个商品详情接口 GET /api/v1/products/{id},用 FastAPI 写的,上线两个月一直相安无事。直到运营搞了一次大促预热,流量涨了大概三倍,监控面板直接红了:

  • P99 延迟:1.8s(正常时段也有 600ms 左右)
  • 数据库 CPU:峰值 82%,慢查询日志每分钟几十条
  • 接口超时率:3.7%

更诡异的是,这个接口逻辑看起来特别简单——查一个商品,带上它的规格、图片、几个标签,返回 JSON。代码拢共不到 80 行。我当时的第一反应是"这么简单的接口怎么会慢",事实证明,简单的是业务逻辑,不是执行路径。

这篇文章就是我排查和优化的完整记录,包括中间踩的坑。先说结论:90% 的收益来自三件事——找到那条 N+1 查询、加对索引、给热点数据加缓存。剩下 10% 是各种细节打磨。

二、环境与版本

先把家底交代清楚,不然数据没法复现:

组件 版本/配置
Python 3.11.6
FastAPI 0.109.2
Uvicorn 0.27.1,workers=4,--loop uvloop
SQLAlchemy 2.0.25(async 模式,asyncpg 驱动)
PostgreSQL 15.4,4C8G,shared_buffers=2GB
Redis 7.2.3,单机 2C4G
压测工具 wrk 4.2.0 + locust 2.24(混合场景)
部署 4 台 4C8G 容器,前面挂 Nginx

压测基线用的是 wrk,固定 200 并发、持续 60 秒:

wrk -t8 -c200 -d60s --latency http://127.0.0.1:8000/api/v1/products/12345

优化前的基线数据:QPS 420,P50 380ms,P99 1.8s。

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

我给自己定的原则是:没抓到证据之前不写一行优化代码。很多性能问题最后发现是"猜错了方向",白改一通还引入 bug。

整体思路分三步:

  1. Profiling 定位:用 py-spy 抓 CPU 火焰图,确认时间到底花在 Python 代码、数据库 IO 还是序列化上。
  2. 数据库层优化:如果瓶颈在 SQL,就看执行计划,干掉 N+1,补索引。
  3. 缓存层:对读多写少的热点数据上 Redis,处理缓存穿透和一致性。

3.1 Profiling:py-spy 抓火焰图

线上服务不能随便加 cProfile,开销太大。py-spy 是采样型的,对进程几乎无侵入,直接 attach 就行:

# 找到 uvicorn worker 进程
ps aux | grep uvicorn

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

火焰图一出来,问题非常明显:超过 65% 的采样落在 asyncpg 的 fetch 等待上,而且调用栈里出现了大量重复的 SELECT ... FROM product_sku WHERE product_id = $1。也就是说,接口在串行地发几十条 SQL。

这就是典型的 N+1 查询。回头翻代码,果然:

# 优化前的写法(问题代码)
@router.get("/products/{product_id}")
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
    product = await db.get(Product, product_id)
    if not product:
        raise HTTPException(404)

    # 每次循环都单独查一次 SKU 表
    skus = []
    for sku_id in product.sku_ids:
        sku = await db.get(ProductSku, sku_id)
        skus.append(sku)

    # 图片、标签同理,又是一个循环一条 SQL
    images = [await db.get(ProductImage, i) for i in product.image_ids]
    ...

一个商品平均 20 个 SKU、8 张图、5 个标签,等于一个请求打 33 条 SQL。200 并发下数据库连接池直接被打爆,请求排队,延迟自然飙到秒级。

顺带说一下,我还用 asyncio 的事件循环监控确认了一下:worker 的 event loop 并没有被 CPU 密集操作卡住,纯粹是 IO 等待。这排除了"序列化太慢"之类的怀疑。

四、核心实现

4.1 干掉 N+1:selectinload 一次查完

SQLAlchemy 2.0 的 async 模式提供了几种预加载策略。这里选 selectinload 而不是 joinedload,原因是:一对多关系用 JOIN 会导致主表数据重复膨胀(一个商品 20 个 SKU,JOIN 出来主表字段重复 20 次),而 selectinload 是"主查询 + 一条 IN 查询",总共 2 条 SQL,干净利落。

# 优化后的查询
from sqlalchemy.orm import selectinload
from sqlalchemy import select

STMT = (
    select(Product)
    .options(
        selectinload(Product.skus),
        selectinload(Product.images),
        selectinload(Product.tags),
    )
    .where(Product.id == product_id)
)

@router.get("/products/{product_id}")
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(STMT)
    product = result.scalar_one_or_none()
    if not product:
        raise HTTPException(404)
    return ProductOut.model_validate(product)   # Pydantic v2

SQL 数量从 33 条降到 3 条(1 条主查询 + 2 条 IN 查询,标签和图片可以合并逻辑)。这一步单独测下来,P99 从 1.8s 降到 900ms 左右。

4.2 索引:别只看 EXPLAIN,要看 data page

SQL 少了,但还是慢。上 EXPLAIN (ANALYZE, BUFFERS) 看执行计划:

Seq Scan on product_sku  (cost=0.00..18432.00 rows=1200000 width=...)
  Filter: (product_id = 12345)
  Buffers: shared hit=12 read=9841

product_sku 表 120 万行,全表扫描,读了 9841 个数据页。product_id 上压根没索引。

加索引之前有个取舍:SKU 表的写入频率不低(运营后台批量改价),索引不能乱加。我最终只加了一个复合索引:

-- 覆盖查询条件 + 排序字段,避免额外排序
CREATE INDEX CONCURRENTLY idx_sku_product_id_sort
ON product_sku (product_id, sort_order)
WHERE deleted_at IS NULL;

注意两个细节:

  1. CONCURRENTLY:线上表加索引必须用这个,否则会锁表。代价是建得慢,但业务无感。
  2. 部分索引 WHERE deleted_at IS NULL:我们的查询永远只查未删除数据,软删除的行占了三成,排除掉之后索引体积小了 30%,扫描更快。

加完索引再跑 EXPLAIN:

Index Scan using idx_sku_product_id_sort on product_sku
  Index Cond: (product_id = 12345)
  Buffers: shared hit=6

数据页读取从 9841 降到 6。这一步把 P99 又拉低到 400ms 上下。

product_image 和 product_tag 表同理,各补了一个 (product_id) 的普通索引。

4.3 缓存:Redis + 本地二级缓存

到 400ms 这个水位,数据库已经不再是瓶颈了(CPU 降到 30%)。但商品详情是典型的读多写少场景,读写比大概 200:1,不上缓存说不过去。

设计上做了两层:

  • L1:进程内 LRU(cachetools.TTLCache,容量 2000,TTL 5 秒)。挡掉同一 worker 内的瞬时重复请求,尤其大促时的热点商品。
  • L2:Redis,TTL 10 分钟 + 随机抖动 60 秒,防止缓存雪崩。
import json
import random
from cachetools import TTLCache

local_cache = TTLCache(maxsize=2000, ttl=5)

async def get_product_cached(product_id: int, db: AsyncSession, redis: Redis):
    # L1
    if (hit := local_cache.get(product_id)) is not None:
        return hit

    # L2
    key = f"product:v2:{product_id}"
    if raw := await redis.get(key):
        data = json.loads(raw)
        local_cache[product_id] = data
        return data

    # 回源数据库
    result = await db.execute(STMT)
    product = result.scalar_one_or_none()
    if product is None:
        # 缓存空值,防穿透,TTL 短一些
        await redis.set(key, "null", ex=60)
        raise HTTPException(404)

    data = ProductOut.model_validate(product).model_dump(mode="json")
    ttl = 600 + random.randint(0, 60)          # 抖动防雪崩
    await redis.set(key, json.dumps(data), ex=ttl)
    local_cache[product_id] = data
    return data

缓存一致性:商品更新走的是"先更新数据库,再删缓存"(Cache-Aside),删除失败时丢进一个延迟队列重试。没用双写,因为我们的商品数据有多个写入入口(后台、导入脚本、价格服务),双写维护成本太高,删缓存更稳。key 里带 v2 是为了以后改结构时能平滑切换,不用写迁移脚本。

五、踩坑与优化

坑 1:selectinload 遇上大集合会炸。有个测试商品挂了 3000 个 SKU,IN 查询直接生成了 3000 个参数,PostgreSQL 报参数上限。后来在业务层限制了单商品 SKU 数量(产品上也不合理),并对超大集合改成分页查询。

坑 2:Redis 大 key。一开始把整个商品对象(含富文本描述)塞进去,单个 value 有 200KB。后来把描述字段拆出去单独缓存,主缓存只留列表页需要的字段,平均 value 降到 8KB。Redis 的 --bigkeys 扫一遍就能发现这类问题。

坑 3:连接池配置。优化前 pool_size=5, max_overflow=10,4 个 worker 一共 60 个连接,200 并发下大量请求在等连接。调整为 pool_size=20, max_overflow=10,配合 pool_pre_ping=True,并且把 PostgreSQL 的 max_connections 提到 300。这里别拍脑袋,用公式估:连接数 ≈ worker数 × (pool_size + max_overflow),再给 DBA 留 30% 余量。

坑 4:JSON 序列化开销。Pydantic v2 的 model_dump 比 v1 快很多,但每次请求都做一遍还是浪费。缓存命中时直接返回序列化好的 dict,绕开模型校验——反正数据是从我们自己写进 Redis 的,可信。

六、效果数据

压测条件不变(wrk,200 并发,60 秒),各阶段对比:

阶段 QPS P50 P99 DB CPU
优化前 420 380ms 1.8s 82%
消除 N+1 980 150ms 900ms 65%
+ 索引 1600 85ms 400ms 38%
+ 两级缓存 2600 45ms 120ms 25%

缓存命中率稳定在 96.3%(Redis 命中 91%,L1 再挡掉一部分)。线上灰度一周后,接口超时率从 3.7% 降到 0.02%,P99 稳定在 110-130ms 之间波动。

另外补充一个 locust 混合场景(详情页 70% + 列表页 20% + 下单 10%)的结果:整体吞吐从 610 RPS 提升到 2900 RPS,错误率 0。

七、总结

回头看,这次调优没什么高深技巧,全是基本功:

  1. 先测量再动手。py-spy 火焰图十分钟就锁定了 N+1,比我拍脑袋猜一天都强。工具链上,py-spy 看 CPU/IO 分布、EXPLAIN (ANALYZE, BUFFERS) 看真实页读取、--bigkeys 查 Redis,这三板斧覆盖了大部分场景。
  2. ORM 的便利是有代价的。懒加载在脚本里很爽,在 API 里就是隐形炸弹。团队后来加了个 lint 规则,禁止在路由层直接用关系属性触发懒加载。
  3. 索引要结合查询模式设计。复合索引的字段顺序、部分索引的过滤条件,都是从实际 SQL 反推出来的,不是"哪个字段查得多就加哪个"。
  4. 缓存不是万能药,但没有它真不行。读写比 200:1 的场景,不上缓存等于让数据库做无用功。但缓存穿透、雪崩、大 key 这些坑,一个都别想躲。

最后说句实在话:这个接口的代码量优化后反而变多了(从 80 行到 130 行),但每一行都有明确的性能意图。性能优化不是把代码写短,是把不确定性写没。