一、问题背景:CPU 100%,数据库却很闲
事情起因是监控告警。我们有一个商品聚合查询接口 GET /api/v1/products/{id}/detail,返回商品基础信息、SKU 列表、库存、最近 20 条评价摘要。日请求量约 200 万,高峰期 QPS 在 350 左右。
现象很怪:
- 接口 P95 延迟 1800ms,P99 冲到 3.2s
- 服务 Pod 的 CPU 使用率稳定在 95% 以上,8 核机器
- 但 RDS 的 CPU 只有 20%,慢查询日志里几乎没有记录
- 单条请求平均返回 JSON 约 45KB
第一反应是"数据库慢",但数据不支持这个结论。那就老老实实做 profiling,别猜。
二、环境与版本
先把环境交代清楚,不然数据没法复现:
| 组件 | 版本/配置 |
|---|---|
| 服务 A | FastAPI 0.110.0 + Uvicorn 0.29.0(4 workers) |
| 服务 B | Flask 3.0.2 + Gunicorn 21.2.0(gevent worker,4 workers) |
| Python | 3.11.8 |
| ORM | SQLAlchemy 2.0.29 |
| 数据库 | MySQL 8.0.36,8C16G |
| 缓存 | Redis 7.2.4 |
| 压测工具 | wrk 4.2.0,locust 2.29.0 |
| Profiling | py-spy 0.3.14、cProfile、EXPLAIN ANALYZE |
机器规格:API 服务 8C16G × 3 副本,压测机 16C32G 独立部署,避免互相干扰。
三、方案设计:先定位,再动手
我的原则是:没有 profiling 数据的优化都是玄学。整体分三步走:
- 定位阶段:py-spy 采样火焰图看 CPU 花在哪,cProfile 看函数级耗时,EXPLAIN 看 SQL 执行计划
- 优化阶段:针对热点分别处理——ORM 查询改成批量预加载、加复合索引、引入两级缓存
- 验证阶段:wrk 固定并发压测,对比 P50/P95/P99 和 QPS,同时观察 CPU 与 DB 指标
这里有个关键判断:CPU 高但 DB 闲,说明瓶颈大概率在应用层的 CPU 密集操作(序列化、循环、对象构造),而不是等待 IO。后面 profiling 证实了这个猜测。
四、核心实现
4.1 用 py-spy 抓火焰图
线上服务不能随便挂 profiler,py-spy 的好处是 attach 到运行中的进程,几乎零开销:
# 找到 worker 进程
ps -ef | grep uvicorn
# 采样 30 秒,生成火焰图
py-spy record -o flame.svg --pid 12345 --duration 30 --rate 100
# 或者直接看 top 函数
py-spy top --pid 12345
火焰图一出来,问题非常明显:超过 40% 的 CPU 时间花在 pydantic 的 model_validate 和 jsonable_encoder 上,另外 25% 花在 SQLAlchemy 的 Session.get 循环调用里——典型的 N+1。
4.2 定位 N+1 查询
原代码长这样(简化版):
# 问题代码:N+1 + 重复序列化
@app.get("/api/v1/products/{pid}/detail")
def get_detail(pid: int, db: Session = Depends(get_db)):
product = db.get(Product, pid)
skus = db.query(Sku).filter(Sku.product_id == pid).all() # 1 次
for sku in skus:
sku.stock = db.query(Stock).filter(Stock.sku_id == sku.id).first() # N 次
reviews = db.query(Review).filter(Review.product_id == pid)\
.order_by(Review.created_at.desc()).limit(20).all()
return {"product": product, "skus": skus, "reviews": reviews}
一个商品平均 12 个 SKU,等于每次请求打 1 + 1 + 12 + 1 = 15 条 SQL。虽然单条都很快(走主键/索引),但连接池争用和 ORM 对象构造开销叠加起来非常可观。
改成 selectinload 批量预加载,SQL 数量从 15 降到 3:
from sqlalchemy.orm import selectinload
from sqlalchemy import select
@app.get("/api/v1/products/{pid}/detail")
def get_detail(pid: int, db: Session = Depends(get_db)):
stmt = (
select(Product)
.options(
selectinload(Product.skus).selectinload(Sku.stock),
selectinload(Product.reviews),
)
.where(Product.id == pid)
)
product = db.execute(stmt).scalar_one_or_none()
if product is None:
raise HTTPException(status_code=404)
return build_detail_response(product)
selectinload 会发一条 WHERE sku_id IN (...) 的批量查询,而不是逐条 get。这里别用 joinedload——一对多 join 会导致结果集笛卡尔膨胀,12 个 SKU × 20 条评价 = 240 行,反而更慢。一对多场景 selectinload 是更稳的选择。
4.3 索引优化
对评价表按 (product_id, created_at DESC) 建复合索引,避免 filesort:
ALTER TABLE review ADD INDEX idx_product_created (product_id, created_at DESC);
用 EXPLAIN ANALYZE 验证前后:
-- 优化前:type=ref, rows=1240, Extra=Using filesort
-- 优化后:type=ref, rows=20, Extra=Using index condition
EXPLAIN ANALYZE
SELECT * FROM review WHERE product_id = 10086
ORDER BY created_at DESC LIMIT 20;
实测该查询从 8.2ms 降到 0.6ms。注意 MySQL 8.0 才支持降序索引,5.7 上写 DESC 会被忽略,这点要留意。
4.4 序列化优化:绕开 pydantic 的重复校验
原来返回 ORM 对象,FastAPI 内部会走一遍 jsonable_encoder + pydantic 校验。数据量 45KB 的时候,这部分开销很吓人。改成在 ORM 层直接构造 dict,用 ORJSONResponse 输出:
from fastapi.responses import ORJSONResponse
def build_detail_response(product) -> dict:
return {
"id": product.id,
"name": product.name,
"skus": [
{"id": s.id, "price": str(s.price), "stock": s.stock.qty if s.stock else 0}
for s in product.skus
],
"reviews": [
{"id": r.id, "score": r.score, "content": r.content}
for r in product.reviews[:20]
],
}
@app.get("/api/v1/products/{pid}/detail", response_class=ORJSONResponse)
def get_detail(pid: int, db: Session = Depends(get_db)):
...
ORJSONResponse 比默认的 JSONResponse 快 2~3 倍,尤其在大 payload 场景。代价是失去 pydantic 的响应模型校验——我们通过单元测试覆盖字段结构来补这个缺口,可以接受。
4.5 两级缓存
商品详情是典型的读多写少,天然适合缓存。设计成两级:
- L1:进程内 LRU(
cachetools.TTLCache,maxsize=2000,ttl=30s),挡住突发重复请求 - L2:Redis 7.2,TTL 300s,带随机抖动(±30s)防雪崩
import json, random
from cachetools import TTLCache
import redis
l1 = TTLCache(maxsize=2000, ttl=30)
rds = redis.Redis(host="redis.internal", port=6379, decode_responses=True)
def get_cached_detail(pid: int, db: Session):
key = f"prod:detail:{pid}"
if (hit := l1.get(key)) is not None:
return hit
if (raw := rds.get(key)) is not None:
data = json.loads(raw)
l1[key] = data
return data
data = build_detail_response(load_product(db, pid))
ttl = 300 + random.randint(-30, 30)
rds.setex(key, ttl, json.dumps(data, ensure_ascii=False))
l1[key] = data
return data
Flask 服务那边结构一样,只是把 FastAPI 的依赖注入换成 before_request 里的连接管理。这里不重复贴代码。
4.6 踩坑记录
坑一:缓存 key 没带版本号。 上线新字段后,老缓存还在返回旧结构,前端直接报错。后来 key 统一加 schema 版本前缀 prod:detail:v2:{pid},发布时 bump 版本号即可。
坑二:gevent + Redis 连接池。 Flask 服务用 gevent worker,最初每个请求新建 Redis 连接,压测时出现 Too many open files。改成模块级单例连接池 max_connections=50 后解决。
坑三:selectinload 的 IN 参数爆炸。 有个商品挂了 800 多个 SKU,IN 列表过长导致 SQL 解析变慢。加了 max_skus=200 的业务上限,超出的走异步导出。
坑四:L1 缓存和写操作不一致。 商品改价后 30s 内仍返回旧价。改成写操作时主动 l1.pop(key) + rds.delete(key),读多写少场景下这个成本可以接受。
五、压测数据对比
压测条件:wrk 固定 200 并发连接、持续 5 分钟、请求分布按线上热点商品 ID 的 Zipf 分布模拟。每轮压测前清空缓存并预热 1 分钟。
| 指标 | 优化前 | 优化后(FastAPI) | 优化后(Flask) |
|---|---|---|---|
| QPS | 312 | 2860 | 2140 |
| P50 | 620ms | 32ms | 48ms |
| P95 | 1810ms | 95ms | 140ms |
| P99 | 3240ms | 210ms | 320ms |
| CPU 使用率 | 96% | 38% | 52% |
| 单请求 SQL 数 | 15 | 3(未命中缓存)/ 0(命中) | 同左 |
| 缓存命中率 | - | 94.3% | 93.8% |
分阶段看贡献度(FastAPI 侧,P95):
- 仅修 N+1 + 索引:1810ms → 780ms
- 加 ORJSONResponse + 序列化改造:780ms → 410ms
- 加两级缓存:410ms → 95ms
可以看到,缓存贡献最大,但前两步是必须的——如果直接上缓存,缓存穿透时的毛刺依然会打到 700ms 以上,而且缓存失效瞬间容易击穿 DB。
Redis 侧观察:命中缓存时 RT 约 0.4ms(同机房),L1 命中约 0.02ms。Redis 内存占用约 1.2GB(200 万商品详情,平均 600B/条,含压缩)。
六、总结
这次调优最大的教训是:"CPU 高 DB 闲"这个信号要第一时间重视,它几乎直接指向应用层而非存储层。如果一上来就去加数据库从库或者换连接池,纯属浪费。
几个可以直接复用的结论:
- py-spy attach 生产进程是安全的,采样率 100Hz 对性能影响 < 2%,比停机排查划算得多
- 一对多关系用
selectinload而非joinedload,避免结果集膨胀 - MySQL 8.0 的降序复合索引对
ORDER BY ... DESC LIMIT n效果显著 - 大 payload 场景换
ORJSONResponse,零业务改动拿到 2 倍以上序列化提升 - 两级缓存里 L1 的 TTL 要显著小于 L2(30s vs 300s),否则一致性问题会被放大
- 缓存 key 一定要带 schema 版本号,这是血泪教训
还有一点没做但值得提:目前缓存是请求时懒加载,热点商品首次请求仍会穿透。下一步打算加一个基于访问计数的后台预热任务,把 Top 5000 商品的缓存提前刷好。另外考虑把评价摘要拆成独立接口,让主接口 payload 从 45KB 降到 12KB 左右,进一步压 P99。
调优这事没有终点,只有当前性价比最高的那一档。先把 P95 从 1.8s 干到 95ms,剩下的等业务量再涨一波再说。