事情是这样的:上周五下午,我正准备收尾一个数据同步脚本,想着用 asyncio 并发拉取 30 个 API 分页,写完一跑——请求居然还是串行的。3 小时的排查,才发现自己掉进了事件循环的“隐形陷阱”。这篇复盘,希望能帮你少走弯路。
背景:一个看似简单的并发需求
我们要从某个开放平台拉取全量订单,对方限制了单页 100 条,共有 30 页。同步逻辑很清晰:先获取总页数,然后同时发出 30 个请求,最后合并结果。我第一反应就是上 asyncio + aiohttp,心里默念“这不就是 IO 密集型的标准剧本嘛”。
初版代码长这样:
import asyncio
import aiohttp
async def fetch_page(session, page: int):
url = f"https://api.example.com/orders?page={page}&size=100"
async with session.get(url) as resp:
data = await resp.json()
return data["items"]
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch_page(session, p) for p in range(1, 31)]
results = await asyncio.gather(*tasks)
# 合并所有返回的订单列表
all_orders = [item for page in results for item in page]
print(f"共获取 {len(all_orders)} 条订单")
asyncio.run(main())
逻辑看着没毛病:创建 30 个协程任务,用 gather 并发执行,拿到结果后展开。可实际运行时,日志里请求是一个接一个返回的,总耗时约 30 秒,和同步 requests 没区别。协程去哪了?
排查过程:逐层剥开事件循环的伪装
第 1 层:怀疑 aiohttp 的 session 限制
我第一个念头是 ClientSession 内部有连接池限制,可能卡在 TCP 连接上了。于是给 session.get 加了 timeout,又显式设置了连接数:
connector = aiohttp.TCPConnector(limit=50)
async with aiohttp.ClientSession(connector=connector) as session:
...
没变化。排除连接池。
第 2 层:怀疑“假协程”占据了任务
我打印了每个 fetch_page 的开始和结束时间,发现所有任务确实同时启动了,但都在等第一个请求返回后才继续。这不科学——await resp.json() 应该交出控制权才对。于是我用 asyncio.ensure_future 替换了 gather,手动逐一轮询,也没改观。
第 3 层:罪魁祸首浮出水面
最后我把抓包打开,发现实际上 所有请求几乎是同时发出的,但服务端返回了 429(限流),而我的代码里根本没处理状态码。aiohttp 在遇到 429 时默认会重试,但重试策略是阻塞式的?不对,aiohttp 不会自动重试。真正的问题在下面。
原来,服务端反爬策略要求在请求头里带上 签名,而签名算法依赖时间戳,时间戳精确到秒。我在构造请求头时,用的是 time.time() 并截断到秒,结果 30 个任务在同一秒内使用了相同的签名,服务端只接受了第一个,其余全部返回 403。我的代码在拿到 403 后没有抛异常,而是 return data["items"] 直接 KeyError,但被 gather 默认吞掉了异常(当时没设 return_exceptions=True),导致事件循环一直在等超时重试——这就是串行假象。
真正的解决方案 & 并发优化
知道问题后,修起来就清晰了:
- 签名中加入随机 nonce 或递增序号,确保同一秒内签名不同;
- 给
gather加return_exceptions=True,让错误显式化; - 加上
asyncio.Semaphore控制并发数,防止打爆对方 API。
优化后的完整代码(可直接运行):
import asyncio
import aiohttp
import hashlib
import time
import os
from typing import List, Dict
API_SECRET = os.getenv("API_SECRET", "dev-secret")
MAX_CONCURRENT = 10 # 控制同时进行中的请求数
async def fetch_page(session, sem: asyncio.Semaphore, page: int):
# 1. 构造带 nonce 的签名,避免同一秒内签名重复
ts = int(time.time())
nonce = f"{page}-{ts}-{os.urandom(4).hex()}"
raw = f"{ts}{nonce}{API_SECRET}"
sign = hashlib.sha256(raw.encode()).hexdigest()
headers = {
"X-Timestamp": str(ts),
"X-Nonce": nonce,
"X-Sign": sign,
}
url = f"https://api.example.com/orders?page={page}&size=100"
async with sem: # 信号量控制并发上限
async with session.get(url, headers=headers) as resp:
if resp.status == 429:
# 如果仍然超限,简单重试一次(实际可加指数退避)
await asyncio.sleep(2)
async with session.get(url, headers=headers) as retry_resp:
retry_resp.raise_for_status()
data = await retry_resp.json()
else:
resp.raise_for_status() # 非 2xx 直接抛异常
data = await resp.json()
return data["items"]
async def main():
sem = asyncio.Semaphore(MAX_CONCURRENT)
connector = aiohttp.TCPConnector(limit=20)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [fetch_page(session, sem, p) for p in range(1, 31)]
# return_exceptions=True 可以捕获单个任务的异常而不中断 gather
results = await asyncio.gather(*tasks, return_exceptions=True)
all_orders = []
for idx, res in enumerate(results, start=1):
if isinstance(res, Exception):
print(f"页 {idx} 请求失败: {res}")
else:
all_orders.extend(res)
print(f"成功获取 {len(all_orders)} 条订单")
if __name__ == "__main__":
asyncio.run(main())
实测优化后,10 个并发请求在 3 秒内完成 30 页拉取,比最初版本快了近 10 倍。
这些坑,你可能也会踩
-
asyncio.gather 默认会抛异常:如果某个任务崩了,其他任务的结果会被丢弃,只有把
return_exceptions=True打开才能收集所有结果并分别处理。很多人以为 gather 像 Promise.all 一样“部分成功”,其实它更严格。 -
事件循环不会魔法:协程只有在遇到
await时才会让出控制权。如果你在协程里写了time.sleep()或者调了一个同步库(如requests),整个事件循环就卡死了。排查时可以用asyncio.current_task()和日志查看任务切换点。 -
服务端限流是并发的隐形杀手:一味提升客户端并发数,如果服务端返回 429/403,重试策略不得当,线程池反而会陷入“重试风暴”。用信号量控制并发数,并实现指数退避重试,是生产环境的必备修养。
-
aiohttp 的 session 要复用:千万别在每个请求里新建
ClientSession,那样不但无法复用连接池,还可能导致文件描述符泄露。用async with包裹整个会话生命周期。 -
别忘了结构化异常:HTTP 状态码检查不要依赖返回数据,先用
resp.raise_for_status()强制校验,再用try/except捕获aiohttp.ClientResponseError,否则一个 KeyError 就会把所有线索埋没。
一句话总结
asyncio 的并发能力值得信任,但并发效果容易被“签名重复”、“隐藏异常”、“低效重试”这些现实细节吃掉,排查时不妨关掉隐式容错,让每个错误都浮出水面。
#Python #asyncio #后端开发 #踩坑日记 #高性能编程