一、问题背景:一个被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"。

具体设计:

  1. Web层换成FastAPI + uvicorn,原生async路由,单worker内单事件循环处理成千并发连接。
  2. 下游调用用 httpx.AsyncClient,全应用共享一个client实例(复用连接池),而不是每请求新建。
  3. 三个下游调用用 asyncio.gather 并发,设置整体超时 asyncio.wait_for。
  4. 对下游失败做降级:库存、促销失败不影响主流程,返回默认值。
  5. 连接池参数: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%是好事——说明机器终于被用起来了,不再是线程在干等。

七、总结

这次改造的核心结论就三条:

  1. I/O密集型API用asyncio的收益是数量级的,前提是你真的把阻塞点全换掉了。一行 time.sleep 或一个同步SDK调用就能让所有优化归零。
  2. 共享连接池 + 合理的pool参数 比协程语法本身更影响最终数字,别只盯着 async/await 关键字。
  3. asyncio不是万能药。如果下游是CPU密集或必须用同步SDK,run_in_executor 是逃生舱,但要显式管好线程池大小,否则瓶颈只是从线程池A挪到了线程池B。

代码都在上面,环境版本也标了,想复现的直接拿去压。如果你的接口也是"调几个下游拼数据"这种形态,这套改造基本可以照搬。