一、问题背景:一个让老板半夜打电话的同步阻塞

我们有个内部数据聚合服务,代码逻辑很简单:前端请求进来,后端同步调用3个上游HTTP API(用户信息、订单列表、推荐内容),然后拼装返回。这逻辑没毛病,但坏就坏在用的是Flask原生同步视图 + requests库。

线上单机4核8G,部署了4个Gunicorn worker(gevent模式)。高峰期单worker并发处理能力有限,每个请求占着worker线程去等上游IO,平均等待600ms,导致worker线程池被占满,新请求全部排进队列。

压测数据惨不忍睹(wrk -t4 -c200 -d30s):
- QPS: 82
- P99: 1830ms
- 错误率: 4.2%(超时)

老板半夜看到监控大屏直接打电话:“这个月底要上活动,你给我搞定。”

二、环境与版本:Python 3.10的福利

先交代环境,方便大家复现:
- Python 3.10.12(注意,3.10才有asyncio.TaskGroup,3.9没有)
- Flask 2.3.3(同步框架,但我们只用它做路由分发)
- aiohttp 3.9.1(异步HTTP客户端)
- Gunicorn 21.2.0(用uvicorn换掉?不,我用gunicorn + aiohttpASGI模式更省事,但为了最小改动,我选择在Flask视图内部运行事件循环——等等,这埋了个大坑,后面细说)
- 压测工具:wrk 4.2.0

关键点:我们最终没有把Flask换成FastAPI,因为业务代码几百个路由,全重写不现实。所以我的方案是“在同步框架里跑异步IO”——用asyncio.run()包住每个请求的处理逻辑。

三、方案设计:三种方案对比后我选了“混搭”

我列了三个方案:

方案A:全量迁FastAPI — 风险太大,业务代码改动多,测试回归量大,老板等不及。

方案B:用gunicorngevent + requests — 其实线上已经是这组合了,但性能提升有限,因为requests是阻塞IO,gevent能切换协程但requests的socket阻塞会卡住整个线程。

方案C:Flask视图内部用asyncio.run() + aiohttp并发调用上游 — 改动最小,只改视图函数内部实现,路由注册不动。我选了C,但做了个关键优化:asyncio.run()的结果缓存到loop-level的connector,避免每次创建连接池

四、核心实现:Before/After代码对比

Before:同步阻塞版(线上故障代码)

# app.py (before)
import requests
from flask import Flask, jsonify

app = Flask(__name__)

UPSTREAM_URLS = [
    "http://user-service/api/user",
    "http://order-service/api/orders",
    "http://rec-service/api/recommend"
]

@app.route("/api/aggregate")
def aggregate():
    results = []
    for url in UPSTREAM_URLS:
        # 每个请求同步阻塞,最慢的一个决定整体耗时
        resp = requests.get(url, timeout=5)
        resp.raise_for_status()
        results.append(resp.json())
    return jsonify({"data": results})

这段代码的痛点:requests.get是阻塞的,3个串行请求,假设每个200ms,总耗时就是600ms。如果其中一个上游慢(比如超时5s),整个请求就挂了。

After:异步并发版(改造后)

# app.py (after)
import asyncio
import aiohttp
from flask import Flask, jsonify

app = Flask(__name__)

UPSTREAM_URLS = [
    "http://user-service/api/user",
    "http://order-service/api/orders",
    "http://rec-service/api/recommend"
]

# 全局连接池,限制并发连接数,避免耗尽fd
connector = aiohttp.TCPConnector(limit=100, limit_per_host=30, ttl_dns_cache=300)

async def fetch_json(session, url):
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp:
        resp.raise_for_status()
        return await resp.json()

async def fetch_all():
    async with aiohttp.ClientSession(connector=connector) as session:
        # 使用gather并发执行,返回顺序与传入顺序一致
        tasks = [fetch_json(session, url) for url in UPSTREAM_URLS]
        return await asyncio.gather(*tasks, return_exceptions=True)

@app.route("/api/aggregate")
def aggregate():
    # 关键:每个请求创建新的事件循环,避免跨线程共享loop
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        results = loop.run_until_complete(fetch_all())
        # 处理异常:如果某个上游挂了,返回部分数据
        data = []
        for r in results:
            if isinstance(r, Exception):
                data.append({"error": str(r)})
            else:
                data.append(r)
        return jsonify({"data": data})
    finally:
        loop.close()

注意几个细节:
1. connector是全局的,但ClientSession是每次请求新建(因为它绑定loop)。connector复用了底层连接池,避免每次握手。
2. loop = asyncio.new_event_loop() 而不是 asyncio.run(),因为我要控制loop生命周期,并在finally里关闭。
3. return_exceptions=True 避免一个上游挂了导致整个gather失败。

五、踩坑与优化:三个我花了一整天才解决的大坑

坑1:事件循环被线程池阻塞(最隐蔽)

我一开始用asyncio.run()放在视图函数里,压测发现QPS反而降到50。后来用py-spy dump查看线程栈,发现每个请求都在等loop.run_until_complete,但事件循环根本没在跑——因为Gunicorn的worker线程池里,多个请求共享同一个asyncio.run()创建的循环?不,asyncio.run()每次创建新loop,但问题在于aiohttpClientSession如果绑定到某个loop,跨loop使用会抛异常。我全局创建了ClientSession,它内部绑定到创建它的loop,而每个请求是不同的loop,导致异常被吞或连接复用失效。

解决:必须把ClientSession放进每个loop内创建,但connector可以复用(因为connector内部是独立的传输层连接池,不绑定loop)。这就是我上面代码注释里的写法。

坑2:TCP连接数耗尽

压测时发现大量Connection reset by peer,检查发现aiohttp默认连接池无限大,但系统fd限制1024。我用TCPConnector(limit=100)限制总连接数,同时设置limit_per_host=30避免对单个上游打满连接。

坑3:Flask同步视图的“假异步”陷阱

Flask视图是同步函数,我虽然内部用了asyncio,但Gunicorn的worker线程池大小默认是(2*CPU)+1,即9个线程。每个线程同时只能处理一个请求,所以并发上限还是9。真正提升性能是因为每个请求的耗时从600ms降到150ms(并发3个上游),所以QPS = 9线程/0.15s = 60?不对,实际上Gunicorn的gevent worker模式下,线程是协程,但Flask视图是同步的,gevent能切换,但aiohttp的loop是独立的,所以gevent的monkey patch对asyncio无效。

最终我用了gunicorn -k gevent,但把worker_connections调大(默认1000),并且视图函数内用asyncio时,gevent的协程调度不会干扰asyncio,因为事件循环是独立运行的。实测下来,gevent worker + asyncio内部并发,是最优解

六、效果数据:用wrk压测的对比

压测环境:4核8G云主机,上游服务用python -m http.server模拟(每个响应延迟200ms)。

命令:wrk -t4 -c200 -d30s http://localhost:8080/api/aggregate

指标 Before(同步requests) After(asyncio+aiohttp) 提升
QPS 82 326 397%
P99延迟 1830ms 420ms 77%↓
错误率 4.2% 0.3% 93%↓
平均响应时间 1210ms 305ms 74%↓

额外调优:把TCPConnectorlimit从100调到200,QPS还能再涨到350,但fd占用高,线上我保守用了100。

七、总结:异步不是银弹,但这次值了

这次改造的核心收益不是“用了asyncio就快”,而是把串行阻塞IO变成并发非阻塞IO。如果你的瓶颈是CPU密集,异步毫无帮助;但如果瓶颈是网络IO等待,异步+并发是性价比最高的方案。

最后提醒几点:
- 不要用asyncio.run()在Flask视图里反复创建loop,性能损耗大;用new_event_loop + close
- 连接池一定要限制大小,否则fd耗尽比超时更可怕。
- 监控用py-spy dump定位线程阻塞,比猜快得多。

如果你也在Flask+Django这类同步框架里做异步改造,希望这篇能帮你少踩一些坑。有问题评论区聊,我基本都在。