一、问题背景:一个被I/O拖死的聚合接口
我们有个商品详情聚合接口 /api/product/{id},逻辑很简单:收到请求后并发调用下游三个服务——商品基础信息、库存、促销——然后拼装返回。下游都是HTTP接口,单次RT在80~150ms之间。
最早的实现是Flask + requests,用 ThreadPoolExecutor 并发调三个下游。看起来很合理,但压测数据很难看:
- 单实例QPS:约120
- P99延迟:2.3s
- 4核机器上线程数经常打到
max_workers=64的上限 - 高峰期CPU利用率不到30%,但请求全在排队
问题很清楚:这是典型的I/O密集型场景,瓶颈根本不在CPU,而在线程模型的上下文切换和线程池容量。每个请求占用一个线程,线程在等下游HTTP响应时是阻塞的,64个线程意味着最多64个并发请求在飞,剩下的全在队列里等。CPU闲着,线程池满了,这就是雪崩的起点。
我当时算了一笔账:单请求下游耗时约100ms×3(并发后约120ms),如果纯粹按I/O等待算,一个线程理论上能撑起远超1个请求的吞吐,但阻塞式IO做不到。这就是要上asyncio的理由。
二、环境与版本
改造前后的环境保持一致,避免引入额外变量:
Python: 3.11.6
OS: Ubuntu 22.04, 4核8G
改造前: Flask 2.3.3 + requests 2.31.0 + gunicorn 21.2.0 (sync worker, 4 workers × 16 threads)
改造后: FastAPI 0.110.0 + httpx 0.27.0 + uvicorn 0.29.0 (4 workers)
压测工具: wrk 4.2.0, 4线程 100连接, 持续60s
选httpx而不是aiohttp,是因为团队已有requests的使用习惯,httpx的API几乎无缝迁移,而且它对HTTP/2和超时控制的支持更完整。这个选择后面会提到一个坑。
三、方案设计
核心思路只有一句话:把"线程等I/O"换成"事件循环等I/O"。
具体设计:
- Web层换成FastAPI + uvicorn,原生async路由,单worker内单事件循环处理成千并发连接。
- 下游调用用
httpx.AsyncClient,全应用共享一个client实例(复用连接池),而不是每请求新建。 - 三个下游调用用
asyncio.gather并发,设置整体超时asyncio.wait_for。 - 对下游失败做降级:库存、促销失败不影响主流程,返回默认值。
- 连接池参数:
limits=httpx.Limits(max_connections=200, max_keepalive_connections=50)。
这里有个关键决策:worker数量。uvicorn用4个worker(等于CPU核数),每个worker一个独立事件循环。不要盲目开很多worker,事件循环本身是单线程的,worker多了反而增加内存和调度开销。
四、核心实现
改造前(Flask + requests + 线程池)
# before_app.py
import requests
from flask import Flask, jsonify
from concurrent.futures import ThreadPoolExecutor
app = Flask(__name__)
executor = ThreadPoolExecutor(max_workers=64)
TIMEOUT = 2.0
def fetch(url):
try:
return requests.get(url, timeout=TIMEOUT).json()
except Exception:
return None
@app.route("/api/product/")
def product(pid):
base = f"http://downstream/internal/{pid}"
f1 = executor.submit(fetch, f"{base}/info")
f2 = executor.submit(fetch, f"{base}/stock")
f3 = executor.submit(fetch, f"{base}/promo")
info = f1.result()
if info is None:
return jsonify({"code": 500}), 500
return jsonify({
"info": info,
"stock": f2.result() or {"stock": 0},
"promo": f3.result() or {"promo": None},
})
这段代码的问题:executor.submit 每次都要抢线程池名额,64个线程是硬上限;requests.get 内部是阻塞socket;f1.result() 还会阻塞等待。整条链路里,线程资源被I/O死死占住。
改造后(FastAPI + httpx.AsyncClient + asyncio.gather)
# after_app.py
import asyncio
import httpx
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
# 全应用共享的连接池,lifespan里创建/关闭
client: httpx.AsyncClient | None = None
@app.on_event("startup")
async def startup():
global client
client = httpx.AsyncClient(
timeout=httpx.Timeout(2.0, connect=0.5),
limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
)
@app.on_event("shutdown")
async def shutdown():
await client.aclose()
async def safe_get(url: str):
try:
r = await client.get(url)
r.raise_for_status()
return r.json()
except Exception:
return None
@app.get("/api/product/{pid}")
async def product(pid: int):
base = f"http://downstream/internal/{pid}"
try:
info, stock, promo = await asyncio.wait_for(
asyncio.gather(
safe_get(f"{base}/info"),
safe_get(f"{base}/stock"),
safe_get(f"{base}/promo"),
),
timeout=1.5,
)
except asyncio.TimeoutError:
return JSONResponse({"code": 504}, status_code=504)
if info is None:
return JSONResponse({"code": 500}, status_code=500)
return {
"info": info,
"stock": stock or {"stock": 0},
"promo": promo or {"promo": None},
}
启动命令:
uvicorn after_app:app --host 0.0.0.0 --port 8000 --workers 4 --loop uvloop --http httptools
--loop uvloop 和 --http httptools 这两个参数别省,实测能再提15%左右吞吐。
五、踩坑与优化
坑1:协程被我自己写成了串行。 第一版我把 safe_get 写成先 await client.get() 再 r.json(),看起来没问题,但有人在 safe_get 里加了 await asyncio.sleep(0) 之外的同步 time.sleep 做重试退避——一行 time.sleep 直接阻塞整个事件循环,所有并发请求全卡住,QPS掉到比改造前还低。教训:async函数里绝对不能出现任何同步阻塞调用,重试退避要用 asyncio.sleep。
坑2:连接池太小导致端口耗尽。 初期 max_connections 用了默认值100,压测到800 QPS左右开始出现 Connection pool is full 警告和大量超时。调到200并确认下游能扛住后消失。同时 max_keepalive_connections 从默认20提到50,减少TCP握手。
坑3:共享client的生命周期。 早期我在每个请求里 async with httpx.AsyncClient(),等于每请求新建连接池,压测QPS只有改造前的1.5倍。改成全局单例后才是质变。注意用lifespan/startup管理,别用模块级全局变量裸奔。
坑4:超时要分层。 下游单次2s、整体 wait_for 1.5s,看起来矛盾,其实是故意的:单请求超时给足余量,整体兜底防止某个下游拖垮整个gather。另外connect timeout单独设0.5s,避免TCP层卡死。
坑5:uvicorn worker和线程池混用。 下游有个老服务只提供同步SDK,我在async路由里用 run_in_executor 调它,结果 executor默认线程数太少成为新瓶颈。显式配置 loop.set_default_executor(ThreadPoolExecutor(max_workers=32)) 后解决。但这说明:asyncio不是银弹,混杂同步依赖时要单独评估。
六、效果数据
同一台4核8G机器,wrk 4线程100连接压测60秒,取稳定期数据:
| 指标 | 改造前(Flask) | 改造后(FastAPI+asyncio) | 变化 |
|---|---|---|---|
| QPS | 120 | 980 | +717% |
| P50延迟 | 780ms | 95ms | -88% |
| P99延迟 | 2300ms | 310ms | -87% |
| CPU利用率 | 28% | 72% | 更充分 |
| 常驻内存 | 约420MB | 约250MB | -40% |
| 错误率(超时) | 6.3% | 0.2% | 显著下降 |
需要说明的是,QPS提升到980并非纯asyncio的功劳,下游服务本身能扛住这个量级是前提。另外CPU利用率从28%升到72%是好事——说明机器终于被用起来了,不再是线程在干等。
七、总结
这次改造的核心结论就三条:
- I/O密集型API用asyncio的收益是数量级的,前提是你真的把阻塞点全换掉了。一行
time.sleep或一个同步SDK调用就能让所有优化归零。 - 共享连接池 + 合理的pool参数 比协程语法本身更影响最终数字,别只盯着
async/await关键字。 - asyncio不是万能药。如果下游是CPU密集或必须用同步SDK,
run_in_executor是逃生舱,但要显式管好线程池大小,否则瓶颈只是从线程池A挪到了线程池B。
代码都在上面,环境版本也标了,想复现的直接拿去压。如果你的接口也是"调几个下游拼数据"这种形态,这套改造基本可以照搬。