问题背景

去年底接手了一个商品服务,核心接口 GET /api/v1/products/{id} 返回商品详情,包括基础信息、SKU列表、库存、标签。业务方反馈这个接口"偶尔很慢",监控上看P99常年在1.1s~1.3s之间波动,P50其实只有40ms左右——很典型的尾部延迟问题。

更麻烦的是这台机器CPU利用率长期在85%以上,扩容到4个Pod也只是把QPS从100出头撑到420,成本涨了四倍效果一般。所以决定不急着扩容,先搞清楚时间到底花在哪。

环境与版本

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

  • Python 3.11.6
  • FastAPI 0.109.2,uvicorn 0.27.0(4 worker,--workers 4)
  • SQLAlchemy 2.0.25(同步ORM,非async)
  • PostgreSQL 15.4,连接池 pool_size=5, max_overflow=10
  • Redis 7.2.3,redis-py 5.0.1
  • 压测工具:wrk 4.2.0,wrk -t8 -c200 -d60s
  • 机器:4C8G 容器,Pod 副本数 4

接口大概长这样,典型的"顺手写"版本:

@app.get("/api/v1/products/{product_id}")
def get_product(product_id: int, db: Session = Depends(get_db)):
    product = db.query(Product).filter(Product.id == product_id).first()
    if not product:
        raise HTTPException(404)
    skus = db.query(SKU).filter(SKU.product_id == product_id).all()
    stock = db.query(Stock).filter(Stock.product_id == product_id).all()
    tags = db.query(Tag).filter(Tag.product_id == product_id).all()
    return build_response(product, skus, stock, tags)

定位:profiling 先于优化

我个人的习惯是:没有profile数据的优化都是猜。这里用了两个工具,一个看宏观、一个看微观。

py-spy 做采样,直接attach到运行中的进程,生产环境可用(只读,开销极小):

pip install py-spy==0.3.14
py-spy top --pid $(pgrep -f "uvicorn main:app" | head -1) --duration 30

采样30秒,火焰图(py-spy record -o profile.svg --pid xxx)出来的结果很直观:约68%的采样落在 psycopg2 的 execute 上,而且调用栈分散在四个不同的 query 调用里。也就是说,一个请求打了4次数据库,其中SKU、Stock、Tag三条是典型的N+1写法(虽然这里不是循环里查,但本质是"主表查完再按外键补查",一次请求4个round trip)。

pg_stat_statements 看SQL层面。开了扩展之后:

SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

结果:SELECT ... FROM sku WHERE product_id = $1 平均执行 3.2ms,但调用次数是商品查询的3倍——因为每次请求都查。单条SQL不慢,慢在次数和网络RTT上。200并发下,4次串行查询的累计RTT就吃掉了大量时间。

再看一个隐藏问题:pool_size=5 配 4 worker,理论最大并发连接20,但200并发压进来,大量请求卡在等连接上。uvicorn 默认 backlog=2048,排队现象被放大了。这一条在压测时表现为P99飙升但CPU没满——跟我们一开始"CPU打满"的观察对不上,后来发现CPU高是另一回事:ORM对象序列化时 build_response 里有个嵌套循环做了 json.dumps 两次。

方案设计

定位清楚后,方案分三层:

  1. 查询层:把4次查询合并成1次主查询 + 3个 selectinload 预加载(SQLAlchemy 2.0 的 selectinload 会发一条 IN 查询,比 joinedload 更适合一对多,避免笛卡尔积膨胀)。
  2. 缓存层:商品详情读多写少,加 Redis 缓存,key 为 product:detail:{id},TTL 300s,写操作时主动删除。用 cachetools 做进程内 L1 兜底(TTL 10s),挡住热点key。
  3. 连接层:pool_size 调到 20,max_overflow 30,加 pool_pre_ping=True,pool_recycle=1800。

另外把 build_response 里的重复 json.dumps 干掉,改成 Pydantic v2 的 model_dump() 一次成型(FastAPI 0.109 默认 Pydantic 2.x,序列化本来就快,之前是自己手写dict又dump了一遍,纯属多余)。

核心实现

查询改造,用 selectinload:

from sqlalchemy.orm import selectinload

@app.get("/api/v1/products/{product_id}", response_model=ProductDetail)
def get_product(product_id: int, db: Session = Depends(get_db)):
    stmt = (
        db.query(Product)
        .options(
            selectinload(Product.skus),
            selectinload(Product.stocks),
            selectinload(Product.tags),
        )
        .filter(Product.id == product_id)
    )
    product = stmt.first()
    if not product:
        raise HTTPException(status_code=404, detail="product not found")
    return product

注意这里有个坑:selectinload 生成的是 WHERE product_id IN (...),单条商品时其实等价于原来的查询,但它把4次round trip压成4条SQL在同一个事务里、由SQLAlchemy批量发出,配合连接复用,实际RTT开销显著下降。如果是列表接口,收益会大得多(一次IN查完所有子表)。

缓存层,L1 + L2:

import json
from cachetools import TTLCache
from redis import Redis

redis_client = Redis(host="redis", port=6379, decode_responses=True)
local_cache = TTLCache(maxsize=5000, ttl=10)  # L1,进程内10秒

def get_product_cached(product_id: int, db: Session) -> dict:
    l1_key = f"p:{product_id}"
    if (hit := local_cache.get(l1_key)) is not None:
        return hit

    l2_key = f"product:detail:{product_id}"
    raw = redis_client.get(l2_key)
    if raw:
        data = json.loads(raw)
        local_cache[l1_key] = data
        return data

    product = _query_product(db, product_id)   # 上面那段selectinload
    if product is None:
        return None
    data = ProductDetail.model_validate(product).model_dump()
    redis_client.setex(l2_key, 300, json.dumps(data, ensure_ascii=False))
    local_cache[l1_key] = data
    return data

写接口里做失效,注意先删缓存再更新DB会有并发脏读窗口,我们用的是"先更新DB,再删缓存"(Cache-Aside),并接受极小概率的短时不一致(业务允许5秒内最终一致):

def update_product(product_id: int, payload: ProductUpdate, db: Session):
    obj = db.query(Product).filter(Product.id == product_id).first()
    if not obj:
        raise HTTPException(404)
    for k, v in payload.model_dump(exclude_unset=True).items():
        setattr(obj, k, v)
    db.commit()
    redis_client.delete(f"product:detail:{product_id}")
    local_cache.pop(f"p:{product_id}", None)

踩坑与优化

坑1:selectinload 和 lazy="dynamic" 冲突。 原来的关系配了 lazy="dynamic",selectinload 直接报错,改成 lazy="select" 才对。

坑2:L1缓存把库存缓存了10秒。 压测时发现超卖投诉,定位到库存字段被进程内缓存住了。解决:把 Stock 从缓存结构里剥离,库存单独走一个短TTL(1s)的key,或者干脆实时查(库存表有索引,单查0.4ms)。最后选了后者,简单。

坑3:连接池调大后PostgreSQL报 too many connections。 4 Pod × (20+30) = 200 个潜在连接,超过了PG的 max_connections=100。最后把 pool_size 收到 12、max_overflow 收到 8,单Pod最多20,4Pod共80,留了余量。连接池不是越大越好,要看数据库端上限。

坑4:Redis大key。 商品详情JSON平均 8KB,热点商品被200并发同时打,L1的 TTLCache 用 maxsize=5000 在4C8G上内存吃紧,调到3000。另外给Redis加了 socket_timeout=0.05,避免Redis抖动拖垮整个接口——缓存挂了应该降级查库,不是跟着挂。

效果数据

同一台机器、同样的 wrk 参数(-t8 -c200 -d60s),改造前后对比:

指标 改造前 改造后
QPS 420 3100
P50 41ms 9ms
P95 620ms 52ms
P99 1210ms 86ms
平均CPU 87% 34%
DB查询次数/请求 4 0.18(命中缓存时0)
缓存命中率 - 94.3%(L1 61% + L2 33%)

压测命令和结果原始输出:

wrk -t8 -c200 -d60s --latency http://127.0.0.1:8000/api/v1/products/10086

# 改造后
Running 1m test @ http://127.0.0.1:8000/api/v1/products/10086
  8 threads and 200 connections
  Latency  Distribution
     50%    9.12ms
     95%   52.30ms
     99%   86.41ms
  Requests/sec:   3102.77
  Transfer/sec:   12.41MB

顺便说一句,缓存命中率94%意味着实际打到DB的请求只有不到6%,这6%里还有一部分是缓存失效瞬间的击穿。后续可以加 singleflight 合并回源,但当前数据已经够用,没继续做。

总结

这次调优最大的感受是:先profile,别猜。一开始团队里有人主张直接上Redis、有人主张换async、有人主张加机器,最后py-spy三十秒就指到了N+1查询上。四个查询串行是主因,缓存是放大器而不是根因。

几个可以带走的经验:

  1. selectinload 对一对多比 joinedload 稳,不会因为笛卡尔积把结果集撑爆,列表接口收益尤其明显。
  2. 连接池参数要和数据库 max_connections 对齐算,pool_size × 副本数 是硬约束。
  3. 缓存分层(进程内 + Redis)对热点key很有效,但要注意哪些字段不能缓存(库存、价格这类强一致字段)。
  4. 尾部延迟问题优先看"每个请求做了几次IO",而不是单次IO有多快。

如果后续QPS继续涨,下一步会考虑把商品详情改造成 async(asyncpg + httpx),以及给缓存加 singleflight 防击穿。但就目前3100 QPS、P99 86ms的水平,这套同步方案还能撑一阵子,不折腾了。