一、问题背景

先说结论:一个看起来很普通的商品详情接口,在FastAPI + SQLAlchemy + PostgreSQL的技术栈上,P99延迟1.8s,QPS压到120就开始雪崩。

这个接口逻辑非常简单:根据商品ID返回商品基本信息、SKU列表、库存、店铺信息、最近10条评价。上线初期数据量小,没人关注。等商品表到了80万行、SKU表300万行、评价表1200万行之后,监控开始报警。

典型的症状是:
- P50延迟还算正常(200ms左右),P99直接飞到1.8s
- CPU使用率在高峰期打到95%以上
- 数据库连接池频繁耗尽,报TimeoutError: QueuePool limit of size 20 overflow 10 reached
- 用户侧表现为"页面转圈,偶尔白屏"

一开始团队怀疑是网络问题、怀疑是K8s调度,折腾了两天没结果。后来老老实实上profiling工具,才发现问题全在自己代码里。

二、环境与版本

先把环境交代清楚,避免版本差异导致结论不可复现:

组件 版本 说明
Python 3.11.6
FastAPI 0.104.1
Uvicorn 0.24.0 workers=4,单机
SQLAlchemy 2.0.23 使用2.0风格查询
asyncpg 0.29.0 异步驱动
PostgreSQL 15.4 8C16G,shared_buffers=4GB
Redis 7.2.3 单机,maxmemory 4GB,allkeys-lru
py-spy 0.3.14 profiling工具
locust 2.19.0 压测工具
机器 8C16G × 2 台 应用层

压测基线:locust 200并发,持续5分钟,请求商品ID随机分布在1~800000之间。

改造前基线数据:

Type     Name              # reqs   # fails   Median  p95    p99    req/s
GET      /api/v1/products  58213    0.31%    210ms   890ms  1800ms 118.6

三、方案设计

定位思路分三步走,每一步都拿数据说话,不猜:

第一步:定位热点。 用py-spy对运行中的worker做采样,生成火焰图,看时间到底花在CPU计算、IO等待还是锁竞争上。同时开SQLAlchemy的慢查询日志(echo_pool + 自定义event listener,超过100ms的SQL打日志)。

第二步:拆解瓶颈。 根据profiling结果分类处理:
- 如果是N+1查询 → 用selectinload / joinedload做预加载
- 如果是索引缺失 → 分析EXPLAIN ANALYZE,补复合索引
- 如果是重复查询 → 上缓存
- 如果是同步阻塞 → 改成异步或丢到线程池

第三步:缓存分层。 不是所有数据都值得缓存。设计上分两层:
- L1:进程内缓存(cachetools.TTLCache),存极热且体积极小的数据,比如店铺基础信息
- L2:Redis,存完整的商品详情聚合结果,TTL 60s + 主动失效

缓存key设计成product:detail:{id}:v{schema_version},schema_version在商品结构变更时手动+1,避免脏数据。

四、核心实现

4.1 Profiling:先看清楚再动手

py-spy的用法很简单,但对生产环境很友好(attach到运行中的进程,不需要重启):

# 找到worker进程
ps -ef | grep uvicorn

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

火焰图上两个明显的"平顶山":
1. asyncpg的fetch调用占了约42%的采样,且调用栈里反复出现load_sku → load_stock → load_shop,典型的N+1
2. 剩下约30%卡在jsonable_encoder上——因为返回的ORM对象太大,Pydantic序列化耗时被低估了

慢查询日志同时印证了这一点:单次请求触发了 47条SQL,其中SKU查询循环了32次。

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

改造前的代码(简化版):

# ❌ 改造前:典型N+1
@app.get("/api/v1/products/{product_id}")
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
    product = await db.get(Product, product_id)
    skus = await db.execute(select(SKU).where(SKU.product_id == product_id))
    sku_list = skus.scalars().all()
    for sku in sku_list:                      # 每个SKU查一次库存
        sku.stock = await db.scalar(
            select(Stock.quantity).where(Stock.sku_id == sku.id)
        )
    shop = await db.get(Shop, product.shop_id)  # 单独查店铺
    reviews = await db.execute(                   # 单独查评价
        select(Review).where(Review.product_id == product_id)
        .order_by(Review.created_at.desc()).limit(10)
    )
    return build_response(product, sku_list, shop, reviews.scalars().all())

改造后,用selectinload一次性预加载,配合lazy="raise"防止意外懒加载:

# ✅ 改造后:预加载 + 复合索引 + 只取需要的列
from sqlalchemy.orm import selectinload
from sqlalchemy import select

@app.get("/api/v1/products/{product_id}")
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
    stmt = (
        select(Product)
        .options(
            selectinload(Product.skus).selectinload(SKU.stock),
            selectinload(Product.shop),
            selectinload(Product.reviews.and_(
                Review.is_deleted == False
            )),
        )
        .where(Product.id == product_id, Product.is_deleted == False)
    )
    product = (await db.execute(stmt)).scalar_one_or_none()
    if product is None:
        raise HTTPException(status_code=404, detail="product not found")

    # 评价只取最近10条,避免把1200万行的表整个拉进内存
    reviews = sorted(product.reviews, key=lambda r: r.created_at, reverse=True)[:10]
    return build_response(product, reviews)

配套的索引(这一步收益巨大,别省):

-- SKU按商品查
CREATE INDEX CONCURRENTLY idx_sku_product_id ON sku (product_id) WHERE is_deleted = false;
-- 库存按SKU查(唯一索引,顺便保证一致性)
CREATE UNIQUE INDEX CONCURRENTLY idx_stock_sku_id ON stock (sku_id);
-- 评价按商品+时间倒序(覆盖排序,避免filesort)
CREATE INDEX CONCURRENTLY idx_review_product_time
    ON review (product_id, created_at DESC) WHERE is_deleted = false;

EXPLAIN ANALYZE验证:评价查询从Seq Scan(1200万行,耗时680ms)变成Index Scan(耗时2.1ms)。

这里有个坑:selectinload会发一条IN (...)查询,如果SKU数量超过几千,IN列表会非常长。我们的商品最多32个SKU所以没问题,但如果你的场景SKU上千,要改成分批加载(selectinload本身不支持分批,得手动拆)。

4.3 缓存策略:L1进程内 + L2 Redis

import json
from cachetools import TTLCache
from redis.asyncio import Redis

# L1:进程内,只存店铺这种小对象,1000条 × 60s
_shop_cache: TTLCache = TTLCache(maxsize=1000, ttl=60)

async def get_shop_cached(shop_id: int, db, redis: Redis) -> dict:
    if (cached := _shop_cache.get(shop_id)) is not None:
        return cached
    key = f"shop:base:{shop_id}:v1"
    if (raw := await redis.get(key)) is not None:
        data = json.loads(raw)
        _shop_cache[shop_id] = data
        return data
    shop = await db.get(Shop, shop_id)
    data = {"id": shop.id, "name": shop.name, "logo": shop.logo}
    await redis.setex(key, 300, json.dumps(data))
    _shop_cache[shop_id] = data
    return data

商品详情聚合结果的缓存,用singleflight模式防止缓存击穿(同一时刻大量请求打到同一个key):

import asyncio
from collections import defaultdict

_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)

async def get_product_detail_cached(product_id: int, db, redis: Redis) -> dict:
    key = f"product:detail:{product_id}:v3"
    if (raw := await redis.get(key)) is not None:
        return json.loads(raw)

    # 同一进程内同key串行,避免击穿
    async with _locks[key]:
        if (raw := await redis.get(key)) is not None:  # double check
            return json.loads(raw)
        data = await build_product_detail(product_id, db)  # 真实查询
        await redis.setex(key, 60, json.dumps(data, ensure_ascii=False))
        return data

注意_locks是进程内的,多worker之间不共享。我们的QPS下这已经够用;如果要求严格,得上Redis分布式锁,但会引入额外RT,权衡后没做。

缓存失效:商品更新时主动删key,同时依赖60s TTL兜底。写操作走await redis.delete(key),失败也不阻塞主流程(记日志+告警)。

五、踩坑与优化

几个真实踩过的坑,值得单独说:

坑1:jsonable_encoder成为新瓶颈。 干掉N+1之后,profiling显示序列化占了35%。原因是返回的是ORM对象,FastAPI内部还要做一次完整编码。解决办法是查询时只select需要的列,直接构造Pydantic model(model_validate with from_attributes=False),序列化耗时从180ms降到22ms。

坑2:连接池配置太保守。 默认pool_size=5, max_overflow=10,4个worker一共才60个连接,高峰期直接排队。调到pool_size=20, max_overflow=10, pool_timeout=5, pool_recycle=1800,配合PostgreSQL的max_connections=200。注意pool_recycle要小于数据库或中间件的空闲超时,否则会遇到"server closed the connection unexpectedly"。

坑3:Redis大key。 一开始把整个商品详情(含32个SKU+库存)序列化成JSON存进去,单key约40KB,GET的RT从0.3ms涨到4ms。后来拆分:主详情key保留,SKU库存用Hash单独存(HSET product:stock:{id}),按需HMGET。单key降到8KB。

坑4:asyncio.Lock在defaultdict里的并发安全问题。 严格说defaultdict的__getitem__在CPython下是原子的,但为了消除歧义,改成了显式的setdefault写法,并且定期清理空锁(我们的实现里锁对象长期存在,量级可控,没做清理)。

坑5:压测环境的假象。 第一次locust压测显示QPS 1200很漂亮,结果是因为压测机到服务的网络走了内网直连,且Redis是空库(全命中L1)。把locust放到独立机器、预热缓存、混入10%的缓存miss请求之后,真实QPS是1400但P99会抖到120ms。压测一定要模拟真实命中率。

六、效果数据

每一轮改造后的locust数据(200并发,5分钟,随机商品ID,缓存命中率约92%):

阶段 QPS P50 P95 P99 单请求SQL数
改造前 118.6 210ms 890ms 1800ms 47
+ 预加载(selectinload) 460 68ms 210ms 420ms 4
+ 复合索引 620 45ms 140ms 260ms 4
+ 列裁剪/序列化优化 780 32ms 95ms 180ms 4
+ Redis L2缓存 1350 12ms 48ms 92ms 0.3(均值)
+ 连接池调优(最终) 1400 11ms 45ms 86ms 0.3

线上真实流量(日均PV 240万)观察一周:P99从1.8s稳定在90ms以内,数据库CPU从85%降到22%,连接池等待告警归零。改造总工时约3人日,其中profiling和验证占了1.5天——定位比优化更花时间,但也更值钱。

七、总结

回头看,这次优化没有用到任何"高级技巧",全是基本功:

  1. 先profiling再动手。火焰图+慢查询日志,半小时就能把"感觉慢"变成"这里慢、慢多少"。没有数据的优化都是赌博。
  2. N+1是API性能的头号杀手。SQLAlchemy 2.0的selectinload配合lazy="raise",能在开发期就把问题暴露出来,强烈建议默认开启。
  3. 索引要按查询模式建,不是按字段建。(product_id, created_at DESC)这种复合索引配合WHERE is_deleted = false的部分索引,收益远超想象。
  4. 缓存是放大器不是救命稻草。查询本身从1.8s优化到180ms之后,缓存才把P99压到86ms;如果查询还是1.8s,缓存击穿时依然会雪崩。
  5. 压测要模拟真实命中率和真实网络,否则数据会骗你。

最后说句实话:这套东西写出来看着挺顺,实际过程是反复试错、改配置、重跑压测,中间还有一次因为索引建错方向导致写入变慢被回滚。性能优化没有银弹,只有"测量—假设—验证"的循环。希望这篇记录能帮你少走两天弯路。