一、问题背景

先交代下场景。我们有个电商中台,对外提供商品详情聚合接口 GET /api/v1/products/{id},返回商品基础信息、SKU列表、库存、促销标签、店铺信息。调用方是App首页和H5,QPS峰值大概 320,日均请求 80 万左右。

上线三个月后监控开始报警:P99 从最初的 400ms 一路涨到 2.3s,超时率 1.8%。运维第一反应是加机器,从 4 台扩到 8 台,结果只是把 P99 压到 1.9s,治标不治本。

我接手后的第一件事不是改代码,是先搞清楚时间花在哪。凭直觉优化是最容易翻车的,之前有同事上来就给接口加 lru_cache,结果缓存了带用户维度的数据,直接出了个线上事故。

二、环境与版本

先把环境列清楚,不然数据没法复现:

  • Python 3.11.7
  • FastAPI 0.110.0 + Uvicorn 0.29.0(gunicorn 21.2 + UvicornWorker,4 workers)
  • Flask 3.0.2 + gunicorn 21.2(sync worker,8 workers)
  • SQLAlchemy 2.0.29,asyncpg 0.29.0(FastAPI 侧)/ psycopg2-binary 2.9.9(Flask 侧)
  • PostgreSQL 15.6,主从,读走从库
  • Redis 7.2.4,单节点 8C16G
  • 压测工具:wrk 4.2.0,机器 8C16G,客户端和服务端分开部署

注意一点:压测机和服务端千万别同机,否则 CPU 抢占会让数据完全失真,我第一次就踩了这个坑,测出来的 P99 比线上还高。

三、方案设计

整体思路分四步,顺序不能乱:

  1. Profiling 定位:先找到真正的瓶颈,而不是猜
  2. 数据库层优化:N+1 查询、缺失索引、慢 SQL
  3. 缓存层设计:Redis 缓存 + 本地缓存两级,注意失效策略
  4. 框架与连接池调参:worker 数量、连接池大小、序列化

原则:每一步都压测,拿到数据再进下一步。不做无数据的优化。

四、核心实现

4.1 Profiling:py-spy 火焰图

线上进程不能随便 attach cProfile,性能损耗太大。用 py-spy 采样,开销低于 1%。

pip install py-spy==0.3.14

# 找到 uvicorn worker 的 pid
ps -ef | grep uvicorn

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

# 或者直接看 top,快速定位热点函数
py-spy top --pid 12345

火焰图出来那一刻我就明白了。get_product_detail 这个函数本身只占 8%,剩下 70% 的时间全在 sqlalchemy.orm.loadingasyncpg 上。点进去一看,经典的 N+1:

# 优化前的代码(反面教材)
async def get_product_detail(product_id: int, db: AsyncSession):
    product = await db.get(Product, product_id)
    result = {
        "id": product.id,
        "name": product.name,
        "skus": [],
    }
    # 这里循环查 SKU,一个商品平均 12 个 SKU
    for sku in product.skus:
        stock = await db.execute(
            select(Stock).where(Stock.sku_id == sku.id)
        )
        result["skus"].append({
            "sku_id": sku.id,
            "price": sku.price,
            "stock": stock.scalar_one().quantity,
        })
    return result

一个请求打出去 1 + 1 + 12 = 14 条 SQL,P99 高的时候直接 20+ 条。这就是 2.3s 的元凶。

4.2 数据库优化:selectinload + 联合索引

改造思路:用 selectinload 一次性把关联数据捞出来,把 14 条 SQL 压成 3 条。

from sqlalchemy.orm import selectinload
from sqlalchemy import select

async def get_product_detail(product_id: int, db: AsyncSession):
    stmt = (
        select(Product)
        .options(
            selectinload(Product.skus).selectinload(Sku.stock),
            selectinload(Product.shop),
        )
        .where(Product.id == product_id)
    )
    product = (await db.execute(stmt)).scalar_one_or_none()
    if product is None:
        return None
    return {
        "id": product.id,
        "name": product.name,
        "shop": {"id": product.shop.id, "name": product.shop.name},
        "skus": [
            {
                "sku_id": s.id,
                "price": str(s.price),
                "stock": s.stock.quantity,
            }
            for s in product.skus
        ],
    }

selectinload 会发一条 WHERE sku_id IN (...) 的查询,比 joinedload 更适合一对多场景(不会产生笛卡尔积膨胀)。

索引这块,检查了 skustock 表,发现 stock.sku_id 上只有外键约束没有独立索引,全表扫。补上:

-- 生产环境用 CONCURRENTLY 避免锁表
CREATE INDEX CONCURRENTLY idx_stock_sku_id ON stock (sku_id);
CREATE INDEX CONCURRENTLY idx_sku_product_id ON sku (product_id);

-- 促销标签的联合索引,查询条件是 product_id + status + 时间范围
CREATE INDEX CONCURRENTLY idx_promo_product_status
ON promotion (product_id, status, start_time DESC);

这一步做完,单请求 SQL 从 14 条降到 3 条,P99 从 2.3s → 780ms。

4.3 缓存策略:Redis + 本地两级

780ms 还不够,商品详情是典型的读多写少,缓存是必选项。但直接上 Redis 有两个问题:热点 key、缓存穿透。

我的方案是 本地 LRU + Redis 两级缓存,本地挡掉 60% 的热点请求,Redis 挡掉剩下的大部分。

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

# 本地缓存:最多 2000 个 key,TTL 5 秒
_local_cache: TTLCache = TTLCache(maxsize=2000, ttl=5)
_redis: Redis = Redis(host="redis.internal", port=6379, decode_responses=True)

def _key(product_id: int) -> str:
    return f"product:detail:v3:{product_id}"

async def get_product_cached(product_id: int, db) -> dict:
    key = _key(product_id)

    # L1 本地缓存
    if (cached := _local_cache.get(key)) is not None:
        return cached

    # L2 Redis
    if (raw := await _redis.get(key)) is not None:
        data = json.loads(raw)
        _local_cache[key] = data
        return data

    # 回源
    data = await get_product_detail(product_id, db)
    if data is None:
        # 空值缓存,防止穿透,TTL 短一些
        await _redis.setex(key, 30, "null")
        return None

    # 随机 TTL 300~360 秒,防雪崩
    import random
    ttl = 300 + random.randint(0, 60)
    await _redis.setex(key, ttl, json.dumps(data, ensure_ascii=False))
    _local_cache[key] = data
    return data

几个关键点:

  • 本地 TTL 5 秒:不能太长,否则多实例数据不一致。5 秒是我权衡一致性和命中率后的值,实测本地命中率 58%。
  • Redis TTL 加随机抖动:防止同一时刻大批 key 同时过期打爆数据库。
  • 空值缓存 30 秒:防穿透,商品不存在也缓存一个 null
  • key 带版本号 v3:改结构时直接升版本,避免脏数据,比手动删 key 靠谱。

4.4 连接池与 worker 调参

数据库连接池这块,之前 SQLAlchemy 用的是默认配置,pool_size=5max_overflow=10,QPS 一高就排队等连接。调成:

engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,          # 稳态连接数
    max_overflow=10,       # 峰值可临时扩到 30
    pool_pre_ping=True,    # 防连接失效
    pool_recycle=1800,     # 30 分钟回收,避免被 DB 端 kill
    echo=False,
)

worker 数量公式:(2 * CPU) + 1 是给同步阻塞场景的,FastAPI 是 async,我实测 4C 机器上 4 workers 就够了,加到 8 反而因为上下文切换 P99 涨了 12%。

Flask 那边是同步模型,8 workers 是合适的,但要注意 gunicorn 的 --timeout 默认 30s,配合前面的缓存,实际单请求 200ms 内,不用改。

五、踩坑与优化

踩过的坑记录一下,都是真金白银:

坑 1:lru_cache 缓存了带用户维度的数据。 这是同事之前的事故。functools.lru_cache 的 key 是函数参数,如果参数里带了 user_id 或者 token,很容易内存爆炸;如果漏了维度,就会串数据。涉及用户维度的缓存千万别用 lru_cache,用带显式 key 的 TTLCache。

坑 2:Redis 大 key。 商品详情 JSON 序列化后平均 8KB,热点商品 40KB。GET 单个大 key 在 QPS 高的时候会阻塞 Redis 单线程。方案是拆分:基础信息、SKU 列表分开存,或者对超大商品做压缩。我这边选择对 40KB 以上的用 zstd 压一下,压完 6KB 左右。

坑 3:压测数据失真。 一开始用 wrk -t4 -c100 打,结果 QPS 上不去,排查发现是压测机和服务端同机,CPU 打满了。换机器后 QPS 直接翻倍。另外 -c 并发数不要一上来就 1000,要逐步加压找拐点。

坑 4:selectinload 的 IN 列表太长。 有个商品的 SKU 有 800 多个,IN (...) 拼出来的 SQL 巨长,PostgreSQL 解析都慢。方案是对超过 200 个的做分批查询。这种极端 case 占比不到 0.1%,但 P99 就是被这些长尾拉高的。

六、效果数据

压测命令统一用:

wrk -t8 -c200 -d60s --latency http://api.internal/api/v1/products/12345

对比数据(单位 ms,QPS 为单机):

阶段 QPS P50 P95 P99 错误率
优化前 380 620 1580 2310 0.9%
加 selectinload + 索引 890 210 580 780 0.1%
加两级缓存 4200 38 96 180 0%
连接池调参后 4850 32 82 156 0%

单机 QPS 从 380 干到 4850,提升 12.7 倍。线上机器从 8 台减到 3 台,P99 稳定在 180ms 以内,CPU 使用率从 75% 降到 28%。

FastAPI 和 Flask 的对比:同样逻辑下,加缓存后 FastAPI 单机 QPS 4850,Flask(8 sync workers)大概 3100。差异主要在异步 IO 和序列化上,FastAPI 用 orjson 替换默认 json 还能再涨 8%。但如果你的接口是纯 CPU 计算,两者差距会小很多,别盲目上 FastAPI。

七、总结

复盘一下,这次优化的核心经验就三条:

  1. 先 profiling 再动手。py-spy 火焰图 5 分钟就能定位问题,比看代码猜半天强。90% 的 API 慢都是数据库查询,别一上来就想着加缓存。
  2. N+1 是头号杀手。SQLAlchemy 的 selectinload 是必会技能,配合索引,往往能解决一半以上的问题。
  3. 缓存要设计,不是加个装饰器。两级缓存、TTL 抖动、空值缓存、版本化 key,这些细节决定了缓存是加速还是埋雷。

最后提醒一句:优化完一定要回归压测。我见过太多人改完代码自我感觉良好,上线后 P99 反而涨了。数据不会骗人。

后续还可以做的:接口层面的响应压缩(gzip/brotli)、CDN 边缘缓存、把商品详情做成预计算。但那是下一个故事了。