一、问题背景:一个被串行IO拖垮的聚合接口
去年接手了一个商品详情页的后端接口 /api/v1/product/{id}。业务逻辑很简单:拿到商品ID后,依次调用三个内部服务——
- 库存服务
stock-svc:查可售库存 - 价格服务
price-svc:查实时价格和促销信息 - 评论服务
review-svc:查评论数和好评率
三个服务各自响应时间都在 150~250ms 之间,但代码是串行调用的,所以单次请求的总耗时基本就是三者之和,P99 稳定在 800ms 以上。压测(wrk,4 线程 200 连接,30 秒)下来 QPS 只有 120 左右,CPU 使用率却低得可怜——不到 15%。
问题很清楚:这是一个典型的 IO 密集型场景,线程大部分时间都在等网络,串行调用把等待时间叠加了三倍。当时的实现用的是 requests,跑在 gunicorn + 同步 Flask worker 上,worker 数量开到 8 也救不了,因为每个 worker 在等待下游响应时是完全阻塞的。
改造目标很明确:三个下游调用并发执行,用 asyncio + aiohttp 重写这个接口,同时保证超时、降级、异常处理这些生产环境必须有的东西不能丢。
二、环境与版本
先把版本钉死,异步生态里版本差异带来的坑不少:
- Python 3.11.7(3.11 的 asyncio 在 TaskGroup 和异常处理上有明显改进,建议至少 3.10+)
- aiohttp 3.9.3
- FastAPI 0.109.2 + uvicorn 0.29.0(标准
[standard]extras,带 uvloop) - uvloop 0.19.0
-
压测工具:wrk 4.2.0,机器 4C8G,下游三个服务部署在同机房,RTT ")
def product_detail(pid):
# 三次串行 HTTP 调用,总耗时 = 三者之和
stock = requests.get(STOCK_URL.format(id=pid), timeout=1.0).json()
price = requests.get(PRICE_URL.format(id=pid), timeout=1.0).json()
review = requests.get(REVIEW_URL.format(id=pid), timeout=1.0).json()return jsonify({
"id": pid,
"stock": stock.get("available", 0),
"price": price.get("final_price"),
"promotion": price.get("promotion"),
"review_count": review.get("count", 0),
"good_rate": review.get("good_rate", 0),
})
### 改造后(asyncio 并发版)
```python
# after: FastAPI + aiohttp,并发调用 + 超时 + 降级
import asyncio
import aiohttp
from fastapi import FastAPI
from contextlib import asynccontextmanager
STOCK_URL = "http://stock-svc/api/stock/{id}"
PRICE_URL = "http://price-svc/api/price/{id}"
REVIEW_URL = "http://review-svc/api/review/{id}"
# 全局连接池,进程内复用
session: aiohttp.ClientSession = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global session
connector = aiohttp.TCPConnector(
limit=200, # 连接池总上限
limit_per_host=50, # 单 host 上限
ttl_dns_cache=300, # DNS 缓存 5 分钟
enable_cleanup_closed=True,
)
timeout = aiohttp.ClientTimeout(total=0.8, connect=0.3)
session = aiohttp.ClientSession(connector=connector, timeout=timeout)
yield
await session.close()
app = FastAPI(lifespan=lifespan)
async def fetch_json(url: str, default: dict) -> dict:
"""单次下游调用,失败返回默认值,不抛异常"""
try:
async with session.get(url) as resp:
if resp.status != 200:
return default
return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError):
return default
@app.get("/api/v1/product/{pid}")
async def product_detail(pid: int):
# 三个调用并发执行,整体超时 0.8s
stock_task = fetch_json(STOCK_URL.format(id=pid), {"available": 0})
price_task = fetch_json(PRICE_URL.format(id=pid), {"final_price": None})
review_task = fetch_json(REVIEW_URL.format(id=pid), {"count": 0, "good_rate": 0})
try:
stock, price, review = await asyncio.wait_for(
asyncio.gather(stock_task, price_task, review_task),
timeout=0.8,
)
except asyncio.TimeoutError:
# 整体超时兜底,返回部分可用数据(这里简化为空结构)
stock, price, review = {"available": 0}, {"final_price": None}, {"count": 0}
return {
"id": pid,
"stock": stock.get("available", 0),
"price": price.get("final_price"),
"promotion": price.get("promotion"),
"review_count": review.get("count", 0),
"good_rate": review.get("good_rate", 0),
}
几个关键点解释一下:
TCPConnector参数:limit=200是进程内总连接上限,limit_per_host=50防止单个下游被打爆。这两个值要结合压测调,一开始我设的 100/20,QPS 上到 800 就开始出现连接等待。ClientTimeout(total=0.8, connect=0.3):总超时 0.8s 比业务 SLA 略紧,留出余量给序列化和网络回程。connect 超时单独设 0.3s,避免 TCP 握手阶段卡太久。return_exceptions的取舍:这里没用gather(return_exceptions=True),而是每个 task 内部自己 try/except 吞掉异常返回默认值。这样 gather 本身不会因为异常提前结束,语义更清晰。asyncio.wait_for兜底:即使单个请求内部超时没触发,整体也有硬超时,防止极端情况下 gather 挂住。
五、踩坑与优化
改造过程不是一次成功的,记录几个真实踩过的坑:
坑 1:Session 不能每次请求新建。 最初图省事在路由函数里 async with aiohttp.ClientSession(),结果 QPS 不升反降,连接建立开销把并发收益吃掉了。改成 lifespan 里全局单例后正常。
坑 2:uvloop 和某些 C 扩展冲突。 项目里有个同步的 protobuf 解析库,在 uvloop 下偶发段错误。最后把那段逻辑挪到 run_in_executor 的线程池里跑才稳定。如果你们项目有类似 C 扩展,建议先小流量灰度。
坑 3:workers=4 时连接池是进程级的。 每个 worker 有自己的 200 连接池,实际总连接数是 4×200。下游服务的连接数配额要按 worker 数算,我第一次没注意,把 stock-svc 的连接数打满了,触发了它的限流。
坑 4:同步 ORM 拖后腿。 商品主表信息还是用 SQLAlchemy 同步查的,虽然只有几毫秒,但在高并发下会阻塞事件循环。后来换成了 asyncpg + SQLAlchemy 2.0 的 async 支持,或者临时用 run_in_executor 包一下。事件循环里绝对不能有阻塞调用,这是铁律。
优化点:给三个下游调用加了简单的本地缓存(cachetools.TTLCache,TTL 1s),价格和库存这种秒级变化的字段命中率不高,但评论数这种变化慢的字段命中率能到 60% 以上,进一步压低了 P99。
六、效果数据
压测条件一致:wrk 4 线程 200 连接,30 秒,下游服务响应时间人为控制在 150~250ms。
| 指标 | 改造前(Flask+requests) | 改造后(FastAPI+aiohttp) | 提升 |
|---|---|---|---|
| QPS | 120 | 1100 | 9.2x |
| P50 延迟 | 620ms | 95ms | -85% |
| P99 延迟 | 850ms | 180ms | -79% |
| CPU 使用率 | 15% | 62% | - |
| 内存占用 | 380MB | 420MB | 略增 |
QPS 从 120 到 1100,基本符合理论预期:三个 200ms 的串行调用变成并发后,单请求耗时从 600ms 降到 200ms 左右,再叠加连接池和事件循环的效率提升,3 倍理论值加上 3 倍左右的并发效率提升,量级对得上。
CPU 从 15% 涨到 62% 是好事,说明机器终于被用起来了。内存略增主要是连接池和事件循环的开销,在可接受范围。
七、总结
这次改造的核心其实不复杂:识别出 IO 密集的串行调用,用 asyncio 并发化,再补齐连接池、超时、降级三件套。 真正花时间的是踩坑和调参,尤其是连接池配额和超时阈值的设定,必须结合压测数据反复调。
几条经验给要动手的同学:
- 别在事件循环里跑任何同步阻塞代码,包括同步 ORM、同步文件 IO、
time.sleep。 ClientSession全局复用,连接池参数按下游配额和 worker 数一起算。- 超时一定要设,而且要分层设(connect / total / 整体 wait_for),生产环境没有超时等于没有防御。
- 并发不等于无脑 gather,单个下游失败要有降级路径,否则一个服务抖动会放大成整体雪崩。
如果你们也有类似的串行聚合接口,建议先加个耗时打点,看看下游调用占比,大概率能靠 asyncio 拿到几倍的免费性能。