一、问题背景

事情是这样的。我们有个商品详情聚合接口 GET /api/v1/products/{id},返回商品基础信息、SKU列表、库存状态和店铺信息。上线初期数据量小,没人关注性能。直到运营做了一次大促预热,监控告警开始疯狂响:P99 延迟 1.2s,5xx 错误率飙到 8%,网关层大量 504。

我接手时的第一反应是"先别猜,测了再说"。很多同学优化性能喜欢凭直觉改代码,比如上来就加缓存、加连接池,结果瓶颈根本不在那儿,白忙活。所以第一步永远是 profiling。

这个接口的技术栈:

  • Python 3.11.6
  • FastAPI 0.109.2 + Uvicorn 0.27.1(4 workers)
  • SQLAlchemy 2.0.25(async + asyncpg 0.29.0)
  • PostgreSQL 15.4
  • Redis 7.2.4
  • 压测工具:wrk 4.2.0 + locust 2.20.1

机器配置:8C16G,接口部署在 K8s 上,单 Pod 4 workers。

二、定位瓶颈:py-spy + cProfile 双管齐下

线上环境不能随便加 cProfile,开销太大。我的做法是:线上用 py-spy 采样,本地用 cProfile 精确定位。

2.1 py-spy 线上采样

py-spy 的好处是 attach 到运行中的进程,几乎零开销:

# 找到 worker 进程
kubectl exec -it product-api-xxx -- ps aux | grep uvicorn

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

火焰图一看就明白了:60% 以上的时间卡在 asyncpg 的 fetch 上,而且调用栈里出现了大量重复的 SELECT ... FROM sku WHERE product_id = $1。这是典型的 N+1 查询——先查商品,再循环查每个 SKU 的库存,每个 SKU 又单独查一次店铺。

2.2 cProfile 本地复现

本地灌了 50 万商品、平均每个商品 8 个 SKU 的数据,用 cProfile 跑单请求:

import cProfile
import pstats
from app.main import app
from fastapi.testclient import TestClient

client = TestClient(app)

def run():
    for _ in range(20):
        client.get("/api/v1/products/12345")

profiler = cProfile.Profile()
profiler.enable()
run()
profiler.disable()

stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(30)

输出里排名靠前的:

ncalls  tottime  cumtime  function
   180    0.002    1.842  asyncpg/connection.py: fetch
    20    0.001    1.201  sqlalchemy/orm/loading.py: load_on_pk_identity
   160    0.008    0.923  app/services/inventory.py: get_stock

单请求 20 次循环,平均每次 92ms,其中数据库 IO 占了 85ms 以上。结论很明确:瓶颈在数据库访问模式,不在 Python 代码本身。

顺带发现一个小问题:Pydantic 序列化也占了约 30ms,因为 SKU 列表里每个对象都做了重复的 model_dump(),这个后面一起优化。

三、方案设计

定位清楚后,方案就顺理成章了,分三层:

  1. 查询层:消灭 N+1,把循环查询改成一次 selectinload 预加载 + 一条聚合 SQL 拿库存;给高频查询字段建复合索引。
  2. 缓存层:商品详情读多写少,加 Redis 缓存,采用 Cache-Aside 模式,TTL 5 分钟 + 主动失效。热点商品加本地 LRU 做二级缓存,扛住瞬时流量。
  3. 序列化层:Pydantic v2 的 model_validate 复用已加载对象,避免重复 dump。

缓存这里有个关键决策:要不要缓存库存? 库存变化频繁,缓存 5 分钟可能导致超卖展示。我的处理是:商品基础信息+SKU 列表缓存 300s,库存单独用一个 10s 短 TTL 的 key,且库存查询走单独的轻量 SQL。这样既保住了性能,又把不一致窗口控制到可接受范围。

四、核心实现

4.1 消灭 N+1:selectinload + 聚合查询

优化前的代码(反面教材):

# ❌ 优化前:典型 N+1
async def get_product_detail(session: AsyncSession, product_id: int):
    product = await session.get(Product, product_id)
    skus = await session.execute(
        select(SKU).where(SKU.product_id == product_id)
    )
    sku_list = skus.scalars().all()

    result = []
    for sku in sku_list:                       # 循环里再查库存
        stock = await session.execute(
            select(Stock).where(Stock.sku_id == sku.id)
        )
        result.append({"sku": sku, "stock": stock.scalar_one_or_none()})
    return product, result

优化后:

# ✅ 优化后:一次预加载 + 一次聚合
from sqlalchemy.orm import selectinload
from sqlalchemy import select, func

async def get_product_detail(session: AsyncSession, product_id: int):
    stmt = (
        select(Product)
        .options(selectinload(Product.skus).selectinload(SKU.shop))
        .where(Product.id == product_id)
    )
    product = (await session.execute(stmt)).scalar_one_or_none()
    if product is None:
        return None

    sku_ids = [sku.id for sku in product.skus]
    # 一条 SQL 拿全部库存,避免循环
    stock_rows = await session.execute(
        select(Stock.sku_id, Stock.quantity, Stock.updated_at)
        .where(Stock.sku_id.in_(sku_ids))
    )
    stock_map = {row.sku_id: row for row in stock_rows}
    return product, stock_map

SQL 从 1 + 1 + N 条(平均 10 条)变成固定 3 条,这是延迟下降的最大贡献项。

4.2 索引优化

用 EXPLAIN ANALYZE 看执行计划,发现 sku.product_id 只有单列索引,但查询实际是 WHERE product_id = ? ORDER BY sort_order,导致额外排序。改成复合索引:

-- 优化前:单列索引,Extra: Using filesort
CREATE INDEX idx_sku_product ON sku(product_id);

-- 优化后:复合索引,覆盖排序
CREATE INDEX CONCURRENTLY idx_sku_product_sort
    ON sku(product_id, sort_order);

-- 库存表按 sku_id 查询,加覆盖索引避免回表
CREATE INDEX CONCURRENTLY idx_stock_sku_cover
    ON stock(sku_id) INCLUDE (quantity, updated_at);

注意线上建索引一定用 CONCURRENTLY,否则会锁表。这个坑我踩过,别问。

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

import json
import redis.asyncio as aioredis
from cachetools import TTLCache

redis = aioredis.from_url("redis://:pwd@redis:6379/0", decode_responses=True)
local_cache = TTLCache(maxsize=2000, ttl=30)  # 本地二级缓存,30s

async def get_product_cached(session, product_id: int):
    cache_key = f"product:detail:{product_id}"

    # L1 本地缓存
    if (hit := local_cache.get(cache_key)) is not None:
        return hit

    # L2 Redis
    if (raw := await redis.get(cache_key)) is not None:
        data = json.loads(raw)
        local_cache[cache_key] = data
        return data

    # 回源
    product, stock_map = await get_product_detail(session, product_id)
    if product is None:
        return None

    data = serialize_product(product, stock_map)
    await redis.setex(cache_key, 300, json.dumps(data, ensure_ascii=False))
    local_cache[cache_key] = data
    return data

更新时主动删缓存,删完再写库(先删后写,降低脏读窗口):

async def update_product(session, product_id: int, payload: dict):
    await session.execute(update(Product).where(Product.id == product_id).values(**payload))
    await session.commit()
    await redis.delete(f"product:detail:{product_id}")
    local_cache.pop(f"product:detail:{product_id}", None)

4.4 压测脚本

用 wrk 快速压,配合 Lua 脚本随机 product_id,模拟真实分布:

-- bench.lua
math.randomseed(os.time())
request = function()
    local id = math.random(1, 500000)
    return wrk.format("GET", "/api/v1/products/" .. id)
end
wrk -t8 -c400 -d60s --latency -s bench.lua http://api.example.com

压测前先灌数据,用 pgbench 或直接 INSERT ... SELECT generate_series,保证 50 万商品、8 SKU 分布,否则压出来的数字没有参考价值。

五、踩坑与优化细节

坑 1:缓存雪崩。 第一版所有 key 统一 300s TTL,压测时同时过期,数据库瞬间被打满。改成 TTL = 300 + random.randint(0, 60),打散过期时间。

坑 2:大 key 序列化开销。 商品详情 JSON 平均 8KB,json.dumps 在 1100 QPS 下 CPU 占用明显。换 orjson 后序列化耗时从 0.4ms 降到 0.08ms。

坑 3:连接池不够。 Uvicorn 4 workers,每个 worker 默认 SQLAlchemy 连接池 5+10,压到 400 并发时出现 TimeoutError: connection pool exhausted。调整 pool_size=10, max_overflow=20, pool_timeout=5,同时 PostgreSQL max_connections 从 100 调到 300。

坑 4:Pydantic 重复序列化。 原来 ORM 对象先 model_dump() 再 jsonable_encoder(),做了两遍。改成 ProductResponse.model_validate(product, from_attributes=True) 一次搞定,省了约 25ms。

坑 5:本地缓存一致性问题。 多 Pod 部署时,本地 LRU 有 30s 延迟。对库存这种强一致字段不缓存,只缓存商品静态信息,业务上可接受。

六、效果数据

压测环境:单 Pod 4 workers,400 并发,60s,数据量 50 万商品。

指标 优化前 优化后 提升
P50 延迟 420ms 28ms 15x
P99 延迟 1210ms 80ms 15x
单机 QPS 180 1100 6.1x
5xx 错误率 8.2% 0.01% -
数据库 QPS 4200 380 -91%
CPU 使用率 92% 41% -

缓存命中率稳定在 96.3%,数据库压力下降 91%,这也解释了为什么 QPS 能翻 6 倍——瓶颈从 DB 转移到了网络和序列化层。

要注意的是,1100 QPS 是缓存全热的情况。冷启动或缓存击穿时,回源路径靠索引优化也能撑住约 350 QPS,比优化前的 180 好不少。

七、总结

这次调优最大的体会是:别猜瓶颈,先 profiling。我一开始也怀疑是 FastAPI 框架慢,结果火焰图直接打脸——框架开销不到 5%,全是 N+1 查询。

三条可复用的经验:

  1. N+1 是 API 性能的头号杀手,SQLAlchemy 里优先用 selectinload,循环里出现 await session.execute 就要警惕。
  2. 索引要看执行计划,EXPLAIN ANALYZE 比拍脑袋建索引靠谱,复合索引的顺序和覆盖字段很关键。
  3. 缓存要有层次和降级,L1 本地 + L2 Redis,TTL 打散,热点字段和强一致字段分开处理。

如果你们接口也有类似问题,建议按"采样定位 → 查询优化 → 缓存兜底 → 压测验证"的顺序来,别一上来就堆机器。机器能解决的问题,代码往往也能解决,而且便宜得多。