一、问题背景
事情起因很朴素:运营反馈商品详情页“转圈圈”。我先看了监控面板,接口 /api/v1/products/{id} 的P99延迟在两周内从1.2s爬到了2.3s,但服务器CPU使用率只有30%左右,内存也稳定。这种“CPU不高但慢”的形态,基本可以排除计算密集型问题,大概率卡在IO等待或者锁上。
再拆一下流量:这个接口QPS峰值约180,单实例部署(4C8G),前面挂Nginx。服务是历史遗留的混合栈——主站用Flask 2.2.5跑在gunicorn sync worker下,新业务用FastAPI 0.110.0跑在uvicorn上,两边共用一个PostgreSQL 14.9和Redis 7.2。商品详情这个接口恰好是Flask写的,而且它内部还通过HTTP调了一次FastAPI的商品推荐服务——一个典型的“同步调异步”的坑。
我的目标很明确:定位瓶颈,把它压到200ms以内,并且给出可复现的压测数据。
二、环境与版本
先把环境交代清楚,不然下面的数字没有参照意义:
- Python 3.11.6
- Flask 2.2.5 + gunicorn 21.2.0(
--workers 4 --worker-class sync) - FastAPI 0.110.0 + uvicorn 0.29.0(
--workers 2) - PostgreSQL 14.9,连接池用 asyncpg 0.29.0 / psycopg2-binary 2.9.9
- Redis 7.2.4,客户端 redis-py 5.0.3
- 压测工具:wrk 4.2.0,
wrk -t4 -c200 -d60s - 监控/分析:py-spy 0.3.14、cProfile、pg_stat_statements
三、方案设计与瓶颈定位
3.1 先做profiling,别猜
第一步是用py-spy对gunicorn worker做采样,因为它对生产环境影响最小(attach上去就行,不用改代码):
# 找到 gunicorn worker 的 pid
ps -ef | grep gunicorn | grep -v master
# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 100
火焰图一出来就很明显了:超过60%的采样落在 socket.recv 和 psycopg2 的等待上。也就是说,worker 大部分时间在等数据库和等下游HTTP响应,而不是在算。这和“CPU 30%”完全对得上——sync worker 被 IO 阻塞了,再多 worker 也只是堆进程。
为了拿到调用级别的细节,我又用 cProfile 在预发环境跑了一轮(生产上cProfile开销太大,不建议常开):
# profile_api.py
import cProfile, pstats, io
from app import create_app
app = create_app()
def run():
with app.test_client() as c:
for _ in range(50):
c.get("/api/v1/products/10086")
pr = cProfile.Profile()
pr.enable()
run()
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
ps.print_stats(25)
print(s.getvalue())
输出里 get_product_detail 内部有个 fetch_recommendations 占了近40%的累计时间,而它只是一次 requests.get 调用;另外 get_product_images、get_product_skus、get_product_attrs 三个查询各查一次库——典型的 N+1。
3.2 定位结论
三个瓶颈,按影响排序:
- 同步HTTP调用下游:Flask worker 用 requests 同步调 FastAPI 推荐服务,平均耗时 480ms,且没有超时设置(默认无限等),下游一抖动就全阻塞。
- N+1 查询:商品详情一次请求打了 4 次库(主表 + 图片 + SKU + 属性),单次往返约 15-25ms,加上连接池争抢,累计 120ms 以上。
- 无缓存:同一个热门商品每秒被查十几次,完全没缓存,数据库白扛。
3.3 方案
- 把商品详情接口整体迁到 FastAPI(async),用
httpx.AsyncClient异步调下游,设 300ms 超时 + 降级; - 四个查询合并为一条带
json_agg的 SQL,一次往返拿全; - 加 Redis 两级缓存:本地 LRU(cachetools,TTL 5s,抗热点)+ Redis(TTL 60s + 随机抖动防雪崩);
- 给
product_id、category_id补索引,并检查pg_stat_statements确认执行计划走了索引。
四、核心实现
4.1 合并查询 + 索引
原来的四次查询改成一条:
-- 索引(先确认已有主键,这里补的是过滤和排序字段)
CREATE INDEX CONCURRENTLY idx_products_category_status
ON products (category_id, status) WHERE status = 'on_sale';
CREATE INDEX CONCURRENTLY idx_product_images_pid_sort
ON product_images (product_id, sort_order);
-- 合并查询
SELECT
p.id, p.title, p.price, p.status, p.updated_at,
COALESCE(imgs.images, '[]'::json) AS images,
COALESCE(skus.skus, '[]'::json) AS skus,
COALESCE(attrs.attrs, '[]'::json) AS attrs
FROM products p
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('id', id, 'url', url, 'sort', sort_order)
ORDER BY sort_order) AS images
FROM product_images WHERE product_id = p.id
) imgs ON TRUE
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('id', id, 'name', name, 'stock', stock)) AS skus
FROM product_skus WHERE product_id = p.id
) skus ON TRUE
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('key', attr_key, 'value', attr_value)) AS attrs
FROM product_attrs WHERE product_id = p.id
) attrs ON TRUE
WHERE p.id = $1;
这条 SQL 在 8 万行商品表上实测 3.1ms(EXPLAIN ANALYZE,命中索引),比原来四次往返的 90ms+ 好太多。注意 LATERAL + json_agg 在子表数据量大时也要留意,如果单个商品图片上千张,建议分页或拆开,别一把梭。
4.2 FastAPI 异步接口 + 缓存
# app/api/product.py
import asyncio, json, random
from fastapi import APIRouter, HTTPException
import asyncpg, httpx
from cachetools import TTLCache
router = APIRouter()
local_cache = TTLCache(maxsize=2000, ttl=5) # 本地热点缓存,5s
SQL = """...""" # 上面那条合并查询
async def get_pool(app):
return app.state.pg_pool
async def fetch_recommend(pid: int) -> list:
"""异步调下游推荐服务,带超时和降级"""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(0.3)) as client:
r = await client.get(f"http://rec-svc/api/rec/{pid}")
r.raise_for_status()
return r.json().get("items", [])
except Exception:
return [] # 降级:推荐失效不影响主流程
@router.get("/api/v1/products/{pid}")
async def product_detail(pid: int, request):
# L1 本地缓存
if pid in local_cache:
return local_cache[pid]
# L2 Redis
redis = request.app.state.redis
cached = await redis.get(f"prod:{pid}")
if cached:
data = json.loads(cached)
local_cache[pid] = data
return data
pool = await get_pool(request.app)
async with pool.acquire() as conn:
row = await conn.fetchrow(SQL, pid)
if not row:
raise HTTPException(404, "product not found")
detail = dict(row)
# 并行:推荐服务与数据组装同时进行
rec_task = asyncio.create_task(fetch_recommend(pid))
detail["recommend"] = await rec_task
payload = json.dumps(detail, default=str)
# TTL 加随机抖动,避免同一批 key 同时过期
await redis.setex(f"prod:{pid}", 60 + random.randint(0, 10), payload)
local_cache[pid] = detail
return detail
连接池初始化用 asyncpg.create_pool(min_size=5, max_size=20),配合 PostgreSQL 的 max_connections=200,两个 uvicorn worker 各 20 条连接,压力完全可控。
4.3 缓存失效
写接口(更新商品)时删缓存,采用“先更新库、再删缓存”的常规策略,并给 key 加版本前缀 prod:v2:{pid},方便整体失效:
async def invalidate(pid: int, redis):
await redis.delete(f"prod:v2:{pid}")
local_cache.pop(pid, None)
多实例部署时本地缓存有 5s 的延迟窗口,业务上可接受(商品详情不是强一致场景),这点我在评审时明确了,避免后续扯皮。
五、踩坑与优化
坑1:httpx 每次请求新建 client。 第一版我图省事在函数里 async with httpx.AsyncClient(),压测发现连接建立开销占了推荐调用的 30%。改成应用级单例 client(在 lifespan 里创建、关闭),复用连接池,那部分耗时直接降到个位数毫秒。
坑2:asyncpg 的 json_agg 返回的是字符串。 json_agg 在 asyncpg 里默认映射成 str 而不是 dict,前端拿到的是转义字符串。解决方式是注册 codec:
async def init_conn(conn):
await conn.set_type_codec(
"json", encoder=json.dumps, decoder=json.loads, schema="pg_catalog"
)
await conn.set_type_codec(
"jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog"
)
pool = await asyncpg.create_pool(dsn, init=init_conn, min_size=5, max_size=20)
坑3:本地缓存把 404 也缓存了。 早期版本没判断空值,某个下架商品被缓存成 None,导致 5 秒内一直返回空。后来只缓存有效结果,空结果走短 TTL(2s)单独 key。
坑4:gunicorn sync worker 的迁移节奏。 我没敢一次性切全量,先按 10% 流量灰度到 FastAPI 实例,观察错误率和 P99 稳定两天后再全量。灰度期间两套服务并存,缓存 key 加了版本前缀隔离,避免脏读。
六、效果数据
压测条件统一:wrk -t4 -c200 -d60s,请求同一批 500 个热门商品 ID(模拟真实热点分布),单实例 4C8G。
| 指标 | 优化前(Flask) | 优化后(FastAPI) |
|---|---|---|
| QPS | 180 | 2100 |
| P50 | 620ms | 28ms |
| P99 | 2310ms | 85ms |
| 平均CPU | 30% | 62% |
| DB 查询次数/请求 | 4 | 1(缓存命中时0) |
| 下游调用超时影响 | 全阻塞 | 300ms降级 |
缓存命中率方面,Redis 层约 92%,本地 LRU 再挡掉约 35% 的 Redis 请求(热门商品集中)。数据库 QPS 从峰值 720 降到 60 左右,pg_stat_statements 里那条合并查询平均 3.1ms。
线上观察一周:P99 稳定在 80-100ms 区间,错误率 0.02%(优化前 0.31%,主要是下游超时导致的 502)。
七、总结
这次调优没有用什么黑科技,核心就三件事:先用 py-spy 和 cProfile 把“慢在哪”钉死,再针对 IO 阻塞做异步化,最后用缓存和索引把重复劳动消掉。几个我认为值得记住的点:
- CPU 低但延迟高,优先怀疑同步 IO 阻塞,别急着加机器;
- sync worker 里调下游 HTTP 一定要设超时,否则一个抖动拖垮整个 worker;
- N+1 在 ORM 时代很常见,用
LATERAL + json_agg合并查询,但要注意子表数据量; - 缓存要设计失效路径和降级,热点 key 的 TTL 加随机抖动是低成本高收益的操作;
- 压测数据要在同一批请求、同一并发下对比,不然数字没意义。
如果你们也有类似的混合栈遗留服务,建议先做 profiling 再决定迁移范围——我们这次只迁了一个接口就拿到了 20 倍 QPS 提升,没必要为了“技术统一”一次性重写全部。