一、问题背景

去年底接手一个电商中台的商品聚合接口,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 三板斧:

  1. py-spy 抓火焰图,看CPU时间分布,不用改代码、不用重启,直接 attach 到进程上。
  2. SQLAlchemy 慢查询日志,把执行超过 50ms 的SQL打出来,配 echo 或事件监听。
  3. 接口内部分段计时,在关键节点打点,确认是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上秒完全合理。

优化方案定了三条:

  1. 消灭N+1:标签、库存、评价全部改成批量查询,一次拿全,Python侧做聚合。
  2. 补索引:product(category_id, id) 联合索引,review(product_id, created_at) 联合索引,product_tag(product_id) 已有但确认一下。
  3. 加缓存: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,结果火焰图打脸——数据库才是大头。

几条可以直接抄走的经验:

  1. py-spy attach 线上进程,零侵入,先看火焰图再动手。
  2. SQLAlchemy 事件监听记慢查询,比 echo=True 干净,能带耗时。
  3. N+1 用批量查询 + 窗口函数解决,一页20条从41条SQL降到4条。
  4. 联合索引注意排序方向,(category_id, status, id DESC) 能消掉 filesort。
  5. 缓存只缓存热点(前两页),key 带版本号,加互斥锁防击穿。
  6. 连接池参数 = workers × pool_size,别把 MySQL 打爆。

遗留问题也有:现在还是同步 SQLAlchemy,高并发下 worker 阻塞明显,下一步打算把查询层迁到 async + asyncmy,理论上还能再提一截。另外缓存目前是单机 Redis,后面要上集群。这些等踩完坑再写一篇。

代码都跑在测试环境验证过,数字真实可复现。有类似场景的同学欢迎评论区交流。