bigywater.tech
爬虫工程 · 68

分布式去重:Redis SADD 的原子性

多个 worker 共享一份“已经抓过”的集合,最直接的写法是先查再加。但检查和加入之间有空隙。这篇用 8 个进程抢同一批 URL,量出这个空隙的代价,再比较几种去重结构的内存、吞吐和大 key 的清理问题。

17472 对 3000
8 个 worker 处理同一批 3000 个 URL:先 SISMEMBER 再 SADD 的抓取次数 与 只用 SADD 返回值的抓取次数
106.5 对 1.9 字节
每个 URL 在 Redis 里占用的内存:SET 存完整 URL 与 位图 Bloom(误判约 1.06%)
242 ms
DEL 一个 200 万成员的 SET 时,别的连接的 PING 被挡住的最长时间(UNLINK 是 1 ms)

先查再加有空隙

实测 · 本机执行输出
  先 SISMEMBER 再 SADD:3 次实验抓取次数 [17472, 16433, 18098](不同 URL 一共 3000 个,多出来的是重复抓取)
  只用 SADD 的返回值(1 = 新加入):3 次实验抓取次数 [3000, 3000, 3000](不同 URL 一共 3000 个,多出来的是重复抓取)

SISMEMBER 和 SADD 是两条命令,两条之间别的 worker 也能通过检查。8 个步调一致的 worker 把 3000 个 URL 抓了一万七千多次。SADD 本身是原子的,它的返回值(1 表示新加入)就是“我是第一个”,只用返回值,3000 个 URL 恰好 3000 次。

占多少内存

实测 · 本机执行输出
  URL 平均 64 字节
  SET:完整 URL 字符串                                  30.5 MiB,平均每个 URL  106.5 字节
    编码 hashtable
  SET:blake2b 8 字节摘要                              12.2 MiB,平均每个 URL   42.5 字节
  SET:md5 16 字节摘要                                 16.7 MiB,平均每个 URL   58.5 字节
  位图 Bloom(每个元素 9.6 位,7 个哈希)                       0.6 MiB,平均每个 URL    1.9 字节
    对 100000 个没加入过的 URL 查询,Bloom 误判 1064 个 = 1.06%(理论值约 1%)
  HyperLogLog(只能数个数,不能判断是否见过)                      0.0 MiB,平均每个 URL    0.1 字节
    PFCOUNT = 299568(真实 300000,误差 0.14%)
    8 字节摘要在 300000 个 URL 里出现碰撞的概率 ≈ n²/2^65 = 2.4e-09(这是算出来的,没有实测)

30 万个平均 64 字节的 URL,SET 存完整字符串约 30.5 MiB(每个 106.5 字节),存 8 字节摘要 12.2 MiB,存 16 字节 md5 16.7 MiB。Bloom 位图只要 0.6 MiB,代价是有误判(实测 1.06%,即约 1% 的新 URL 会被当成见过而漏抓)。HyperLogLog 只能估计个数。8 字节摘要的碰撞概率是算出来的(30 万个时约 2.4e-09),没有实测。

吞吐

实测 · 本机执行输出
  逐个 SADD(一次往返一个,只跑 2 万个)                        中位     22109 个/秒(21431–22462)
  pipeline,每批 100 个(每个 SADD 都返回新/旧)              中位    185879 个/秒(181536–198964)
  pipeline,每批 1000 个                             中位    262806 个/秒(262218–262929)
  一条 SADD 带 1000 个成员(只返回新加入的个数)                  中位    820929 个/秒(791220–871517)

逐个 SADD 一次往返一个,约 2.2 万个/秒;pipeline 每批 1000 个约 26.3 万个/秒并保留每个返回值;一条 SADD 带多个成员更快(82 万个/秒),但只返回新加入的总数,不知道是哪几个。

Bloom 也要原子

实测 · 本机执行输出
  先 GETBIT 全部检查,没见过再 SETBIT:3 次实验抓取次数 [9590, 9713, 9688](3000 个不同 URL;位图很大,Bloom 自身的误判可以忽略)
  在 MULTI/EXEC 里对 7 个位置 SETBIT,用返回的旧值判断:3 次实验抓取次数 [3000, 3000, 3000](3000 个不同 URL;位图很大,Bloom 自身的误判可以忽略)

Bloom 的“先检查再置位”同样有空隙(抓了九千多次)。SETBIT 会返回旧值:在 MULTI/EXEC 里对 k 个位置 SETBIT,旧值全是 1 才算见过,一次完成测试和置位(3000 次)。

带过期时间

实测 · 本机执行输出
  连续 3 次 SET NX EX 2 的返回:[True, None, None];等 2.2 秒后再 SET NX:True(返回 True 表示拿到了,None 表示已存在)
  10 万个 URL:每个 URL 一个带过期时间的 key(SET NX EX):平均每个 84 字节;全部放进一个 SET(8 字节摘要):平均每个 37 字节

只想“多久之内不重复抓”时,每个 URL 一个 key,用 SET NX EX:第一次拿到,之后返回空,过期后又能拿到。内存比单个 SET 大(每个 84 字节对 37 字节)。

大 key

实测 · 本机执行输出
  DEL:命令本身返回用了 242 ms;期间 PING 最大延迟 242 ms(共 373 次,中位 0.11 ms)
  UNLINK:命令本身返回用了 0 ms;期间 PING 最大延迟 1 ms(共 377 次,中位 0.12 ms)

Redis 单线程执行命令。DEL 一个 200 万成员的 SET 用了 242 ms,别的连接的 PING 也被挡了这么久;UNLINK 立刻返回,PING 最大 1 ms。去重集合会越长越大,清理时用 UNLINK,或按域名、日期拆分。

分步演示

分布式去重
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。redis-server 8.10.2 独立实例(只监听 127.0.0.1 的 9700 以上端口,关闭持久化,实验后关闭;没有碰本机已有的 Redis),redis-py 8.0.1;第一、四节是 8 个独立 Python 进程同时开始(每种做法 3 次);第二节每种结构一次,用 INFO memory 的 used_memory 差值(包含 Redis 的分配开销,不是纯数据大小);第三节每种方式 3 次取中位数;第五、六节各一次
    • 没有验证:Redis 的 Bloom 模块(RedisBloom,BF.ADD / BF.EXISTS);Redis 集群模式下 SET 分片和跨分片去重;持久化(AOF/RDB)打开时 SADD 的耗时和重启后的恢复;多个 Redis 副本之间复制延迟引起的重复;真实 URL 集合的分布(这里的 URL 是合成的);8 字节摘要的真实碰撞率(只算了理论值);worker 步调不一致时先查再加的重复数(这里是最坏情形,重复远少于这个数)
    京ICP备2026067502号-1京公网安备11010502063487号