我用 asyncio 把数据聚合服务从 30s 干到 2s,效果出乎意料

0 阅读1分钟

上周四下午,运营突然在群里 @ 我:“哥,活动页数据加载要 30 多秒,用户都在投诉。”我打开监控一看,那个数据聚合接口内部串行调用 12 个下游 API,每个耗时 2-3 秒,累计直接飙到 30 秒以上。这接口是我两年前写的,当时觉得“功能优先”,结果现在成了瓶颈。我心里一横:改异步。花了一个下午用 asyncio 重构,上线后压测结果让后端群直接沸腾——平均响应时间从 32 秒降到 2.1 秒。这篇文章就复盘整个过程,把代码、思路和踩的坑全部分享出来,希望能帮到同样被同步阻塞折磨的朋友。


为什么非得 asyncio,而不是多线程?

很多同学第一反应是:“你不开线程池吗?”我试过 concurrent.futures.ThreadPoolExecutor,在 12 个下游全打满的情况下,线程上下文切换开销加上 GIL 竞争,CPU 吃满但吞吐上不去,而且超时控制极其难受。我们的场景是典型的 IO 密集型:接口 99% 的时间在等网络响应,CPU 几乎闲置。这正是 asyncio 的甜点区——单线程事件循环,任务挂起不占线程,切换成本几乎为零。

简单说,同步模型是“一个一个排队买票”,asyncio 是“所有人同时用手机下单,谁先响应就先处理谁”。

核心方案:用 aiohttp + gather 并发撸下游

重构的第一步是选型。我没有用裸 asyncio 搭 HTTP 客户端,而是直接上 aiohttp,它原生支持异步且连接池管理优秀。核心代码如下:

import asyncio
import aiohttp
from typing import List, Dict, Any

async def fetch_one(session: aiohttp.ClientSession, url: str) -> Dict[str, Any]:
    """单个 API 调用,带超时和异常兜底"""
    try:
        # aiohttp 的 timeout 是总超时,包含连接+读取
        async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
            resp.raise_for_status()
            return await resp.json()
    except Exception as e:
        # 生产环境一定要接住,别让一个下游拖死整个聚合
        return {"error": str(e), "url": url}

async def aggregate_data(api_urls: List[str]) -> List[Dict[str, Any]]:
    """并发调用所有下游,gather 收集结果"""
    # 连接器:限制总连接数,防止打爆下游
    connector = aiohttp.TCPConnector(limit=20, limit_per_host=5)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [fetch_one(session, url) for url in api_urls]
        # return_exceptions=True: 不让单个异常中断 gather
        results = await asyncio.gather(*tasks, return_exceptions=True)
    return results

这里 asyncio.gather 是关键:12 个协程同时发起请求,总耗时只取决于最慢的那一个。也就是说,如果每个下游耗时 2-3 秒,理论上总时间能压到 3 秒左右,再加上连接建立和 JSON 解析的开销,2.1 秒的真实表现完全合理。

进阶优化:信号量限流 + 超时熔断

上面的代码在生产环境还不够稳。如果下游某些接口极其慢(比如突然变成 30 秒),或者突发流量把连接池打满,需要更精细的控制。我加了两把锁:

import asyncio
from asyncio import Semaphore

# 全局信号量:最多同时跑 8 个请求,防止下游被压垮
SEM = Semaphore(8)

async def fetch_one_limited(session: aiohttp.ClientSession, url: str) -> Dict[str, Any]:
    async with SEM:  # 获取槽位,超出数量的协程在此排队
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp:
                return await resp.json()
        except asyncio.TimeoutError:
            return {"error": "timeout", "url": url}
        except Exception as e:
            return {"error": str(e), "url": url}

async def aggregate_with_limit(api_urls: List[str]) -> List[Dict[str, Any]]:
    connector = aiohttp.TCPConnector(limit=20)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [fetch_one_limited(session, url) for url in api_urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
    return results

Semaphore(8) 限制了“同时在空中飞”的请求数,哪怕传进来 100 个 URL,也最多有 8 个并发。这既保护了下游服务,又避免了本地端口耗尽。

踩坑实录:真金白银换来的教训

坑1:不小心把同步阻塞代码塞进协程

我最开始用 time.sleep(1) 做模拟测试,结果整个事件循环被卡死,所有请求串行执行。协程里绝对不能出现同步阻塞调用,必须用 await asyncio.sleep()。类似的坑还有:在协程里调用 requests.get(),它会把整个线程卡住直到响应返回。查这种问题可以用 asyncio 的 debug 模式PYTHONASYNCIODEBUG=1,它会警告“协程执行时间过长”。

坑2:异常处理不慎导致 gather 崩盘

asyncio.gather() 默认一旦某个任务抛异常,会立刻取消其他任务并抛出异常,导致整体失败。这对于需要部分可用结果的场景是灾难。必须设置 return_exceptions=True,这样异常对象会正常返回,我们可以在结果列表中遍历判断 isinstance(item, Exception),实现优雅降级。

坑3:忘记复用 aiohttp Session

每次请求新建 ClientSession 会重复建立连接,性能极差。正确的做法是在一个上下文里复用 session,甚至可以在应用启动时创建一个全局 session 并通过依赖注入传递。搭配连接池参数 TCPConnector(limit=...)keepalive_timeout 能进一步提升复用效率。

坑4:Jupyter / IPython 里直接 await

在 Jupyter Notebook 或 IPython 环境里,你可以直接写 await fetch_one(...),因为它本身运行在事件循环上下文中。但很多人习惯后用 asyncio.run() 套一层,结果在已有运行中的事件循环里报 “cannot be called from a running event loop” 错误。教训:了解你的执行环境,不要乱套 asyncio.run()

总结

asyncio 不是银弹,但面对高 IO 等待场景,它比多线程更轻、更稳、更好控。 这次重构让我彻底把项目里的同步阻塞代码全部换成了异步方案,不光聚合服务,连带着报表导出、消息推送模块都受益了。如果你也有一个“慢得令人发指”的接口,试试 asyncio,你会回来点赞的。

#Python #asyncio #异步编程 #性能优化 #后端实战