前言
先把标题里的前提点破:「每秒千次」对单一站点发起请求,不是技术指标,而是压站行为。任何有一定规模的网站,一秒收到上千次来自同一来源的请求,都会被判定为异常流量并触发限流甚至封禁;从合规角度,这也不符合「礼貌抓取」的基本共识。所以本文不教你冲这个数字,而是反过来讲:怎么用asyncio+aiohttp把并发压到一个可控、可解释的值,并配合限速访问公开页面。
需要明确:aiohttp是第三方库,不属于 Python 标准库,需要单独安装。本文只描述它的典型用法思路,具体 API、参数名与行为以 aiohttp 官方文档为准。
正文真正要讲的是两件事:一是aiohttp怎么和事件循环配合做异步请求,二是用asyncio.Semaphore控制并发上限——这才是高并发抓取里最有价值的工程技能,比任何「每秒多少次」的口号都管用。
一、aiohttp 是什么,为什么用它
标准库urllib.request是同步的,一个请求要阻塞到响应回来。放进协程里,它会把整个事件循环卡住。aiohttp提供了基于asyncio的 HTTP 客户端,让「发出请求」和「等待响应」这两步都能await,等待期间事件循环可以去处理其他请求。
一个最典型的用法骨架是:先建一个客户端会话(session),在会话里发起请求,请求返回的对象作为异步上下文管理器读取响应体。
# 适用于 Python 3.8+;需要第三方库 aiohttp,API 细节以其官方文档为准
import asyncio
import aiohttp
async def fetch_one(session, url):
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.text()
async def main():
async with aiohttp.ClientSession() as session:
html = await fetch_one(session, "https://example.com/")
print(len(html))
asyncio.run(main())这里有两个实践原则:
- 一个会话复用连接。
ClientSession内部维护连接池,复用 TCP 连接能显著减少握手开销。不要每请求都新建 session。 - 会话要用
async with或手动await session.close()。忘了关闭会留下「Unclosed client session」警告,长期运行会泄漏资源。
二、用 Semaphore 限制并发上限
无限并发是这个领域最大的坑。你以为「有多少 URL 就并发多少」是高效,实际上:
- 本地文件描述符会被耗尽;
- 对目标站构成突发压力,容易被封;
- 大量连接同时等待,反而拖慢整体。
正确做法是用信号量(asyncio.Semaphore)把「同时进行」的请求数量锁死在一个小值上。信号量的语义是:拿不到名额就等,有人释放了才轮到下一个。
# 适用于 Python 3.8+;需要第三方库 aiohttp
import asyncio
import aiohttp
async def fetch_one(session, sem, url):
async with sem: # 超过上限就在这里排队
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.text()
async def crawl(urls, limit=5, delay=0.5):
sem = asyncio.Semaphore(limit) # 最多 limit 个请求同时进行
results = []
async with aiohttp.ClientSession() as session:
async def task(u):
html = await fetch_one(session, sem, u)
await asyncio.sleep(delay) # 每个请求后主动退让,降低频率
return html
tasks = [asyncio.create_task(task(u)) for u in urls]
for coro in asyncio.as_completed(tasks):
results.append(await coro)
return results信号量里有几个值得注意的细节:
limit设多大?没有万能值。对公开页面,从个位数起步,观察目标站的响应时间与是否返回 429/503,再决定要不要调整。宁可慢,不要打到对方。- 用
asyncio.sleep(delay)做请求间隔是简单的限速手段;想更精确可以算令牌桶,但对个人规模的采集没必要上那么复杂。 Semaphore是asyncio自带的协作式原语,不能用threading.Semaphore代替,后者会阻塞事件循环。
三、合规抓取的最低要求
并发能力是一把双刃剑,用之前先满足这几条:
- 遵守 robots.txt:标准库
urllib.robotparser可以解析它。被标注为不允许的路径就别抓。 - 只抓公开页面:不要试图绕过登录墙、付费墙——本文也不提供任何此类做法。
- 限速与退避:既控制并发数,也在收到 429/503 时退避重试,而不是加倍猛冲。
- 标明身份:给请求带上说明用途的 User-Agent,方便站长识别与联系,别伪装成浏览器做对抗。
- 尊重数据许可:抓下来的内容能否存储、二次使用,要看网站条款与当地法规。
# 适用于 Python 3.8+
import urllib.robotparser
def can_fetch(url: str, user_agent: str = "*") -> bool:
rp = urllib.robotparser.RobotFileParser()
rp.set_url("https://example.com/robots.txt")
# 实际使用时需先 rp.read()(会发起网络请求),这里只演示判定逻辑
rp.parse(["User-agent: *", "Disallow: /private/"])
return rp.can_fetch(user_agent, url)上面为了可读性,直接用parse()喂了几行规则文本;真实使用时可以先rp.read()去拉取 robots.txt。can_fetch(useragent, url)返回布尔值,告诉你这个地址是否允许抓取。
四、一个更完整的骨架:带超时与错误隔离
把前面的点拼起来,就是一段可以直接改改就用的骨架:会话复用、并发上限、单请求超时、失败不拖垮整体。
# 适用于 Python 3.8+;需要第三方库 aiohttp,API 细节以其官方文档为准
import asyncio
import aiohttp
async def fetch(session, sem, url, timeout=10.0):
async with sem:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp:
resp.raise_for_status()
return url, await resp.text()
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
return url, f"ERROR: {type(exc).__name__}"
async def main(urls):
sem = asyncio.Semaphore(5)
async with aiohttp.ClientSession() as session:
tasks = [asyncio.create_task(fetch(session, sem, u)) for u in urls]
for done in asyncio.as_completed(tasks):
url, body = await done
print(url, len(body))
if __name__ == "__main__":
asyncio.run(main(["https://example.com/"] * 10))要点解释:
- 每个任务内部包了
try/except,单个请求失败只影响它自己,其他任务照跑。这是「错误隔离」。 - 超时用
aiohttp.ClientTimeout(其具体参数名以官方文档为准),配合asyncio.wait_for也可以,但一层就够,不要叠。 as_completed让结果按完成先后返回,不必等最慢的那个。
再次强调:把Semaphore(5)改成Semaphore(1000)不会让你「每秒抓一千次」,只会让你更快被封、更快把本地文件描述符用光。
常见坑点
- 把「每秒千次」当目标
❌ 无限并发猛冲单一站点
✅ 用Semaphore把并发压到个位数,并加请求间隔
- 每个请求都新建 ClientSession
❌ 在fetch里aiohttp.ClientSession()
✅ 全程复用一个 session,用async with管理生命周期
- session 用完不关
❌ 脚本结束冒出 Unclosed client session 警告
✅async with aiohttp.ClientSession() as session:或显式await session.close()
- 用线程版同步原语
❌threading.Semaphore/queue.Queue放进协程
✅ 用asyncio.Semaphore/asyncio.Queue
- 不读响应、不判断状态码
❌ 只session.get(url)就算成功
✅resp.raise_for_status(),并对 429/503 做退避
- 收到限流还加倍重试
❌ 429 后立刻再发更多请求
✅ 指数退避,仍被拒就停手
- 绕过 robots.txt 或登录墙
❌ 想方设法抓不允许访问的内容
✅ 只用合规途径访问公开页面,遵守站点规则
- 把 aiohttp 的 API 当标准库背书
❌ 凭记忆写参数名并声称是官方接口
✅ 不确定就以 aiohttp 官方文档为准,只写确定的用法
总结
| 主题 | 错误认知 | 正确做法 |
|---|
| 并发数 | 越多越快 | 用Semaphore限制上限 |
| 会话 | 每请求一个 session | 全程复用,用后关闭 |
| 频率 | 冲到极限 | 加延迟、退避、遵 robots.txt |
| 异常 | 一失败全崩 | 任务内 try/except 隔离 |
| 库属性 | 标准库 | 第三方,以官方文档为准 |
aiohttp的价值在于让成百上千个「等待响应」的时刻重叠起来,而不是让你把某个站点打垮。真正需要练的功夫,是知道并发上限该设多少、什么时候退避、哪些内容不该抓。把这些想清楚,你写的采集脚本才是能长期跑下去的。