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协议是同步阻塞的,强行异步会破坏兼容性。更优雅的方案是换aiohttpFastAPI,但业务代码迁移成本高。这里选择最小改造,风险可控。

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.runloop参数(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背景,直接用WebFluxGoroutine可能更顺手。Python下asyncio适合轻量级改造,重写框架选FastAPI或aiohttp更彻底。

最后建议:生产环境务必配合gunicorn + gevent workeruvloop进一步提升性能。我测试过uvloop能再带来15%的QPS提升,但需要额外依赖,权衡后没上。如果追求极致,可以试试。


以上是本次asyncio优化的完整记录。如果你在改造中也遇到EventLoop坑,欢迎留言讨论。代码已精简,可直接复制跑通。