1. 问题背景:同步HTTP调用链是性能杀手
先描述下场景:内部订单系统有个/api/order_summary接口,逻辑很简单——根据订单ID,依次调用三个下游服务:
user-service:获取用户信息(平均耗时150ms)inventory-service:获取商品库存(平均耗时200ms)promotion-service:获取优惠活动(平均耗时100ms)
原始代码用requests库同步调用,每个请求串行等待,总耗时≈450ms。压测时35并发就把Tomcat线程池打满(Flask内置Werkzeug默认线程池200),QPS跌到200,大量请求排队。
瓶颈本质:IO密集场景下,同步等待导致线程资源被无效占用。Python线程切换开销大,GIL在大并发下更是雪上加霜。
2. 环境与版本:Python 3.10 + Flask 2.2
Python: 3.10.12
Flask: 2.2.5
aiohttp: 3.8.5
gunicorn: 21.2.0 (worker_class=sync, workers=4)
压测工具: wrk -t8 -c400 -d30s
注意:Flask本身是WSGI同步框架,不能直接异步处理请求。我的方案是在同步视图内部使用asyncio.run(),配合aiohttp发起异步HTTP请求。后面会详细讲这个方案的坑。
3. 方案设计:最小侵入式异步改造
设计原则:
1. 不动Flask路由和视图函数签名——保持同步函数,内部用asyncio.run()驱动协程。
2. 只替换HTTP调用层——将requests.get()换成aiohttp异步会话。
3. 用asyncio.gather并发——三个独立调用同时发起,总耗时从450ms降到≈200ms(取最大耗时,而非累加)。
为什么不用asyncio.run直接跑整个Flask?因为WSGI协议是同步阻塞的,强行异步会破坏兼容性。更优雅的方案是换aiohttp或FastAPI,但业务代码迁移成本高。这里选择最小改造,风险可控。
4. 核心实现:Before vs After
Before(同步版本,性能瓶颈)
import requests
def get_order_summary(order_id: str):
# 串行调用三个下游服务,总耗时 = 150 + 200 + 100 = 450ms
user_resp = requests.get(f'http://user-service/users/{order_id}', timeout=0.5)
inv_resp = requests.get(f'http://inventory-service/inventory/{order_id}', timeout=0.5)
promo_resp = requests.get(f'http://promotion-service/promotions/{order_id}', timeout=0.5)
return {
'user': user_resp.json(),
'inventory': inv_resp.json(),
'promotion': promo_resp.json()
}
After(asyncio优化版本)
import asyncio
import aiohttp
from functools import lru_cache
# 全局复用Session,避免每次请求都建立TCP连接
@lru_cache(maxsize=1)
def get_session():
return aiohttp.ClientSession()
async def fetch_json(session, url: str):
async with session.get(url, timeout=aiohttp.ClientTimeout(total=0.5)) as resp:
return await resp.json()
async def async_get_order_summary(order_id: str):
session = get_session()
# 并发发起三个请求,总耗时 = max(150, 200, 100) = 200ms
results = await asyncio.gather(
fetch_json(session, f'http://user-service/users/{order_id}'),
fetch_json(session, f'http://inventory-service/inventory/{order_id}'),
fetch_json(session, f'http://promotion-service/promotions/{order_id}'),
return_exceptions=True # 防止单个失败导致全部失败
)
return {
'user': results[0] if not isinstance(results[0], Exception) else {},
'inventory': results[1] if not isinstance(results[1], Exception) else {},
'promotion': results[2] if not isinstance(results[2], Exception) else {}
}
def get_order_summary(order_id: str):
# Flask视图保持同步,内部用asyncio.run驱动
return asyncio.run(async_get_order_summary(order_id))
关键点:
- asyncio.run()每次创建新事件循环,有微小开销(约0.1ms),可忽略。
- return_exceptions=True防止一个服务挂了拖死整个接口。
- ClientSession必须全局复用,否则每次创建Session会重新建立连接池,性能反而更差。
5. 踩坑与优化:三个真实遇到的坑
坑1:EventLoop被阻塞
改造后第一次压测,QPS只到800就上不去了。排查发现asyncio.run()在每次请求时创建新loop,而get_session()返回的ClientSession内部connector绑定在第一个事件循环上。后续请求复用session时,协程在另一个loop运行,直接报RuntimeError: Event loop is closed。
解决:不用lru_cache缓存Session,改为在async_get_order_summary内部创建,或者使用asyncio.run的loop参数(3.10已废弃)。最终我选择在每次请求时新建Session,牺牲少量连接复用,换取稳定性。测试后性能影响约5%,可接受。
坑2:信号量限流
下游服务很脆弱,400并发时直接把user-service打挂。必须加信号量限制并发数:
_semaphore = asyncio.Semaphore(50) # 限制最多50个并发请求
async def fetch_json(session, url: str):
async with _semaphore:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=0.5)) as resp:
return await resp.json()
这个Semaphore是模块级全局变量,但注意它绑定在创建它的loop上。如果asyncio.run每次新建loop,Semaphore会失效。解决:把Semaphore的创建也移到协程内部,或者用asyncio.Lock配合asyncio.run_coroutine_threadsafe(不推荐,太复杂)。最终我选择了在每次请求的协程内创建Semaphore,虽然会重复创建,但开销极小(微秒级)。
坑3:超时设置
最初没设超时,下游服务假死导致连接池耗尽。用了aiohttp.ClientTimeout(total=0.5),并配合return_exceptions,确保单个服务故障不影响整体响应。
6. 效果数据:实测对比
用wrk -t8 -c400 -d30s压测(4个gunicorn worker),数据如下:
| 指标 | 同步版本 | asyncio版本 | 提升幅度 |
|---|---|---|---|
| QPS | 208 | 1536 | 638% |
| 平均延迟 | 450ms | 210ms | 53% |
| P99延迟 | 1.2s | 280ms | 77% |
| 线程池使用率 | 100% (打满) | 35% | - |
| gunicorn worker CPU | 98% | 62% | - |
性能提升原因:
1. 单个请求耗时从450ms降到210ms(理论最优是max(150,200,100)=200ms,实测接近)。
2. 线程不再被IO阻塞,同样线程数能处理更多并发请求。
3. gunicorn worker的CPU负载下降,因为asyncio是单线程事件循环,减少了线程切换开销。
注意:QPS提升6倍不仅仅是异步的功劳,还因为原来线程打满后请求在队列里排队,现在线程空闲率高,队列几乎不积压。
7. 总结:什么场景该用asyncio
这次改造的收益前提是:
- 大量IO等待(HTTP调用、数据库查询)
- 下游服务延迟在几十~几百ms
- 并发量高(>200 QPS)
如果瓶颈在CPU计算,asyncio反而会降低性能。另外,如果项目是Java/Go背景,直接用WebFlux或Goroutine可能更顺手。Python下asyncio适合轻量级改造,重写框架选FastAPI或aiohttp更彻底。
最后建议:生产环境务必配合gunicorn + gevent worker或uvloop进一步提升性能。我测试过uvloop能再带来15%的QPS提升,但需要额外依赖,权衡后没上。如果追求极致,可以试试。
以上是本次asyncio优化的完整记录。如果你在改造中也遇到EventLoop坑,欢迎留言讨论。代码已精简,可直接复制跑通。