一、问题背景
事情是这样的。我们有个商品详情聚合接口 GET /api/v1/products/{id},返回商品基础信息、库存、价格、店铺信息和几个推荐位。这个接口被App首页、商品列表页和搜索页同时调用,日均请求量大概200万,峰值QPS在800左右。
上线初期没什么问题,但随着商品数据量涨到千万级、调用方越来越多,监控上P99延迟慢慢爬到了1.8s,高峰期还会冒出一批5s超时的告警。更难受的是,这个接口一慢,会拖累同一个uvicorn worker里的其他接口——因为它内部有同步阻塞调用,事件循环被卡住了。
我先做了几件“止血”的事:把uvicorn worker数从4调到8,加了限流。但这只是把问题往后推,根因没解决。于是决定认真做一次profiling。
二、环境与版本
先把环境交代清楚,不同版本的行为差异挺大的:
- Python 3.11.7
- FastAPI 0.110.0
- Uvicorn 0.27.1(
--workers 8,--loop uvloop) - SQLAlchemy 2.0.29(异步模式,asyncpg 0.29.0)
- PostgreSQL 15.6,商品表约1200万行
- Redis 7.2.4(单机,maxmemory 8GB,
allkeys-lru) - 压测工具:wrk 4.2.0,机器 8C16G,压测机和被测机分开
三、定位:profiling 工具怎么选
线上服务不能随便挂调试器,我分了两层来做。
第一层,线上火焰图用 py-spy。 它通过 process_vm_readv 采样,不需要改代码、不需要重启,对性能影响很小:
# 采样 30 秒,输出火焰图
py-spy record -o profile.svg --pid $(pgrep -f "uvicorn app.main:app") --duration 30 --rate 200
看火焰图的时候我一眼就发现问题:time.sleep 和 requests.get 占据了大量采样。翻代码发现,接口里调用了两个第三方HTTP服务(运费模板、推荐位),用的是同步 requests,而且没设超时。在async函数里跑同步IO,等于把整个事件循环堵死——这就是为什么它一慢会拖累其他接口。
第二层,单请求级别的细粒度分析用 cProfile。 我在测试环境用中间件包了一层,只对慢请求采样:
import cProfile
import pstats
import io
from fastapi import Request
@app.middleware("http")
async def profile_slow_requests(request: Request, call_next):
profiler = cProfile.Profile()
profiler.enable()
response = await call_next(request)
profiler.disable()
if response.headers.get("x-elapsed-ms") and int(response.headers["x-elapsed-ms"]) > 500:
s = io.StringIO()
pstats.Stats(profiler, stream=s).sort_stats("cumulative").print_stats(20)
logger.warning("slow request profile:\n%s", s.getvalue())
return response
cProfile 的结果里,除了那个同步HTTP调用,还暴露了第二个问题:SELECT ... FROM product_sku WHERE product_id = ? 这个查询被执行了 20 多次——典型的 N+1。代码里对SKU列表循环查库存和价格,每个SKU一次查询。
到这里两个瓶颈就很清楚了:
- async 路由里混用同步阻塞IO(requests + 无超时)
- SKU 维度的 N+1 查询,单请求最多打 20+ 次数据库
四、方案设计与核心实现
4.1 同步阻塞改异步 + 并发编排
把 requests 换成 httpx.AsyncClient,并给所有外部调用加超时(连接 0.5s、读 1.5s),然后用 asyncio.gather 把两个互不依赖的远程调用并发起来。原来串行是 2 × ~400ms,并发后约 400ms。
import asyncio
import httpx
from fastapi import APIRouter, HTTPException
router = APIRouter()
# 全局复用连接池,不要每次请求新建 client
_http_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=0.5, read=1.5, write=1.5, pool=0.5),
limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
http2=True,
)
async def fetch_shipping_template(shop_id: int):
r = await _http_client.get(f"http://shipping-svc/internal/templates/{shop_id}")
r.raise_for_status()
return r.json()
async def fetch_recommendations(product_id: int):
r = await _http_client.get(f"http://rec-svc/internal/recs/{product_id}")
r.raise_for_status()
return r.json()
@router.get("/api/v1/products/{product_id}")
async def get_product(product_id: int):
# 商品主信息、远程调用、SKU 批量查询三者互不依赖,一起并发
product_task = asyncio.create_task(load_product_detail(product_id))
shipping_task = asyncio.create_task(fetch_shipping_template(product_id))
rec_task = asyncio.create_task(fetch_recommendations(product_id))
product, shipping, recs = await asyncio.gather(
product_task, shipping_task, rec_task, return_exceptions=False
)
return {"product": product, "shipping": shipping, "recommendations": recs}
这里有个坑后面会讲:asyncio.gather 里任何一个抛异常,整体就挂了,所以外部服务的降级得单独处理。
4.2 消除 N+1:批量查询 + 索引
原来的写法是循环 SKU 逐个查,改成一次性 IN 查询,同时在 product_sku(product_id) 上补了索引(之前只有主键索引,product_id 上没有索引,1200 万行的表上每次都走顺序扫描)。
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
async def load_product_detail(session: AsyncSession, product_id: int):
stmt = (
select(Product)
.options(selectinload(Product.skus)) # 一条 SQL 批量加载所有 SKU
.where(Product.id == product_id)
)
product = (await session.execute(stmt)).scalar_one_or_none()
if product is None:
raise HTTPException(status_code=404, detail="product not found")
sku_ids = [sku.id for sku in product.skus]
# 库存和价格一次 IN 查询搞定,替代原来的 for 循环
stock_rows = await session.execute(
select(SkuStock.sku_id, SkuStock.available).where(SkuStock.sku_id.in_(sku_ids))
)
stock_map = {row.sku_id: row.available for row in stock_rows}
for sku in product.skus:
sku.available = stock_map.get(sku.id, 0)
return product
对应的索引变更:
CREATE INDEX CONCURRENTLY idx_product_sku_product_id ON product_sku (product_id);
CREATE INDEX CONCURRENTLY idx_sku_stock_sku_id ON sku_stock (sku_id);
用 CONCURRENTLY 是为了不锁表,线上千万级表直接 CREATE INDEX 会阻塞写入,这个后面踩坑里细说。
4.3 两级缓存
商品详情是典型的读多写少,非常适合缓存。我做了两级:本地 LRU(cachetools 5.3.3,TTL 30s,容量 5000)+ Redis(TTL 300s,加随机抖动防雪崩)。本地缓存挡掉热点 key,Redis 挡掉跨 worker 的重复查询。
import json
import random
from cachetools import TTLCache
_local_cache = TTLCache(maxsize=5000, ttl=30)
async def get_product_cached(redis, session, product_id: int):
key = f"product:detail:{product_id}"
if (hit := _local_cache.get(key)) is not None:
return hit
if raw := await redis.get(key):
data = json.loads(raw)
_local_cache[key] = data
return data
data = await load_product_detail(session, product_id)
payload = serialize_product(data)
# TTL 加 0~60s 抖动,避免同一批 key 同时过期造成缓存击穿
await redis.set(key, json.dumps(payload), ex=300 + random.randint(0, 60))
_local_cache[key] = payload
return payload
写入侧在商品更新时主动删 Redis key,本地缓存靠 30s TTL 自然过期,接受最多 30s 的不一致(业务上可接受,价格敏感字段走单独接口实时查)。
五、踩坑与优化
坑1:asyncio.gather 没有降级。 改造后推荐服务偶尔抖动,直接让整个接口 500。后来给远程调用包了一层,失败时返回空推荐位而不是抛异常:
async def safe_fetch(coro, default):
try:
return await coro
except (httpx.HTTPError, asyncio.TimeoutError) as e:
logger.warning("remote call failed: %s", e)
return default
坑2:CREATE INDEX CONCURRENTLY 在事务里不能执行。 我一开始用 alembic migration 跑,报 CREATE INDEX CONCURRENTLY cannot run inside a transaction block。解决办法是在 migration 里 op.execute("COMMIT") 后再执行,或者干脆手动在低峰期跑。另外 CONCURRENTLY 失败会留下 invalid 索引,记得查 pg_index.indisvalid 清理。
坑3:SQLAlchemy 异步 session 没有正确关闭。 压测时连接数飙到 300+,PG 报 too many clients。原因是依赖注入里手动 new session 忘了 close。改成 async with async_session() as session 之后连接数稳定在 50 以内。
坑4:uvloop 和某些 C 扩展不兼容。 我们有个老的日志库用了 gevent monkey patch,和 uvloop 冲突,表现是偶发卡死。最后是把这个库换掉才彻底解决。这也是我建议 profiling 一定要看火焰图的原因——这种问题看日志根本看不出来。
坑5:本地缓存的序列化开销。 一开始本地缓存存的是 ORM 对象,每次序列化 JSON 要 3~5ms,反而成了新瓶颈。改成缓存序列化后的 dict,直接命中返回,省了这步。
六、效果数据
压测条件:wrk 4.2.0,8 线程 200 连接,持续 5 分钟,数据为 3 轮取中位数。
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| QPS | 780 | 4200 | +438% |
| P50 | 620ms | 38ms | -94% |
| P95 | 1450ms | 95ms | -93% |
| P99 | 1820ms | 120ms | -93% |
| 错误率 | 0.8%(超时) | 0.00% | - |
| DB 查询次数/请求 | 23 | 3 | -87% |
| 单请求远程调用耗时 | ~800ms(串行) | ~420ms(并发) | -47% |
线上灰度后观察一周,P99 稳定在 100~140ms,高峰期没有再出现超时告警,同 worker 下其他接口的延迟也顺带降了约 30%——这印证了之前同步阻塞拖累事件循环的判断。
缓存命中率方面,本地 LRU 命中约 62%,Redis 再命中约 28%,整体回源率不到 10%。
七、总结
这次调优最大的体会是:先定位,再动手。我一开始凭直觉调 worker 数、加限流,全是治标。真正用 py-spy 看火焰图、用 cProfile 看单请求调用栈之后,两个瓶颈(同步阻塞、N+1)十分钟内就暴露了。
几点可以复用的经验:
- FastAPI 的 async 路由里绝对不能出现同步阻塞 IO,
requests、同步 DB driver、time.sleep都是雷,火焰图上会非常明显。 - ORM 的 N+1 在 profiling 里表现为同一个 SQL 模板高频重复,看到就上
selectinload或批量IN查询。 - 大表建索引一定用
CONCURRENTLY,并且提前确认不在事务里。 - 缓存做两级,TTL 加抖动,写入侧主动失效,本地缓存接受短时间不一致,这是性价比最高的组合。
- 压测数据和线上监控要对照着看,压测机和服务同机部署会互相干扰,数字会骗人。
如果你们也有类似的慢接口,建议先从火焰图开始,别急着改代码。