一、问题背景
去年接手了一个内部聚合服务,职责很简单:接收前端一个查询请求,并行调用用户中心、订单中心、商品中心三个下游HTTP接口,把结果拼装后返回。听起来就是个典型的IO密集场景,但当时的实现是这样的:
# before: Flask 同步实现
@app.route('/api/v1/aggregate')
def aggregate():
uid = request.args.get('uid')
user = requests.get(f'{USER_SVC}/user/{uid}', timeout=3).json()
orders = requests.get(f'{ORDER_SVC}/orders?uid={uid}', timeout=3).json()
products = requests.get(f'{PRODUCT_SVC}/products?uid={uid}', timeout=3).json()
return jsonify({'user': user, 'orders': orders, 'products': products})
三个接口串行调用,每个平均120ms左右,加起来360ms起步。上线后压测数据很难看:
- 单请求平均耗时:380ms
- 4 worker(gunicorn sync模式)下 QPS:约120
- P99:1.2s
- 高峰期CPU利用率不到15%,但请求就是排不上队
问题很明显:进程都在等网络IO,CPU闲着。当时的临时方案是加机器,从4台扩到8台,勉强扛住了。但这显然不是长久之计,于是决定用asyncio重构。
二、环境与版本
先把环境交代清楚,避免读者踩版本坑:
- Python 3.11.6(3.8以下不建议,
asyncio.to_thread、TaskGroup之类用不了) - aiohttp 3.9.1
- FastAPI 0.109.0 + uvicorn 0.25.0(替代Flask,因为原生支持async def路由)
- gunicorn 21.2.0(如果继续用Flask也可以用,但这里直接换FastAPI)
- 压测工具:wrk 4.2.0,
wrk -t4 -c200 -d60s - 部署:4C8G 容器,下游服务为内网HTTP,RTT约1ms
选FastAPI不是因为跟风,主要是它的路由天然支持async def,而Flask的async支持一直比较尴尬(需要额外装flask[async]且生态兼容性一般)。如果团队强依赖Flask,其实也可以用asgiref包一层,但没必要给自己找麻烦。
三、方案设计
核心思路就一句话:把串行IO改成并发IO。
具体设计:
- 三个下游调用用
asyncio.gather并发发起,总耗时从t1+t2+t3变成max(t1,t2,t3) - 用
aiohttp.ClientSession复用连接,避免每次请求重建TCP - 连接池限制为
limit=100,limit_per_host=50,配合下游承载能力 - 每个下游调用单独设超时(
ClientTimeout(total=2)),并用return_exceptions=True做降级——某个下游挂了不能拖垮整个接口 - uvicorn worker数设为2(4核机器),每个worker单线程跑事件循环
这里有个关键决策:worker数量。同步模式下gunicorn通常开2*CPU+1个worker,但异步模式下单个worker就能扛住大量并发连接,worker开多了反而增加上下文切换和内存开销。4C机器开2个uvicorn worker是比较稳的配置,实测开到4个QPS反而略降。
四、核心实现
4.1 重构后的代码
# after: FastAPI + asyncio + aiohttp
import asyncio
import aiohttp
from fastapi import FastAPI, Query
from contextlib import asynccontextmanager
USER_SVC = "http://user-svc.internal"
ORDER_SVC = "http://order-svc.internal"
PRODUCT_SVC = "http://product-svc.internal"
# 全局 session,随应用生命周期创建/关闭
session: aiohttp.ClientSession | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global session
connector = aiohttp.TCPConnector(
limit=100, # 总连接池上限
limit_per_host=50, # 单host上限
ttl_dns_cache=300, # DNS缓存5分钟
enable_cleanup_closed=True,
)
timeout = aiohttp.ClientTimeout(total=2, connect=0.5, sock_read=1.5)
session = aiohttp.ClientSession(connector=connector, timeout=timeout)
yield
await session.close()
app = FastAPI(lifespan=lifespan)
async def fetch_json(url: str, name: str):
try:
async with session.get(url) as resp:
resp.raise_for_status()
return name, await resp.json()
except Exception as e:
# 单下游失败降级,不阻断整体
return name, {"error": str(e)}
@app.get("/api/v1/aggregate")
async def aggregate(uid: str = Query(...)):
tasks = [
fetch_json(f"{USER_SVC}/user/{uid}", "user"),
fetch_json(f"{ORDER_SVC}/orders?uid={uid}", "orders"),
fetch_json(f"{PRODUCT_SVC}/products?uid={uid}", "products"),
]
results = await asyncio.gather(*tasks, return_exceptions=False)
data = dict(results)
return {"code": 0, "data": data}
几个细节值得说:
ClientSession必须是全局复用,每次请求新建session是最常见的性能杀手,握手开销直接吃掉异步收益sock_read=1.5比total=2更细,能区分"连不上"和"连上了但下游慢"return_exceptions=False是因为fetch_json内部已经try/except兜底了,这里不需要再让gather吞异常- 降级返回
{"error": ...}而不是抛500,前端可以做局部渲染
4.2 启动命令
uvicorn app:app --host 0.0.0.0 --port 8000 \
--workers 2 \
--loop uvloop \
--http httptools \
--backlog 2048
uvloop和httptools这两个可选依赖装上,实测QPS能再提升15%左右,白给的性能没理由不要。
五、踩坑与优化
重构过程不是一帆风顺,记录几个真实踩过的坑:
坑1:aiohttp的DNS解析阻塞。 一开始没配ttl_dns_cache,压测时发现每隔一段时间有毛刺,P99飙到800ms。原因是aiohttp默认每次新建连接都要走getaddrinfo,这个调用在Linux上是阻塞的,会卡住整个事件循环。加上ttl_dns_cache=300后毛刺消失。
坑2:连接池太小反而更慢。 最初把limit设成20,想着保守点,结果压测QPS卡在800上不去。原因是并发200时连接不够用,请求在池里排队。调到100后QPS直接翻倍。经验值:limit至少设为预期并发的50%,但要小于下游服务的连接承载上限。
坑3:同步库混用。 中间有段代码需要读Redis,用的是redis-py同步客户端,结果一压测事件循环就被阻塞。换成redis.asyncio后解决。异步函数里绝对不能出现同步IO调用,这一条要写进团队规范。
坑4:uvicorn worker数。 一开始按同步思维开了8个worker,结果内存暴涨(每个worker一份session池),QPS反而不如2个worker。异步模式下worker数建议CPU核数/2到CPU核数之间。
坑5:超时配置不统一。 下游某个接口偶尔慢到5s,但我们的total=2会先超时,触发降级。这里要跟下游对齐SLA,别自己拍脑袋定超时值。
六、效果数据
压测条件一致:wrk -t4 -c200 -d60s,下游为mock服务固定120ms延迟。
| 指标 | 重构前(Flask+gunicorn 4w) | 重构后(FastAPI+uvicorn 2w) | 变化 |
|---|---|---|---|
| QPS | 120 | 2100 | 17.5x |
| 平均延迟 | 380ms | 135ms | -64% |
| P99延迟 | 1.2s | 180ms | -85% |
| CPU利用率 | 12% | 68% | - |
| 单机内存 | 320MB | 410MB | +28% |
| 线上机器数 | 8台 | 2台 | -75% |
线上灰度一周后,高峰期P99稳定在200ms以内,没有再出现排队超时。机器从8台缩到2台,省下的成本够团队喝一年咖啡了。
需要说明的是,QPS提升17倍看着夸张,主要是因为原实现的串行IO浪费太严重。如果原本就是合理并发(比如用线程池),提升幅度大概在3-5倍。asyncio的收益和IO占比强相关,CPU密集场景用它没意义。
七、总结
这次重构最大的体会是:asyncio不是银弹,但对IO密集的聚合类接口是降维打击。几个可复用的经验:
ClientSession必须全局复用,连接池参数按并发量调,别用默认值- 异步代码里严禁同步IO,混用等于白改
- worker数在异步模式下要往下调,不是越多越好
- 超时和降级要设计好,异步并发会把下游的抖动放大
uvloop+httptools是低成本高收益的优化项
如果你们的服务也是这种"一个请求打多个下游"的模式,且CPU长期低于30%,那基本可以确定是IO在拖后腿,值得动手改一改。改完之后记得用真实流量灰度,mock压测和线上的差距有时候不小。