一、问题背景
去年底接手一个电商中台的商品聚合接口,GET /api/v1/products/feed,给App首页用。逻辑不复杂:按类目分页查商品,每个商品带上标签、库存状态、最近一条评价摘要。
上线初期没问题,后来运营在首页塞了几个大类目,单个类目下商品量从几千涨到十几万,接口开始不对劲。监控上能看到:
- 平均耗时 200ms 左右,看着还行
- P99 直接飙到 1.8s,偶尔 3s+
- QPS 到 50 左右,uwsgi 的 worker 就开始排队,超时告警
典型的"平均值骗人"场景。用户感知的是P99,不是均值。这篇文章就是把这次排查和优化完整复盘一遍,给遇到类似问题的同学一个参考。
二、环境与版本
先把家底交代清楚,不然后面的数字没意义。
- Python 3.11.6
- FastAPI 0.110.0 + Uvicorn 0.27.1(4 workers,
--workers 4) - SQLAlchemy 2.0.27(同步ORM,没用async,这是历史包袱)
- MySQL 8.0.35,InnoDB,buffer pool 4G
- Redis 7.2.4,单机
- 压测工具:wrk 4.2.0,本机到服务同一内网,排除网络干扰
- 机器:8C16G,服务本身没做水平扩展,单机压
压测命令统一用这条,后面所有数据都基于它:
wrk -t8 -c100 -d60s --latency http://127.0.0.1:8000/api/v1/products/feed?category_id=12&page=1&size=20
三、方案设计:先定位,再动手
我的原则是:没profile过的优化都是猜。所以第一步不是改代码,是搞清楚时间花在哪。
Profiling 三板斧:
- py-spy 抓火焰图,看CPU时间分布,不用改代码、不用重启,直接 attach 到进程上。
- SQLAlchemy 慢查询日志,把执行超过 50ms 的SQL打出来,配
echo或事件监听。 - 接口内部分段计时,在关键节点打点,确认是DB慢还是序列化慢。
先上 py-spy:
py-spy record -o profile.svg --pid --duration 30
同时在压测。火焰图一出来,问题很明显:sqlalchemy/engine/base.py 里的 _execute_context 占了 60% 以上的采样,剩下大头在 Pydantic 的序列化。也就是说,瓶颈基本在数据库,不是 Python 逻辑。
再开SQLAlchemy的慢查询记录(SQLAlchemy 2.0 用事件监听更干净):
from sqlalchemy import event
from sqlalchemy.engine import Engine
import logging, time
logger = logging.getLogger("slow_sql")
@event.listens_for(Engine, "before_cursor_execute")
def before_execute(conn, cursor, statement, params, context, executemany):
conn.info["query_start"] = time.perf_counter()
@event.listens_for(Engine, "after_cursor_execute")
def after_execute(conn, cursor, statement, params, context, executemany):
elapsed = (time.perf_counter() - conn.info["query_start"]) * 1000
if elapsed > 50:
logger.warning("SLOW %.1fms | %s | params=%s", elapsed, statement[:300], params)
压测一分钟,日志刷屏了。一个请求打了 41 条 SQL。这就是问题核心:典型的 N+1。
接口逻辑原本长这样(简化版):
# 优化前:每个商品单独查标签、库存、评价
products = db.query(Product).filter(Product.category_id == cid).offset(offset).limit(size).all()
result = []
for p in products:
tags = db.query(Tag).join(ProductTag).filter(ProductTag.product_id == p.id).all()
stock = db.query(Stock).filter(Stock.product_id == p.id).first()
review = db.query(Review).filter(Review.product_id == p.id).order_by(Review.created_at.desc()).first()
result.append(build_item(p, tags, stock, review))
一页20个商品,1条主查询 + 20×3 = 61 条,实际因为有些商品没评价略少,41条对得上。每条SQL哪怕只有2ms,光数据库往返就80ms+,再加上连接池竞争、ORM对象构造开销,P99上秒完全合理。
优化方案定了三条:
- 消灭N+1:标签、库存、评价全部改成批量查询,一次拿全,Python侧做聚合。
- 补索引:
product(category_id, id)联合索引,review(product_id, created_at)联合索引,product_tag(product_id)已有但确认一下。 - 加缓存:feed 首页第一页(page=1)是绝对热点,加 Redis 缓存,TTL 60s,配合主动失效。
四、核心实现
4.1 批量查询替代N+1
改完的核心代码如下,用 SQLAlchemy 2.0 的 select 风格:
from sqlalchemy import select
from sqlalchemy.orm import Session
def get_feed(db: Session, category_id: int, page: int, size: int):
offset = (page - 1) * size
# 1. 主查询:只取需要的列
stmt = (
select(Product.id, Product.title, Product.price, Product.cover)
.where(Product.category_id == category_id, Product.status == 1)
.order_by(Product.id.desc())
.offset(offset).limit(size)
)
rows = db.execute(stmt).all()
if not rows:
return []
pids = [r.id for r in rows]
# 2. 批量查标签
tag_rows = db.execute(
select(ProductTag.product_id, Tag.name)
.join(Tag, Tag.id == ProductTag.tag_id)
.where(ProductTag.product_id.in_(pids))
).all()
tag_map = {}
for pid, name in tag_rows:
tag_map.setdefault(pid, []).append(name)
# 3. 批量查库存
stock_rows = db.execute(
select(Stock.product_id, Stock.quantity, Stock.status)
.where(Stock.product_id.in_(pids))
).all()
stock_map = {pid: (q, s) for pid, q, s in stock_rows}
# 4. 批量查最近评价:用窗口函数一次搞定
from sqlalchemy import func
rn = func.row_number().over(
partition_by=Review.product_id,
order_by=Review.created_at.desc()
).label("rn")
subq = select(Review.product_id, Review.content, Review.rating, rn).subquery()
review_rows = db.execute(
select(subq.c.product_id, subq.c.content, subq.c.rating)
.where(subq.c.rn == 1, subq.c.product_id.in_(pids))
).all()
review_map = {pid: (c, r) for pid, c, r in review_rows}
return [build_item(r, tag_map, stock_map, review_map) for r in rows]
这里有个取舍:评价用窗口函数 ROW_NUMBER() 一次查完,比"每个商品查一条"省了20次往返。MySQL 8.0 对窗口函数支持没问题,执行计划走 review(product_id, created_at) 索引,很快。
改完SQL条数从41条降到 4条(主查询+标签+库存+评价)。
4.2 索引
-- 主查询覆盖排序和过滤
ALTER TABLE product ADD INDEX idx_cat_status_id (category_id, status, id DESC);
-- 评价窗口函数依赖
ALTER TABLE review ADD INDEX idx_product_created (product_id, created_at DESC);
-- 标签关联
ALTER TABLE product_tag ADD INDEX idx_product (product_id);
第一条索引注意 id DESC,因为主查询是 order_by(id desc),联合索引带上排序方向能省掉 filesort。MySQL 8.0 支持降序索引,实测 EXPLAIN 从 Using filesort 变成了 Using index。
4.3 Redis缓存
首页第一页是绝对热点,直接缓存整个响应体:
import json
from redis import Redis
redis = Redis(host="127.0.0.1", port=6379, decode_responses=True)
def get_feed_cached(db: Session, category_id: int, page: int, size: int):
# 只缓存前两页,后面页命中率太低不值得
if page > 2:
return get_feed(db, category_id, page, size)
key = f"feed:{category_id}:{page}:{size}:v3"
cached = redis.get(key)
if cached:
return json.loads(cached)
data = get_feed(db, category_id, page, size)
redis.setex(key, 60, json.dumps(data, ensure_ascii=False, default=str))
return data
TTL 60s 是权衡结果:商品上下架对首页时效要求没到秒级,1分钟可接受;同时商品变更时主动删 key(redis.delete(f"feed:{cid}:1:20:v3")),双保险。
注意 key 里带了 v3,这是版本号,改返回结构时直接升版本,避免旧缓存脏数据,比手动清缓存省心。
五、踩坑与优化
坑1:in_() 参数过多。 一页20个商品没问题,但后来有人把 size 调到 200,IN 列表200个元素,MySQL 优化器选择走全表扫。后来加了硬限制 size <= 50,并在 Pydantic 层校验。
坑2:窗口函数在没索引时反而更慢。 一开始 review 表没加 (product_id, created_at) 联合索引,窗口函数要对全表排序,单条SQL跑了 400ms。加索引后降到 8ms。索引是窗口函数的前提,别搞反顺序。
坑3:连接池配置。 Uvicorn 4 workers,SQLAlchemy 默认 pool_size=5, max_overflow=10,压测到 100 并发时连接不够,出现 QueuePool limit overflow。调到 pool_size=10, max_overflow=20, pool_recycle=1800 后稳定。这个参数要跟 workers 数乘一下,别超过 MySQL 的 max_connections。
坑4:缓存击穿。 热点 key 过期瞬间,100并发全打到DB。加了个简单的互斥锁(redis.set(key+":lock", nx=True, ex=5)),拿不到锁的等50ms重试读缓存。虽然土,但有效,压测时DB QPS峰值从 800 压到 150。
坑5:Pydantic 序列化。 火焰图里序列化占 15% 左右。把 response_model 从嵌套模型改成扁平结构,并用 model_config = ConfigDict(from_attributes=True) 减少校验开销,这部分降到了 7%。不算大头,但顺手做了。
六、效果数据
所有数据都是同一条 wrk 命令、同机器、跑3次取中位数。
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 平均延迟 | 210ms | 42ms | -80% |
| P99 延迟 | 1820ms | 180ms | -90% |
| P99.9 | 3100ms | 340ms | -89% |
| 单机 QPS | 52 | 420 | +708% |
| 单请求SQL数 | 41 | 4(缓存命中时0) | -90% |
| DB CPU 峰值 | 85% | 22% | — |
| 缓存命中率 | — | 91.3% | — |
补充两个细节:
- 缓存命中时(page=1 且热点类目),P99 直接到 38ms,基本就是 Redis 往返 + JSON 反序列化的时间。
- 冷类目(没进缓存)P99 约 210ms,还是比优化前好很多,因为N+1没了。这也说明查询优化是地基,缓存是加速器,顺序不能反。
火焰图前后对比也很直观:优化前 _execute_context 占 60%+,优化后降到 18%,剩下主要是 JSON 序列化和框架开销。
七、总结
这次调优最大的感受是:P99 问题一定要用 profiling 定位,别靠直觉。 我一开始怀疑是 Pydantic 慢,差点去折腾 orjson,结果火焰图打脸——数据库才是大头。
几条可以直接抄走的经验:
- py-spy attach 线上进程,零侵入,先看火焰图再动手。
- SQLAlchemy 事件监听记慢查询,比
echo=True干净,能带耗时。 - N+1 用批量查询 + 窗口函数解决,一页20条从41条SQL降到4条。
- 联合索引注意排序方向,
(category_id, status, id DESC)能消掉 filesort。 - 缓存只缓存热点(前两页),key 带版本号,加互斥锁防击穿。
- 连接池参数 = workers × pool_size,别把 MySQL 打爆。
遗留问题也有:现在还是同步 SQLAlchemy,高并发下 worker 阻塞明显,下一步打算把查询层迁到 async + asyncmy,理论上还能再提一截。另外缓存目前是单机 Redis,后面要上集群。这些等踩完坑再写一篇。
代码都跑在测试环境验证过,数字真实可复现。有类似场景的同学欢迎评论区交流。