1. 问题背景:一个拖垮网关的“简单”API
上个月我们有一个内部监控面板接口 /api/v1/health/overview,它要做的事很简单:
- 调A服务(MySQL查询)拿节点状态
- 调B服务(Redis聚合)拿流量统计
- 调C服务(冷存储)拿历史告警
这三个服务都是独立的HTTP接口,平均响应分别是300ms、500ms、400ms。但因为是同步串行调用,总耗时稳稳落在1.2秒。
压测结果更难看:单机4核8G,QPS只有120,P95延迟2.1秒。网关那边已经报警了——因为上游超时设置是2秒,这意味着高峰期有大量请求会直接504。
我第一反应是:这接口怎么写成串行的?打开代码一看,果不其然:
# 改造前:同步串行调用
def get_overview():
node_status = requests.get(NODE_API, timeout=0.8).json()
traffic_data = requests.get(TRAFFIC_API, timeout=0.8).json()
alert_data = requests.get(ALERT_API, timeout=0.8).json()
return merge_data(node_status, traffic_data, alert_data)
三个请求,每个都在等IO,CPU完全空闲。经典的多IO阻塞场景,这就是asyncio的用武之地。
2. 环境与版本:Python 3.11 + FastAPI + httpx
先说环境,避免版本坑:
- Python 3.11.4(用到asyncio.TaskGroup,3.11才稳定)
- FastAPI 0.104.1(异步框架,但改造也适用于Flask+async)
- httpx 0.25.1(支持异步的HTTP客户端,替代requests)
- uvicorn 0.24.0(ASGI服务器)
- 压测工具:wrk 4.2.0,单机4线程,连接数200
注意: 如果你还在用requests库,它是纯同步的,没法配合asyncio。必须换成httpx或者aiohttp。
部署环境是Docker容器,CPU 4核,内存8G,Python官方slim镜像。
3. 方案设计:并发IO + 信号量限流
核心思路很简单:三个独立的HTTP请求,改成并发发出,总耗时从“三者之和”变成“三者最大值”。
但有两个细节必须考虑:
- 并发度控制:如果接口被刷爆,每个请求都并发3个下游调用,下游会被打挂。需要
asyncio.Semaphore限制最大并发数。 - 超时控制:原来每个请求0.8s超时,并发后总超时应该控制在1s内,否则失去了优化的意义。
我设计了一个轻量级异步调度器:
# 改造后:asyncio并发请求
import asyncio
import httpx
from asyncio import Semaphore, TaskGroup
# 全局信号量:限制单个进程中最多50个并发下游请求
_semaphore = Semaphore(50)
async def fetch_with_timeout(client: httpx.AsyncClient, url: str, timeout: float = 0.9):
"""带信号量和超时控制的异步请求"""
async with _semaphore:
try:
response = await client.get(url, timeout=timeout)
response.raise_for_status()
return response.json()
except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
# 下游挂了不能拖垮主链路,返回降级数据
return {"error": str(e), "degraded": True}
async def get_overview_async():
async with httpx.AsyncClient() as client:
# TaskGroup 自动管理三个任务的并发和异常
async with TaskGroup() as tg:
task1 = tg.create_task(fetch_with_timeout(client, NODE_API))
task2 = tg.create_task(fetch_with_timeout(client, TRAFFIC_API))
task3 = tg.create_task(fetch_with_timeout(client, ALERT_API))
# 注意:TaskGroup退出时会等待所有任务完成
return merge_data(task1.result(), task2.result(), task3.result())
这里有个Python 3.11的细节:TaskGroup如果某个任务抛异常(除了我们捕获的),会直接取消其他兄弟任务。所以我们内部必须捕干净异常,否则一个下游抖动,三个任务全废。
4. 核心实现:FastAPI接入异步路由
FastAPI天生支持async路由,但要注意:如果你在async函数里用了同步库(比如requests),事件循环会被阻塞,性能反而更差。 必须保证整条调用链都是异步的。
改造路由层:
# main.py
from fastapi import FastAPI
import asyncio
app = FastAPI()
@app.get("/api/v1/health/overview")
async def health_overview():
# 直接调用异步版本,不阻塞事件循环
data = await get_overview_async()
return data
# 如果非要兼容同步代码,用run_in_executor跑线程池,但这不是最佳方案
# 推荐:全部改成async def
同时,我把下游超时从0.8s调整到0.9s——因为并发后,总耗时 = max(0.3, 0.5, 0.4) ≈ 0.5s,给单次请求留出0.9s的余量足够,而不像原来串行时总耗时要累加。
5. 踩坑与优化:连接池耗尽和DNS解析阻塞
坑1:httpx.AsyncClient的复用问题
第一版代码我在每个请求里都async with httpx.AsyncClient(),相当于每次新建TCP连接。压测发现连接建立开销巨大,且TIME_WAIT连接堆积。
解决:把client设为模块级单例,并配置连接池:
# client_pool.py
import httpx
# 连接池最大100个连接,保持10个空闲连接
client = httpx.AsyncClient(
limits=httpx.Limits(max_connections=100, max_keepalive_connections=10),
timeout=httpx.Timeout(timeout=0.9, connect=0.3)
)
坑2:DNS解析阻塞事件循环
httpx默认用同步DNS解析,这在高并发下会阻塞事件循环。表现为:QPS到达500后,延迟突然飙升。
解决:显式开启异步DNS(需要额外安装httpx[socks]或者用anyio的DNS解析器)。我用了最简单的方式——把IP直连写死,跳过DNS(因为我们下游服务是内网静态IP)。
坑3:Semaphore的等待队列
Semaphore(50)意味着第51个请求会等待。但如果有1000个并发进来,等待队列会积压,导致超时。我加了信号量获取超时:
async def fetch_with_timeout(client, url, timeout=0.9):
try:
# 最多等待0.2秒获取信号量,拿不到就直接降级
await asyncio.wait_for(_semaphore.acquire(), timeout=0.2)
except asyncio.TimeoutError:
return {"error": "semaphore_timeout", "degraded": True}
try:
response = await client.get(url, timeout=timeout)
return response.json()
finally:
_semaphore.release()
6. 效果数据:QPS提升6倍,P95降低82%
直接上压测数据,wrk压测5分钟,参数:wrk -t4 -c200 -d300s http://localhost:8080/api/v1/health/overview
| 指标 | 改造前(同步串行) | 改造后(asyncio并发) | 提升 |
|---|---|---|---|
| QPS | 120 | 850 | 6.1倍 |
| 平均延迟 | 1.2s | 350ms | 70.8%↓ |
| P95延迟 | 2.1s | 380ms | 81.9%↓ |
| 错误率 | 3.2%(超时504) | 0.1%(仅降级数据) | 96.9%↓ |
抖动测试:故意将C服务(冷存储)延迟拉到2秒,改造前整个接口直接超时;改造后,A和B的数据正常返回,C返回降级数据,接口仍然在400ms内响应。这就是异步+异常隔离带来的稳定性提升。
资源占用:CPU从改造前的85%降到40%(因为不再忙等IO),内存从1.2G降到800M(连接池复用)。4核8G的容器现在可以支撑原先6倍的流量。
7. 总结:什么时候该用asyncio?
这次改造收益巨大,但我要泼点冷水:asyncio不是银弹。
- 如果你的IO等待时间 > 1ms,且并发量 > 50,asyncio收益明显。
- 如果是CPU密集型任务(如JSON大字段处理、图片压缩),asyncio没用,该用多进程。
- 如果下游服务不稳定,一定要配合信号量限流和超时降级,否则异步会让你的下游雪崩。
最后说一句:Python 3.11的TaskGroup真的比asyncio.gather好用太多,异常处理逻辑清晰,推荐升级。
这次改造整体花了半天时间,换来了6倍的性能提升。下次遇到接口性能瓶颈,先看看是不是IO串行了——大概率是。