一、问题背景

事情是这样的,我们有个商品详情聚合接口 /api/v1/products/{id}/detail,功能不复杂:返回商品基本信息、SKU列表、库存、促销标签、最近10条评价。上线初期没什么流量,跑得挺快。但最近做了一次运营活动,流量涨了大概10倍,这个接口直接成了重灾区。

监控上看,P99从最初的200ms涨到了1.2s,QPS卡在120左右上不去,CPU单核跑满。更诡异的是,数据库连接池经常被打满,报 QueuePool limit of size 20 overflow 10 reached

这篇文章就把我从定位到优化的全过程记下来,包括踩的坑。

二、环境与版本

先把环境交代清楚,避免版本差异导致结论不适用:

  • Python 3.11.6
  • FastAPI 0.110.0
  • Uvicorn 0.27.1,workers=4(8核机器)
  • SQLAlchemy 2.0.27(同步ORM,没用async)
  • PostgreSQL 15.4
  • Redis 7.2.4,redis-py 5.0.1
  • py-spy 0.3.14
  • locust 2.20.0(压测)

部署是单机8C16G,接口走的是同步def,FastAPI用线程池跑。

三、方案设计:先定位,再动手

我见过太多人一上来就"加缓存",结果缓存加了个寂寞,因为瓶颈根本不在数据库。所以顺序一定是:Profiling → 定位 → 针对性优化 → 压测验证

我的计划:

  1. 用 py-spy 采样火焰图,找到CPU时间花在哪
  2. 开 SQLAlchemy 的 echo 或慢查询日志,看SQL数量和耗时
  3. 根据结果决定:是加索引、改查询、还是加缓存
  4. 每步都用 locust 压测对比

定位:py-spy 抓火焰图

py-spy 的好处是不用改代码,直接attach到进程:

# 找到uvicorn worker的pid
ps aux | grep uvicorn

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

# 或者直接top看实时热点
py-spy top --pid 12345

火焰图一出来就清楚了:sqlalchemy/orm/loading.py 占了将近60%的采样,psycopg2execute 又占其中大头。也就是说,大量时间花在ORM加载和SQL执行上

再看SQL日志,我把 echo=True 打开跑了一次请求,好家伙,一个请求打了 47条SQL。典型的N+1:

  • 1条查商品
  • 1条查SKU
  • 每个SKU单独查一次库存(10个SKU就是10条)
  • 每个SKU查促销标签(又是10条)
  • 1条查评价
  • 评价里的用户信息又每条查一次(10条)

47条SQL,每条哪怕只有5ms,光网络往返+解析就235ms,再加上ORM的对象构造开销,1.2s一点不冤。

四、核心实现:三步优化

第一步:消灭N+1,改批量查询

原来的代码大概长这样(简化版):

# 优化前:典型N+1
@app.get("/api/v1/products/{pid}/detail")
def get_product_detail(pid: int, db: Session = Depends(get_db)):
    product = db.query(Product).filter(Product.id == pid).first()
    if not product:
        raise HTTPException(404)

    skus = db.query(SKU).filter(SKU.product_id == pid).all()

    sku_list = []
    for sku in skus:
        # 每个SKU单独查库存
        stock = db.query(Stock).filter(Stock.sku_id == sku.id).first()
        # 每个SKU单独查促销
        promos = db.query(Promotion).filter(Promotion.sku_id == sku.id).all()
        sku_list.append({
            "id": sku.id,
            "name": sku.name,
            "stock": stock.quantity if stock else 0,
            "promotions": [p.tag for p in promos],
        })

    reviews = db.query(Review).filter(Review.product_id == pid)\
                .order_by(Review.created_at.desc()).limit(10).all()
    review_list = []
    for r in reviews:
        # 每条评价单独查用户
        user = db.query(User).filter(User.id == r.user_id).first()
        review_list.append({"content": r.content, "user": user.nickname})

    return {"product": product.name, "skus": sku_list, "reviews": review_list}

改成批量查询 + selectinload 预加载:

# 优化后:批量预加载,SQL从47条降到4条
from sqlalchemy.orm import selectinload

@app.get("/api/v1/products/{pid}/detail")
def get_product_detail(pid: int, db: Session = Depends(get_db)):
    product = db.query(Product).filter(Product.id == pid).first()
    if not product:
        raise HTTPException(404)

    # 一次查出所有SKU及其关联的库存、促销
    skus = db.query(SKU)\
        .options(
            selectinload(SKU.stock),
            selectinload(SKU.promotions),
        )\
        .filter(SKU.product_id == pid)\
        .all()

    sku_list = [{
        "id": s.id,
        "name": s.name,
        "stock": s.stock.quantity if s.stock else 0,
        "promotions": [p.tag for p in s.promotions],
    } for s in skus]

    # 评价和用户一次join查出来
    reviews = db.query(Review, User)\
        .join(User, User.id == Review.user_id)\
        .filter(Review.product_id == pid)\
        .order_by(Review.created_at.desc())\
        .limit(10)\
        .all()

    review_list = [{"content": r.content, "user": u.nickname} for r, u in reviews]

    return {"product": product.name, "skus": sku_list, "reviews": review_list}

这一步SQL从47条降到4条。

第二步:补索引

看慢查询日志发现两个问题:

  1. review.product_id + created_at 没有联合索引,ORDER BY ... LIMIT 10 走了全表扫
  2. stock.sku_id 有索引但没生效(因为类型不匹配,sku_id是bigint,传进去的是int,PG没做隐式转换)

补索引:

CREATE INDEX CONCURRENTLY idx_review_product_created
    ON review (product_id, created_at DESC);

-- 确认类型匹配后重建
CREATE INDEX CONCURRENTLY idx_stock_sku_id ON stock (sku_id);
ANALYZE review;
ANALYZE stock;

注意用 CONCURRENTLY,线上建索引不锁表。

第三步:加缓存

商品详情这种读多写少的数据,天然适合缓存。策略:

  • 商品基础信息+SKU+促销:缓存 5 分钟(product:detail:{pid}
  • 库存:单独缓存 10 秒(库存变化频繁,缓存太久会超卖感知延迟)
  • 评价:缓存 60 秒

缓存代码:

import json
import redis
from functools import wraps

rds = redis.Redis(host="127.0.0.1", port=6379, db=0,
                  decode_responses=True,
                  socket_timeout=1,
                  socket_connect_timeout=1,
                  max_connections=50)

def cache(key_tpl: str, ttl: int):
    def deco(func):
        @wraps(func)
        def wrapper(pid: int, *args, **kwargs):
            key = key_tpl.format(pid=pid)
            cached = rds.get(key)
            if cached:
                return json.loads(cached)
            result = func(pid, *args, **kwargs)
            rds.setex(key, ttl, json.dumps(result, ensure_ascii=False))
            return result
        return wrapper
    return deco

# 静态部分缓存5分钟
@cache("product:base:{pid}", ttl=300)
def get_base_info(pid: int, db: Session):
    ...

# 库存单独10秒
@cache("product:stock:{pid}", ttl=10)
def get_stock_info(pid: int, db: Session):
    ...

踩坑:一开始我把整个响应体缓存了,包括库存,TTL设了300秒。结果运营改了库存,用户5分钟内看到的还是旧库存,被投诉了。后来拆成"静态部分长缓存 + 库存短缓存"才解决。

还有个坑是缓存击穿:热门商品缓存过期瞬间,大量请求同时打到DB。加了个简单的互斥锁:

def get_with_lock(key: str, ttl: int, loader):
    val = rds.get(key)
    if val:
        return json.loads(val)
    # setnx做轻量锁,避免击穿
    lock_key = f"lock:{key}"
    if rds.set(lock_key, "1", nx=True, ex=3):
        try:
            data = loader()
            rds.setex(key, ttl, json.dumps(data, ensure_ascii=False))
            return data
        finally:
            rds.delete(lock_key)
    else:
        # 没抢到锁,短暂等待后重试一次
        time.sleep(0.05)
        val = rds.get(key)
        return json.loads(val) if val else loader()

五、压测与效果数据

用 locust 压测,同样的场景:100并发用户,持续3分钟,请求同一个热门商品ID。

压测脚本:

from locust import HttpUser, task, between

class ProductUser(HttpUser):
    wait_time = between(0.1, 0.3)

    @task
    def detail(self):
        self.client.get("/api/v1/products/10086/detail")

对比数据:

指标 优化前 优化后 提升
SQL条数/请求 47 4(命中缓存时0) -91%
P50 480ms 32ms 15x
P99 1200ms 85ms 14x
QPS 120 1420 11.8x
CPU单核 100% 35% -
DB连接池 常打满 峰值8/20 -

分阶段看的话:

  • 只做批量查询(47→4条SQL):P99 1200ms → 420ms
  • 加索引:P99 420ms → 260ms
  • 加缓存:P99 260ms → 85ms

可以看到,批量查询贡献最大,缓存是锦上添花但边际收益也很明显。索引那步如果一开始SQL就写对了,其实可以省掉。

六、总结与几个提醒

  1. 别急着加缓存。缓存会掩盖问题,也会引入一致性问题。先把SQL写对,再加缓存。
  2. py-spy 是神器。不改代码、低开销,生产环境直接采样,火焰图一看就知道热点在哪。
  3. N+1 是性能杀手。SQLAlchemy 的 selectinload / joinedload 要熟练,实在不行就手写join。
  4. 索引要配合查询ORDER BY + LIMIT 场景,联合索引的字段顺序很关键,(product_id, created_at DESC)(created_at, product_id) 效果天差地别。
  5. 缓存粒度要拆。静态数据长TTL,动态数据短TTL,别一刀切。库存这种强一致的,宁可短TTL也别缓存太久。
  6. 压测要用真实数据分布。我一开始用随机商品ID压测,缓存命中率虚高;换成热门商品ID(真实场景就是头部集中)才反映真实情况。

最后,如果你也在用Flask,思路完全一样:flask-profilerpy-spy 采样,SQLAlchemy的N+1问题、索引、缓存三件套,代码层面把 selectinload 换成 joinedload 或手写查询即可,压测数字会给你答案。

优化不是玄学,是定位→验证→迭代的工程活。希望这篇记录能帮你少走点弯路。