一、问题背景:一个慢得让人抓狂的聚合接口

去年接手了一个内部服务,功能很简单:客户端传一个用户ID,服务端去三个外部系统(用户中心、订单中心、风控中心)分别拉数据,聚合后返回。

原实现是Flask + requests,代码大概长这样:

# before: app_sync.py
from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.route("/api/user/profile")
def get_profile():
    uid = request.args.get("uid")
    user = requests.get(f"http://user-svc/users/{uid}", timeout=3).json()
    orders = requests.get(f"http://order-svc/orders?uid={uid}", timeout=3).json()
    risk = requests.get(f"http://risk-svc/check/{uid}", timeout=3).json()
    return jsonify({"user": user, "orders": orders, "risk": risk})

三个请求串行执行,每个平均耗时80~150ms,加起来单请求就是300~400ms。线上用gunicorn起了4个worker,每个worker 4线程,压测QPS只有120左右,P99延迟1.2s。高峰期接口直接超时,业务方天天来催。

问题的本质很清楚:这是典型的I/O密集型任务,却被写成了串行阻塞。线程模型下要提升吞吐只能加worker,但内存和上下文切换成本摆在那里,加机器不是长久之计。

二、环境与版本

  • Python 3.11.6(3.10+对asyncio有持续优化,3.11的TaskGroup很好用)
  • 原方案:Flask 2.3.2 + requests 2.31.0 + gunicorn 21.2.0(sync worker,4 worker × 4 thread)
  • 新方案:FastAPI 0.110.0 + uvicorn 0.29.0 + httpx 0.27.0
  • 压测工具:wrk 4.2.0,wrk -t4 -c200 -d30s
  • 机器:4C8G 容器,和外部服务同内网,RTT 约1ms

三、方案设计

核心思路就三点:

  1. 用异步框架替换同步框架:FastAPI原生基于ASGI,单worker就能处理成千上万并发连接,不需要线程池。
  2. 并发发起外部请求:三个下游接口彼此无依赖,用 asyncio.gather 并发执行,总耗时约等于最慢的那个,而不是三者之和。
  3. 共享HTTP连接池:httpx的 AsyncClient 复用TCP连接,避免每次请求都三次握手;同时设置合理的连接池上限和超时。

为什么不用 aiohttp?其实两者性能接近,选httpx主要是因为它API和requests几乎一致,迁移成本低,而且支持HTTP/2和同步/异步双模式,方便灰度。

四、核心实现

先看改造后的代码:

# after: app_async.py
import asyncio
import httpx
from fastapi import FastAPI, Query
from contextlib import asynccontextmanager

# 全局共享的 AsyncClient,复用连接池
client: httpx.AsyncClient | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global client
    limits = httpx.Limits(
        max_connections=200,        # 总连接上限
        max_keepalive_connections=50,
        keepalive_expiry=30.0,
    )
    timeout = httpx.Timeout(connect=1.0, read=2.0, write=2.0, pool=1.0)
    client = httpx.AsyncClient(limits=limits, timeout=timeout)
    yield
    await client.aclose()

app = FastAPI(lifespan=lifespan)

async def fetch_user(uid: str):
    r = await client.get(f"http://user-svc/users/{uid}")
    r.raise_for_status()
    return r.json()

async def fetch_orders(uid: str):
    r = await client.get(f"http://order-svc/orders", params={"uid": uid})
    r.raise_for_status()
    return r.json()

async def fetch_risk(uid: str):
    r = await client.get(f"http://risk-svc/check/{uid}")
    r.raise_for_status()
    return r.json()

@app.get("/api/user/profile")
async def get_profile(uid: str = Query(...)):
    # 三个请求并发执行,总耗时 = max(t1, t2, t3)
    user, orders, risk = await asyncio.gather(
        fetch_user(uid),
        fetch_orders(uid),
        fetch_risk(uid),
        return_exceptions=False,
    )
    return {"user": user, "orders": orders, "risk": risk}

启动命令也从gunicorn换成了uvicorn:

uvicorn app_async:app --host 0.0.0.0 --port 8000 --workers 4 --loop uvloop --http httptools

uvloop 是libuv的Python绑定,事件循环性能比默认的asyncio快2~4倍;httptools 是Node.js同款的HTTP解析器,也比默认的快不少。

五、踩坑与优化

改造过程中踩了几个坑,记录一下:

坑1:把AsyncClient放在函数里每次new一个。 一开始图省事,在每个fetch函数里 async with httpx.AsyncClient() as c,结果连接池完全没复用,QPS只有600多。改成全局单例+lifespan管理后直接翻倍。

坑2:忘记设置超时。 httpx默认超时是5秒,下游一个接口抖动就会拖垮整个聚合接口。显式设置connect 1s / read 2s后,异常隔离效果好很多。

坑3:gather没做异常处理。 下游任何一个接口报错,整个请求就500。后来改成 return_exceptions=True,对失败的子请求返回降级数据:

results = await asyncio.gather(
    fetch_user(uid), fetch_orders(uid), fetch_risk(uid),
    return_exceptions=True,
)
user, orders, risk = [
    r if not isinstance(r, Exception) else None for r in results
]

坑4:uvicorn的workers参数。 单worker时QPS大概600,因为只有一个CPU核在跑事件循环。改成4 workers(等于CPU核数)后QPS冲到2100。异步不是不要多进程,CPU还是得吃满。

坑5:CPU阻塞函数混进了协程。 有个JSON序列化逻辑用了同步的 json.dumps,大payload时阻塞事件循环。后来换成orjson,或者丢到 asyncio.to_thread 里跑。

六、效果数据

压测条件:wrk -t4 -c200 -d30s,下游mock服务固定延迟80ms。

指标 Before (Flask+requests) After (FastAPI+asyncio) 提升
QPS 120 2100 17.5x
P50 延迟 340ms 82ms 4.1x
P99 延迟 1200ms 85ms 14x
单请求CPU时间 降低约60% -
内存占用 4 worker ≈ 480MB 4 worker ≈ 320MB -33%

P50从340ms掉到82ms,基本就是下游单次调用的耗时,说明并发确实生效了。P99也从1.2s降到85ms,抖动被抹平。

七、总结

这次改造最大的感受是:I/O密集型的Web API,用同步框架+多线程是费力不讨好。asyncio不是银弹,但它把"等待下游"这件事的成本压到了最低。

几点经验:
- 全局复用 AsyncClient,别每次new
- 所有外部调用都要有超时,池超时也要设
- asyncio.gatherreturn_exceptions=True 做优雅降级
- uvicorn workers = CPU核数,别指望单进程吃满
- 事件循环里千万别塞同步阻塞代码

如果你的服务也是"请求进来 → 调好几个下游 → 聚合返回"这种模式,真的值得花半天时间改造成asyncio,收益立竿见影。