一、问题背景
去年接手一个订单中心的聚合接口,逻辑很"朴素":前端传一个order_id,后端要依次去三个服务拿数据——订单主表、物流状态、优惠券明细,最后拼成一个JSON返回。原实现是Flask + requests,代码大概长这样:
@app.route("/api/order/")
def get_order_detail(order_id):
order = requests.get(f"{ORDER_SVC}/order/{order_id}", timeout=2).json()
logistics = requests.get(f"{LOGISTICS_SVC}/logistics/{order_id}", timeout=2).json()
coupon = requests.get(f"{COUPON_SVC}/coupon/{order_id}", timeout=2).json()
return jsonify({"order": order, "logistics": logistics, "coupon": coupon})
问题很直观:三个下游请求是串行的,每个平均耗时200~300ms,加起来单请求就要700ms以上。线上4核8G的Pod,压测QPS只有120左右,P95达到820ms。大促期间接口超时率一度冲到3%。
这三个服务之间没有数据依赖,完全可以并发。于是决定用asyncio重构。
二、环境与版本
- Python 3.11.6(3.11的TaskGroup和异常组很好用,但为了兼容性我最终用了gather)
- FastAPI 0.110.0 + uvicorn 0.29.0(标准版,
uvicorn[standard]) - httpx 0.27.0(异步客户端,替代requests)
- 原环境:Flask 2.3.3 + requests 2.31.0 + gunicorn 21.2.0(4 workers)
- 压测工具:wrk 4.2.0,
wrk -t4 -c200 -d30s
部署方式也做了调整:原来gunicorn用同步worker,现在换成uvicorn单进程多协程。因为asyncio的并发模型里,多进程反而会浪费内存,单进程靠事件循环就能吃满IO等待。
三、方案设计
核心思路只有一句话:把IO等待重叠起来。
- 用
httpx.AsyncClient复用一个全局连接池,避免每次请求重建TCP - 用
asyncio.gather并发发起3个下游调用 - 给每个下游单独设超时,用
asyncio.wait_for包一层,慢的那个不拖垮整体 - 关键服务做降级:物流和优惠券拿不到就返回空,订单主表失败才整体报错
一个容易忽略的点是超时预算。串行时总超时是2s×3=6s,并发后总预算应该压到1.5s以内,否则并发没意义。我给每个下游设800ms超时,整体兜底1.2s。
四、核心实现
先看改造后的完整代码:
import asyncio
import httpx
from fastapi import FastAPI, HTTPException
app = FastAPI()
# 全局复用连接池,限制最大连接数,避免打爆下游
client = httpx.AsyncClient(
timeout=httpx.Timeout(0.8, connect=0.3),
limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
http2=True,
)
ORDER_SVC = "http://order-svc"
LOGISTICS_SVC = "http://logistics-svc"
COUPON_SVC = "http://coupon-svc"
async def fetch(url: str, timeout: float = 0.8):
"""带独立超时的GET,失败抛异常由上层处理"""
resp = await asyncio.wait_for(client.get(url), timeout=timeout)
resp.raise_for_status()
return resp.json()
async def safe_fetch(url: str, default=None):
"""降级版本:失败返回默认值,不阻断整体"""
try:
return await fetch(url)
except (httpx.HTTPError, asyncio.TimeoutError):
return default
@app.get("/api/order/{order_id}")
async def get_order_detail(order_id: str):
# 订单主表是强依赖,失败直接报错;另外两个可降级
order_task = asyncio.create_task(fetch(f"{ORDER_SVC}/order/{order_id}"))
logistics_task = asyncio.create_task(
safe_fetch(f"{LOGISTICS_SVC}/logistics/{order_id}", default={})
)
coupon_task = asyncio.create_task(
safe_fetch(f"{COUPON_SVC}/coupon/{order_id}", default=[])
)
try:
order, logistics, coupon = await asyncio.wait_for(
asyncio.gather(order_task, logistics_task, coupon_task),
timeout=1.2,
)
except (httpx.HTTPError, asyncio.TimeoutError):
raise HTTPException(status_code=504, detail="order service timeout")
return {"order": order, "logistics": logistics, "coupon": coupon}
启动命令:
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1 --loop uvloop --http httptools
这里--workers 1是刻意的。asyncio场景下,单进程的事件循环足以处理大量并发IO,多worker只会在进程间重复建连接池。如果CPU成为瓶颈(比如有大量JSON序列化),再考虑加worker。
再贴一个压测用的对比脚本,方便你复现:
# bench.py —— 简单对比串行 vs 并发
import asyncio, time, httpx
URLS = [
"http://order-svc/order/123",
"http://logistics-svc/logistics/123",
"http://coupon-svc/coupon/123",
]
async def serial(client):
for u in URLS:
await client.get(u)
async def concurrent(client):
await asyncio.gather(*[client.get(u) for u in URLS])
async def main():
async with httpx.AsyncClient(timeout=1.0) as client:
for name, fn in [("serial", serial), ("concurrent", concurrent)]:
t = time.perf_counter()
for _ in range(100):
await fn(client)
cost = (time.perf_counter() - t) / 100 * 1000
print(f"{name}: {cost:.1f} ms/req")
asyncio.run(main())
五、踩坑与优化
坑1:忘了复用client。 第一版我在每个请求里async with httpx.AsyncClient(),结果QPS反而比原来低——每次都在做TLS握手和连接建立。改成全局client后,P95直接掉了300ms。记住:AsyncClient是设计来长生命周期复用的。
坑2:连接池太小。 默认max_connections=100,压测到并发200时出现大量PoolTimeout。调到200后消失。经验值是max_connections ≥ 预期并发数,否则请求会在池里排队,并发形同虚设。
坑3:gather的异常传播。 asyncio.gather默认一个任务抛异常,其他任务不会被取消,但整体会立刻抛错。我一开始没设return_exceptions,导致物流服务抖动时整个接口挂掉。后来用safe_fetch包了一层降级才稳。
另外把日志里的time.time()换成time.perf_counter()做耗时统计,精度高很多。
六、效果数据
同一台4核8G机器,wrk压测-t4 -c200 -d30s:
| 指标 | Before (Flask+requests) | After (FastAPI+asyncio) |
|---|---|---|
| QPS | 120 | 1800 |
| P50 | 720ms | 62ms |
| P95 | 820ms | 95ms |
| P99 | 1100ms | 140ms |
| 错误率 | 0.3% | 0% |
| 内存占用 | 380MB | 210MB |
QPS提升15倍,P95降了88%。注意这个提升主要来自并发重叠IO和连接池复用,不是FastAPI本身比Flask快多少——如果下游是CPU密集,asyncio一点忙都帮不上。
七、总结
asyncio不是银弹,它只解决一件事:IO等待时的CPU空转。判断要不要用,就看你的接口有没有大量可并发的IO。像这个订单聚合接口,3个下游串行750ms,并发后取最慢的那个约250ms,理论收益就是3倍,实际因为连接复用和框架开销还更高。
几个可以直接抄的结论:
- AsyncClient全局复用,max_connections调到并发量级
- 每个下游独立超时,总预算压在1.5s内
- 强依赖用gather直接抛错,弱依赖用safe_fetch降级
- uvicorn单worker起步,CPU打满再加
如果你的接口是纯数据库查询,先把连接池和SQL优化做完再考虑asyncio,顺序别搞反了。