asyncio中直接用set()去重会因add()非原子性导致并发重复添加,应改用pybloomlite等无锁布隆过滤器配合asyncio.Queue实现高效去重,兼顾性能与容错。

为什么 asyncio 里直接用 set() 去重会出问题
在 asyncio 环境下,多个协程并发访问同一个 set() 实例时,set.add() 不是原子操作——它先查再插,中间可能被其他协程抢占,导致重复 URL 被误判为“未见过”。这不是数据竞争的偶发现象,而是必然发生,尤其当 QPS 上升后复现率接近 100%。
常见错误现象:KeyError 不会出现,但日志里反复看到同一 URL 被 fetch() 多次调用,甚至触发目标站反爬限流。
- 不要用
if url not in seen: seen.add(url); do_fetch()这种模式 - 避免在协程间共享可变内置容器(
list、dict、set)作去重主逻辑 - 即使加了
asyncio.Lock,也会严重拖慢吞吐——锁争用会让异步优势归零
用 pybloomlite + asyncio.Queue 实现无锁布隆过滤器共享
pybloomlite 是纯 Python 实现、线程/协程安全的布隆过滤器,底层用 bytearray 和位运算,不依赖全局解释器锁(GIL)外的同步原语,适合 asyncio 场景。关键不是“用布隆过滤器”,而是怎么让它不成为并发瓶颈。
实操建议:
立即学习“Python免费学习笔记(深入)”;
- 初始化一个全局
BloomFilter实例(如容量capacity=100000,误差率error_rate=0.01),在事件循环启动前完成 - 所有协程直接调用
bf.add(url)和url in bf—— 这两个操作都是无锁、O(1) 的 - 把布隆过滤器和
asyncio.Queue组合:URL 入队前先过滤,避免无效入队放大内存压力
示例片段:
import asyncio
from pybloomlite import BloomFilter
<p>bf = BloomFilter(capacity=100000, error_rate=0.01)</p><div class="aritcle_card flexRow">
<div class="artcardd flexRow">
<a class="aritcle_card_img" href="/xiazai/skill11526" title="Python Hardener"><img
src="https://img.php.cn/upload/skill/001/246/273/6adaf20dbae7c507.jpg" alt="Python Hardener" ></a>
<div class="aritcle_card_info flexColumn">
<a href="/xiazai/skill11526" title="Python Hardener">Python Hardener</a>
<p>通过完整的安全审查、代码整洁重构、健壮的错误处理、正确的日志记录以及完整的文档字符串覆盖,强化 Python 脚本。</p>
</div>
<a href="/xiazai/skill11526" title="Python Hardener" class="aritcle_card_btn flexRow flexcenter"><b></b><span>下载</span> </a>
</div>
</div><p>async def safe_enqueue(queue, url):
if url in bf: # 布隆过滤器查重(假阳性可接受)
return
bf.add(url) # 插入也必须做,否则后续相同 URL 仍会通过
await queue.put(url)</p>布隆过滤器的假阳性和内存权衡怎么控制
布隆过滤器一定会漏判(假阴性为 0),但可能误判(假阳性)。对爬虫来说,“本该抓的没抓”是灾难,“本不该抓的抓了”只是多一次 HTTP 请求——所以容忍假阳性,但必须压低其概率。
参数选择直接影响效果:
-
capacity必须预估总 URL 数量级,设小了假阳性飙升;设大了浪费内存(pybloomlite占用约capacity * 1.2 / 8字节) -
error_rate建议 0.01~0.05:设成 0.001 会让内存翻倍,但假阳性从 1% 降到 0.1%,收益远不如加一层本地缓存 - 不要复用同一个
BloomFilter实例跨爬虫任务——不同种子、不同 URL 分布会导致误差率失控
验证假阳性率的小技巧:用测试 URL 列表跑两遍,统计第二次命中 url in bf 但实际未插入过的比例。
真正要防重的不是单次运行,而是多次启动间的 URL 冲突
布隆过滤器是内存结构,重启即丢。如果爬虫中断后重跑,会重复抓取已处理过的 URL——这才是线上最常被忽略的问题。
解决方案不是“换 Redis”,而是分层处理:
- 内存层:用
pybloomlite拦截 99% 的瞬时重复(协程内/本进程内) - 持久层:每次成功抓取并解析出新 URL 后,将原始 URL 写入一个追加式文件(如
seen_urls.log),用tail -n 10000类思路加载最近 N 条进布隆过滤器初始化 - 若需强一致性,才引入
aioredis的BF.ADD,但注意 Redis 布隆模块(RedisBloom)不是所有云服务商都默认开启
重点在于:布隆过滤器解决的是“并发去重”,不是“全量去重”。后者必须结合外部存储,且要考虑写放大和冷热分离。

















