上周四下午,运营突然在群里 @ 我:“哥,活动页数据加载要 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 #异步编程 #性能优化 #后端实战