说实话,做采集最怕的是重复抓、抓重复。
你花了一整晚采集到几百万条数据,结果发现有三分之一是重复的,心情立刻从“数据工程师”变成“搬砖机器人”。
尤其在高并发环境下,几百上千个请求一齐发出,URL重叠是常态。
这时候,怎么判断一个URL是不是已经爬过,就成了系统稳定性的关键。
set() 不再可靠我们先看最常见的写法:
if url not in visited:
visited.add(url)
crawl(url)没错,set()查重在小项目里非常方便。
但当你把并发量开到几百甚至几千时,它会带来三个大坑:
set()分分钟上GB;visited集合完全不同;我第一次踩这个坑是在采集几个热门新闻网站的时候(包括财新网、第一财经、36氪、虎嗅、澎湃)。
刚开始还挺稳,半小时后服务器内存飙升、Redis报警、日志一堆重复URL。
——那一刻我才明白,“去重”不是功能,而是“防爆机制”。
后来我想着稳一点,用文件或SQLite数据库来存URL,毕竟这样可以“持久化”,不怕重启丢。
结果更惨。
文件I/O太慢,磁盘写锁频繁争用;SQLite在多线程场景下锁表;整套系统吞吐量直接砍半。
结论:文件去重方案在高并发下基本等于摆设。
于是我开始系统地比较各种方案:
方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
Bloom Filter | 用多个哈希函数映射到位数组 | 占内存小、速度快 | 有误判(少量URL被错判为已存在) |
Redis HyperLogLog | 概率统计唯一值数量 | 分布式天然支持 | 只能统计,不支持直接查重 |
持久化方案(LevelDB/SQLite) | 存入本地数据库 | 可恢复 | 性能差,不适合高并发 |
可以看到,没有哪个是“完美方案”。
要么快但不准,要么准但慢。
所以,答案很明显:我们得组合拳出击。
最后定下的架构是这样的:
整个数据流大概长这样:
URL输入 → Bloom Filter查重 → (新URL) → Redis队列 → 爬取 → 存库
↘ 每日写入文件备份既快、又有一定的安全感。
下面是完整Python示例代码,能跑、能抓、能查重。
我用爬虫代理IP来规避限制,大家可以按需替换自己的账号。
import requests
import mmh3
from bitarray import bitarray
import redis
import time
# ==============================
# 亿牛云爬虫代理配置
# ==============================
PROXY_HOST = "proxy.16yun.cn" # 代理域名
PROXY_PORT = "31111" # 端口号
PROXY_USER = "16YUN" # 用户名
PROXY_PASS = "16IP" # 密码
proxies = {
"http": f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
"https": f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}"
}
# ==============================
# Redis + Bloom Filter配置
# ==============================
redis_client = redis.Redis(host='localhost', port=6379, db=0)
BIT_SIZE = 10 ** 7
HASH_COUNT = 7
class BloomFilter:
def __init__(self, size, hash_count):
self.size = size
self.hash_count = hash_count
self.bit_array = bitarray(size)
self.bit_array.setall(0)
def add(self, item):
for i in range(self.hash_count):
digest = mmh3.hash(item, i) % self.size
self.bit_array[digest] = 1
def check(self, item):
for i in range(self.hash_count):
digest = mmh3.hash(item, i) % self.size
if self.bit_array[digest] == 0:
return False
return True
def fetch_url(url):
"""使用代理请求网页"""
try:
response = requests.get(url, proxies=proxies, timeout=10)
if response.status_code == 200:
print(f"[OK] {url}")
redis_client.pfadd("url_counter", url) # HyperLogLog统计唯一数
return response.text
else:
print(f"[Fail] {url} 状态码: {response.status_code}")
except Exception as e:
print(f"[Error] {url}: {e}")
if __name__ == "__main__":
bloom = BloomFilter(BIT_SIZE, HASH_COUNT)
urls = [
"https://www.caixin.com/",
"https://www.yicai.com/",
"https://www.36kr.com/",
"https://www.huxiu.com/",
"https://www.thepaper.cn/"
]
for _ in range(5): # 模拟高并发多轮爬取
for url in urls:
if not bloom.check(url):
bloom.add(url)
fetch_url(url)
else:
print(f"[Skip] 已采集过: {url}")
time.sleep(2)
# 每日持久化
with open("bloom_backup.bin", "wb") as f:
bloom.bit_array.tofile(f)
print("Bloom Filter 持久化完成。")
unique_count = redis_client.pfcount("url_counter")
print(f"Redis HyperLogLog 统计唯一URL数:{unique_count}")这段代码实际跑下来非常稳:
Bloom Filter 的设计很有意思:
它用多个哈希函数把一个URL映射到位数组中几个位置,只要这些位全是1,就认为“这个URL可能存在”。
因此有极小概率误判,但速度快得惊人。
HyperLogLog 则是个“数学怪才”,它并不关心每个URL,而是关心“有多少种不同URL出现过”。
适合做去重效果的统计监控,而不是直接判重。
持久化 就是我们的保险机制。
Bloom Filter一旦重启就清空,所以每天写文件一次,重启后可以再载入,避免历史重复。
层级 | 工具 | 作用 |
|---|---|---|
内存层 | Bloom Filter | 高速查重 |
分布式层 | Redis HyperLogLog | 唯一数统计 |
存储层 | 文件 / SQLite | 宕机恢复 |
做采集久了你会发现:
“去重”不是一个模块,而是一种系统设计哲学。
有时候,我们不需要完美的准确率,而是需要一个能在高并发下“稳住阵脚”的方案。
Bloom Filter + Redis + 持久化,正好是一种在速度、准确和可恢复性之间的平衡。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。