问题背景
去年底接手了一个商品服务,核心接口 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 两次。
方案设计
定位清楚后,方案分三层:
- 查询层:把4次查询合并成1次主查询 + 3个
selectinload预加载(SQLAlchemy 2.0 的selectinload会发一条IN查询,比joinedload更适合一对多,避免笛卡尔积膨胀)。 - 缓存层:商品详情读多写少,加 Redis 缓存,key 为
product:detail:{id},TTL 300s,写操作时主动删除。用cachetools做进程内 L1 兜底(TTL 10s),挡住热点key。 - 连接层:
pool_size调到 20,max_overflow30,加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查询上。四个查询串行是主因,缓存是放大器而不是根因。
几个可以带走的经验:
selectinload对一对多比joinedload稳,不会因为笛卡尔积把结果集撑爆,列表接口收益尤其明显。- 连接池参数要和数据库
max_connections对齐算,pool_size × 副本数是硬约束。 - 缓存分层(进程内 + Redis)对热点key很有效,但要注意哪些字段不能缓存(库存、价格这类强一致字段)。
- 尾部延迟问题优先看"每个请求做了几次IO",而不是单次IO有多快。
如果后续QPS继续涨,下一步会考虑把商品详情改造成 async(asyncpg + httpx),以及给缓存加 singleflight 防击穿。但就目前3100 QPS、P99 86ms的水平,这套同步方案还能撑一阵子,不折腾了。