一、问题背景
先交代下场景。我们有个电商中台,对外提供商品详情聚合接口 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 比线上还高。
三、方案设计
整体思路分四步,顺序不能乱:
- Profiling 定位:先找到真正的瓶颈,而不是猜
- 数据库层优化:N+1 查询、缺失索引、慢 SQL
- 缓存层设计:Redis 缓存 + 本地缓存两级,注意失效策略
- 框架与连接池调参: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.loading 和 asyncpg 上。点进去一看,经典的 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 更适合一对多场景(不会产生笛卡尔积膨胀)。
索引这块,检查了 sku 和 stock 表,发现 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=5,max_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。
七、总结
复盘一下,这次优化的核心经验就三条:
- 先 profiling 再动手。py-spy 火焰图 5 分钟就能定位问题,比看代码猜半天强。90% 的 API 慢都是数据库查询,别一上来就想着加缓存。
- N+1 是头号杀手。SQLAlchemy 的
selectinload是必会技能,配合索引,往往能解决一半以上的问题。 - 缓存要设计,不是加个装饰器。两级缓存、TTL 抖动、空值缓存、版本化 key,这些细节决定了缓存是加速还是埋雷。
最后提醒一句:优化完一定要回归压测。我见过太多人改完代码自我感觉良好,上线后 P99 反而涨了。数据不会骗人。
后续还可以做的:接口层面的响应压缩(gzip/brotli)、CDN 边缘缓存、把商品详情做成预计算。但那是下一个故事了。