一、问题背景:一个慢得让人抓狂的聚合接口
去年接手了一个内部服务,功能很简单:客户端传一个用户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
三、方案设计
核心思路就三点:
- 用异步框架替换同步框架:FastAPI原生基于ASGI,单worker就能处理成千上万并发连接,不需要线程池。
- 并发发起外部请求:三个下游接口彼此无依赖,用
asyncio.gather并发执行,总耗时约等于最慢的那个,而不是三者之和。 - 共享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.gather 配 return_exceptions=True 做优雅降级
- uvicorn workers = CPU核数,别指望单进程吃满
- 事件循环里千万别塞同步阻塞代码
如果你的服务也是"请求进来 → 调好几个下游 → 聚合返回"这种模式,真的值得花半天时间改造成asyncio,收益立竿见影。