1. 问题背景:一个同步IO地狱产生的慢接口

上个月接手一个订单聚合服务,业务逻辑很简单:前端调用 /api/v1/orders/{id},后端需要做三件事:

  1. 查MySQL订单主表(耗时约40ms)
  2. 调库存服务HTTP接口(耗时约650ms)
  3. 调用户画像服务HTTP接口(耗时约700ms)
  4. 调物流追踪服务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.waitas_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密集场景收益巨大

这次重构的经验可以总结成三句话:

  1. 识别IO密集场景:如果代码里大量阻塞在requests.getsocket.recv这类网络调用,且调用之间无数据依赖,asyncio能带来数量级的提升。本次QPS提升5倍,响应时间降低85%。
  2. 注意事件循环生命周期asyncio.run()每次创建新loop,在长驻进程里频繁调用会有开销。更优做法是进程启动时创建loop,通过loop.run_until_complete复用。但和gunicorn的兼容性需要验证。
  3. 限流一定要有:并发从3提升到30,对下游的冲击是10倍。没有Semaphore保护,下游服务必挂。生产环境建议把信号量值做成可配置项,方便调优。

最后想说:同步代码写起来确实舒服,但面对IO密集场景,别犹豫,直接上asyncio。Python 3.8+的asyncio已经足够稳定,aiohttp的API设计和requests非常接近,迁移成本远低于你的想象。如果你的接口也卡在多个外部HTTP调用上,试试把同步串行改为异步并发,你会回来感谢我的。