1. 问题背景:一个同步IO地狱产生的慢接口
上个月接手一个订单聚合服务,业务逻辑很简单:前端调用 /api/v1/orders/{id},后端需要做三件事:
- 查MySQL订单主表(耗时约40ms)
- 调库存服务HTTP接口(耗时约650ms)
- 调用户画像服务HTTP接口(耗时约700ms)
- 调物流追踪服务HTTP接口(耗时约550ms)
代码逻辑是教科书级的“逐行执行”:
# 同步版:总耗时 = 40 + 650 + 700 + 550 = 1940ms ≈ 2s
def get_order_detail(order_id):
order = mysql_query(order_id) # 40ms
inventory = requests.get(f"http://inventory-svc/{order.product_id}", timeout=1).json() # 650ms
profile = requests.get(f"http://profile-svc/{order.user_id}", timeout=1).json() # 700ms
tracking = requests.get(f"http://tracking-svc/{order.tracking_no}", timeout=1).json() # 550ms
return merge(order, inventory, profile, tracking)
三个HTTP请求之间没有数据依赖,完全可以并行。但同步代码只能眼睁睁地等。运维给的监控数据显示:这个接口占用了网关30%的线程资源,但CPU使用率只有12%——线程全在阻塞等网络IO。
2. 环境与版本:老项目上动手术
项目是个运营了三年的Flask单体应用,Python版本从3.6升到了3.8。本次重构涉及的核心库版本如下:
- Python 3.8.10(注意:3.10之前asyncio.run有已知的loop复用问题,下面会讲)
- Flask 1.1.2(没有用Flask异步扩展,因为改造面太大)
- aiohttp 3.7.4(替代requests做并发请求)
- mysql-connector-python 8.0.19(MySQL查询是同步的,不在本次优化范围)
- gunicorn 20.1.0(部署,配置worker=4,worker-class=gevent)
我的方案很简单:保持Flask同步视图不变,内部用asyncio.run启动一个事件循环,在循环里并发执行三个HTTP调用。消息一发出,团队里有人提出疑问:asyncio.run不是会阻塞线程吗?没错,但它阻塞的是单个worker线程,而不再阻塞三个串行的网络等待。
3. 方案设计:asyncio + aiohttp完成IO密集并发
先看重构后的代码骨架,核心思路是用asyncio.gather并发调度三个独立IO任务:
import asyncio
import aiohttp
from flask import jsonify
# 异步版核心:gather并发三个HTTP调用
async def fetch_json(session, url, semaphore):
async with semaphore: # 信号量限制并发数,防止打爆下游
async with session.get(url, timeout=aiohttp.ClientTimeout(total=1)) as resp:
return await resp.json()
async def fetch_all_async(product_id, user_id, tracking_no):
# 每个worker复用同一个连接池
conn = aiohttp.TCPConnector(limit=50, ttl_dns_cache=300)
timeout = aiohttp.ClientTimeout(total=2, connect=0.5)
async with aiohttp.ClientSession(connector=conn, timeout=timeout) as session:
semaphore = asyncio.Semaphore(10) # 全局限流10个并发
tasks = [
fetch_json(session, f"http://inventory-svc/{product_id}", semaphore),
fetch_json(session, f"http://profile-svc/{user_id}", semaphore),
fetch_json(session, f"http://tracking-svc/{tracking_no}", semaphore)
]
return await asyncio.gather(*tasks, return_exceptions=True)
def get_order_detail_sync(order_id):
order = mysql_query(order_id) # 这块还是同步阻塞,但只占40ms
# 关键点:在同步函数里跑事件循环
results = asyncio.run(fetch_all_async(
order.product_id, order.user_id, order.tracking_no
))
# results是长度3的列表,顺序与tasks一致
inventory, profile, tracking = results
if isinstance(inventory, Exception): # 优雅降级
inventory = {}
return jsonify(merge(order, inventory, profile, tracking))
设计要点说明:
- 连接池复用:每个worker进程创建一次
aiohttp.TCPConnector,不要每次请求都新建TCP连接。连接复用能省掉TCP握手和TLS协商时间,实测大约节省30-50ms。 - 信号量限流:
Semaphore(10)保证即使突发流量,对下游服务最大并发也只有10个连接。没有这个保护,双11流量一来下游直接雪崩。 - 超时控制:总超时2秒,连接超时0.5秒。如果某个服务故障,整个接口最坏情况是2秒返回错误,而不是无限等待。
4. 踩坑记录:三个让生产环境差点回滚的问题
4.1 asyncio.run在gunicorn多worker下的EventLoop冲突
上线第一天,监控告警:内存暴涨,大量“Event loop is closed”错误。排查发现gunicorn的gevent worker和asyncio的事件循环有冲突。gevent会patch标准库的socket,而asyncio在Python 3.8里使用的是自有的selector实现。两者混用导致asyncio.run()每次创建新的事件循环时,底层的selector被gevent污染。
解决方案:把gunicorn的worker-class从gevent改成sync,然后额外启动4个gunicorn进程(每个进程一个worker)。因为asyncio本身就能处理高并发,不需要gevent来提升并发能力。改动配置后,内存稳定在800MB左右。
4.2 连接池连接泄漏
上线第二天,下游反馈单连接数暴涨。排查发现aiohttp.ClientSession没有正确关闭——在fetch_all_async里用了async with,但asyncio.run结束时,如果gather里的任务抛了异常,__aexit__可能不会触发。最后在finally块里手动await session.close()解决。
async def fetch_all_async(...):
session = None
try:
session = aiohttp.ClientSession(connector=conn)
...
return await asyncio.gather(...)
finally:
if session:
await session.close()
4.3 响应数据顺序错乱
asyncio.gather保持任务顺序,但不保证返回顺序——实际上gather返回顺序与tasks列表顺序一致。但如果你用asyncio.wait或as_completed,顺序就乱了。团队里一个同事用了as_completed后,把库存数据填到了用户画像字段里,花了半小时排查。记住:gather比wait更适合这种需要一一对应结果的场景。
5. 性能对比:响应时间分布与资源占用
重构前后在预发环境压测了30分钟,用wrk模拟真实流量(线程数8,连接数200,持续5分钟)。结果如下:
| 指标 | 同步版 | asyncio版 | 提升 |
|---|---|---|---|
| 平均响应时间 | 2.13s | 0.31s | 85.4%↓ |
| P95响应时间 | 2.44s | 0.35s | 85.7%↓ |
| P99响应时间 | 4.52s | 0.48s | 89.4%↓ |
| 吞吐量(QPS) | 45 req/s | 268 req/s | 495%↑ |
| Worker线程数 | 4个进程×32线程 | 4个进程×8线程 | 线程数减少75% |
| CPU使用率 | 32%(等待IO) | 68%(实际计算) | 利用率翻倍 |
最直观的变化:以前接口超时率大约1.2%(网关设置3秒超时),现在基本为0。下游服务监控显示,库存服务的负载从原来的每秒请求120次降到90次——因为连接复用减少了TCP握手开销。
6. 优化还差的最后一步:MySQL同步查询怎么办
目前MySQL查询还是同步的,占用40ms。如果要彻底异步化,可以用aiomysql替代mysql-connector-python,但改造涉及整个数据访问层,风险较高。目前40ms的占比已经从2%上升到12%(因为总时间减少了),后续考虑用aiomysql做连接池,预计还能砍掉30ms左右。
另一个值得说的小优化:给三个HTTP调用设置了不同的超时时间,库存和物流服务对实时性要求高(1秒超时),用户画像服务可以容忍稍慢(2秒超时)。用asyncio.wait_for分别包裹任务,避免一个服务慢拖累整体。
7. 总结:异步不是银弹,但IO密集场景收益巨大
这次重构的经验可以总结成三句话:
- 识别IO密集场景:如果代码里大量阻塞在
requests.get、socket.recv这类网络调用,且调用之间无数据依赖,asyncio能带来数量级的提升。本次QPS提升5倍,响应时间降低85%。 - 注意事件循环生命周期:
asyncio.run()每次创建新loop,在长驻进程里频繁调用会有开销。更优做法是进程启动时创建loop,通过loop.run_until_complete复用。但和gunicorn的兼容性需要验证。 - 限流一定要有:并发从3提升到30,对下游的冲击是10倍。没有
Semaphore保护,下游服务必挂。生产环境建议把信号量值做成可配置项,方便调优。
最后想说:同步代码写起来确实舒服,但面对IO密集场景,别犹豫,直接上asyncio。Python 3.8+的asyncio已经足够稳定,aiohttp的API设计和requests非常接近,迁移成本远低于你的想象。如果你的接口也卡在多个外部HTTP调用上,试试把同步串行改为异步并发,你会回来感谢我的。