部分情节为虚构演绎,仅供参考
说实话,我们团队维护着一个站内搜索引擎,爬虫集群每天要抓取上亿个网页。每个网页在抓取流水线里要打一堆布尔标记:有没有被抓取过、有没有被索引、内容有没有更新、有没有被 robots 协议禁止、是不是死链、需不需要重新抓取。这些标记本质上就是一堆 True 和 False。
听着挺简单对吧?不就是给每个 URL 贴几个布尔标签嘛,能有多难?但你猜怎么着?现实啪啪打脸!网页量一涨,这堆布尔标记直接把爬虫调度器整崩了——不是收录的「收」,是崩溃的「崩」。抓取队列积压、重复抓取风暴、索引更新延迟,整个搜索结果新鲜度从小时级退化到天级。
越想精准收录,越把集群收崩。
这大概是我做搜索引擎以来最反直觉的一段经历:明明每一步都在往「更省内存、更快调度」的方向走,结果却是一步一个坑。直到我放弃自己造轮子,才发现这个问题的正确解法。
2. 从 set 到位图数据库:五种方案轮番翻车
2.1 用 set 存已抓取 URL:内存直接爆
最开始用最朴素的方案,拿 set 存已抓取和待抓取的 URL:
crawled_urls = set()
indexed_urls = set()
dead_links = set()
need_recrawl = set()
单条 URL 平均长度 60 字节,进了 set 加上哈希表开销至少 150 字节。1 亿个 URL 光一个 crawled set 就要 15GB,四个 set 就是 60GB。而且每次调度器要查「哪些 URL 已抓取但未索引」,得做集合差运算,几亿条数据的 set 差集慢得要死。
2.2 用 list[bool] 按 URL 序号标记:指针的狂欢
后来给每个 URL 分配一个整数 ID,用 list 存布尔标记:
is_crawled = [False] * 500_000_000 # 5亿网页
is_indexed = [False] * 500_000_000
Python list 存的是指向 PyObject 的指针,每个指针 8 字节,5 亿元素光指针就 4GB,一个标记 4GB,四个标记 16GB。服务器内存直接 OOM。
2.3 用 bytearray:省了内存但调度慢
换成 bytearray,每个标记 1 字节:
is_crawled = bytearray(500_000_000) # 500MB
is_indexed = bytearray(500_000_000) # 又500MB
四个标记 2GB,还是太大。而且每次调度器要找「已抓取但未索引且非死链」的 URL,得同时遍历三个 bytearray 做与运算,Python 层循环慢得要死,一次调度扫描要好几分钟,抓取队列早就空了。
2.4 用 numpy 矩阵:调度快但动态扩容灾难
换成 numpy 把所有标记排成矩阵:
import numpy as np
# 5亿网页 × 6个标记位 = 30亿字节 ≈ 3GB
flags = np.zeros((500_000_000, 6), dtype=np.bool_)
向量化调度确实快,一次与运算几秒钟。但新 URL 不断被发现,numpy 定长数组扩容要全量拷贝 3GB。更要命的是,大部分网页已经稳定不再变化,只有不到 5% 需要重新抓取,numpy 不管稀疏密集照单全收,3GB 里 95% 都是 False,纯浪费。
2.5 自己写混合标记数组:踩坑十二天
前面的方案都不满意,我决定自己写一个混合数组——活跃 URL 用位图,稳定 URL 只存需要重抓的下标。听起来很美好,然后就踩了十二天坑。
2.6 小结
| 方案 | 5亿网页6标记内存 | 调度扫描 | 新增URL | 稀疏适配 |
|---|---|---|---|---|
| set 存URL | 60GB+ | 慢 | 快 | 天然稀疏 |
| list[bool] | 16GB+ | 慢 | 快 | 无 |
| bytearray | 3GB | 慢 | 快 | 无 |
| numpy 矩阵 | 3GB | 快 | 灾难 | 无 |
| 自写混合数组 | 理论几十MB | 自己写 | 自己写 | 有 |
五条路走下来,内存墙、调度墙、维护成本墙,三面夹击。
3. 破局思路:给索引系统装个自动变速箱
3.1 内存墙:省内存为什么等于快调度
很多人以为省内存只是省钱。但在爬虫调度器里,省内存直接决定调度速度。每次调度要扫描「已抓取但未索引的网页」,如果标记数据在 CPU 缓存里,一次扫描就是毫秒级;如果在主存里,就是百毫秒级;如果还要跨网络去 Redis 查,就是秒级。数据量越大,缓存命中率越低,调度越慢。
省内存的本质是让更多数据塞进 CPU 缓存,减少主存访问次数。时间和空间不是守恒关系,省内存恰恰能同时提速。
3.2 自动变速箱构想
盯着这个问题想了好几天,我突然想到:为什么不能让布尔数组像汽车变速箱一样自动切换?
- 抓取活跃的时候(比如新站上线大量页面待抓),用位图紧凑存储,调度快;
- 抓取稳定的时候(大部分页面已收录不再变化),只存需要重抓的下标,省内存;
- 活跃度变化时自动换挡——但换挡只在两个时机发生:创建数组时和调用 optimize() 时。平时标记 URL 都不换挡,避免来回抖动。
我越想越觉得靠谱,当晚就开干。然后就踩了十二天坑。
4. 自己写,踩了十二天坑
- 第一天:写了个能跑的混合标记类,稀疏场景内存只要几 MB,觉得自己是天才。
- 第二天:加了密集模式,阈值写死 10%,活跃度在阈值附近波动时疯狂来回切换,调度性能比不切还差。
- 第三天:加了滞回区间防抖,结果阈值判断和实际存储对不上,标记写串了,已索引的被当成未索引重复抓。
- 第四天:稀疏区用 array(‘I’) 存 URL 下标,下标越界不报错,静默溢出,排查了一整天。
- 第五天:批量标记接口写完,发现「按位置标记」和「按值过滤标记」两个语义写串了。
- 第六天:按位取反(已索引翻转成未索引)写完,count(True) 数字对不上——稀疏区取反后忘了翻转特殊值。
- 第七天:支持 in 运算符判断某 URL 是否已抓取,结果每次全量扫描,5 亿 URL 查一次好几秒。
- 第八天:统计待抓取数量的方法数字忽大忽小——缓存了统计结果但标记变更时缓存没失效。
- 第九天:自动换挡函数写完,换挡瞬间全量重建内部结构,大批量新 URL 涌入时卡了几百毫秒,调度超时。
- 第十天:支持 pickle 序列化,内部结构太复杂,存进去读出来数据全乱。
- 第十一天:查找第一个待索引 URL 的位置,稀疏区返回的是下标表位置而不是真实 URL ID,差了好几个量级。
- 第十二天:盯着 2000 多行代码,发现多线程安全、内存对齐、GC 压力全没处理,心态崩了。
最崩溃的是第十三天早上,我意识到自己犯了一个根本性错误:我把换挡做成了每次标记变化都可能触发的高频动作,结果活跃度一波动就疯狂重建内部结构。正确做法是换挡只在创建时和 optimize() 时发生,平时操作只在当前挡位内进行。
从零实现一个生产可用的混合布尔数组,真不是一个人两个月能干完的事。我决定去社区求助。
5. 转机:发帖求助,评论区集体推荐同一个库
我把踩坑经历整理成帖子发到技术社区,标题是:
「5 亿网页的抓取索引标记,set 爆内存、numpy 爆拷贝、Redis 爆延迟,怎么办?」
评论区画风出奇一致,所有人都在推荐同一个库:bool-hybrid-array。其中一条评论直接点醒了我:
「你那个自动变速箱构想,bool-hybrid-array 早就实现了。换挡只在创建时和调用 optimize() 时发生,平时插入查询都不换挡,所以不会抖。你之前的问题是把换挡做成了高频动作。」
对啊,换挡本来就该是低频的!创建时根据初始数据定好挡位,平时就在这个挡位里干活,只有活跃度发生大变化时才手动调一次 optimize()。这才是自动变速箱的正确打开方式。
评论区还提到:
- 「直接 pip install bool-hybrid-array,你这个场景它天生就是为这个设计的。」
- 「我用 numpy 存 URL 标记内存爆了,换它之后稀疏场景内存降了 90% 以上。」
- 「memory_usage(detail=True) 可以看详细内存占用,数字不会骗人。」
- 「我生产环境跑了半年,爬虫调度标记就是它的主场,稳得很。」
- 「密集区用位图、稀疏区只存下标,两边都是成熟方案,不是野路子。」
- 「月下载量过万,迭代了 100 多个版本,不是课程作业。」
- 「支持 numpy 直接转换,np.array(arr) 一行接进现有数据管道。」
- 「MIT 协议,商用随便用。」
- 「Python 3.9 到 3.14 全支持,PyPy 也没问题。」
- 「find 和 rindex 在稀疏区返回真实位置,不是下标表位置。」
我动手验了一下:
from bool_hybrid_array import BoolHybridArr
# 5亿网页,只有5%需要重新抓取
need_recrawl = BoolHybridArr(i % 20 == 0 for i in range(500_000_000))
print(need_recrawl.memory_usage(detail=True))
跑出来的数字:稀疏场景下 5 亿布尔值只占十几 MB,比 numpy 的 500MB 省了 90% 以上。我用 tracemalloc 独立验证过,误差在 1% 以内。但 memory_usage 是库自己算的,不是第三方审计的。我只能保证我这边对得上,你那边请自己测。别信我,也别信它,信你自己的测量。
6. 同类方案横向对比
6.1 RoaringBitmap:集合运算的工业标准
RoaringBitmap 把整数按高 16 位分桶,桶内根据密度在数组和位图之间自适应。它在 URL ID 去重、已抓取集合等场景下是工业标配,集合运算(并交差)极快。但它不是数组:没有 arr[i] 按位置访问的语义,不支持 append/pop,不保留数组长度和顺序。如果你的需求是「维护一个完整的、按 URL ID 排列的布尔标记序列」,它的集合语义就不对味了。
6.2 bitarray 和 pyarrow
bitarray 把每个布尔值压成 1bit,5 亿元素约 62.5MB,保留数组语义,但定长且无稀疏优化。pyarrow.BooleanArray 同样位压缩,强在列式存储和跨语言,但数组不可变,每次修改都要重建。
6.3 对比表
| 方案 | 5亿 bool 内存(5% 稀疏) | 数组语义 | 动态追加 | 稀疏自适应 | 集合运算 | 典型场景 |
|---|---|---|---|---|---|---|
| list[bool] | 4GB+ | 有 | 有 | 无 | 无 | 小规模原型 |
| numpy | 500MB | 有 | 无 | 无 | 向量化 | 密集定长数值计算 |
| bitarray | 62.5MB | 有 | 麻烦 | 无 | 位运算 | 密集位压缩 |
| pyarrow | 62.5MB | 有 | 无 | 无 | 有 | 列式存储跨语言 |
| RoaringBitmap | 约 25MB | 无(集合语义) | add/remove | 有 | 极强 | URL ID集合运算 |
| bool-hybrid-array | 约 25MB | 有 | 有 | 有 | 有但非主场 | 动态布尔数组稀疏密集自适应 |
6.4 缺点与适用边界
第一,optimize() 是低频操作,频繁手动调用会导致全量重建,抖动问题会回来。第二,换挡瞬间是 O(n) 全量拷贝,大规模数据可能上百毫秒。第三,非线程安全,多线程要自己加锁。第四,生态年轻,没有 RoaringBitmap 十年工业验证。第五,均匀分布 50/50 时和 numpy 打平,没有优势。第六,memory_usage 是自报数据,生产前请用 tracemalloc 自己验。
适用场景:稀疏+动态更新+单线程+数组语义,四个条件同时满足时最优。纯集合运算用 RoaringBitmap,均匀定长用 numpy。
bool-hybrid-array 的作者承诺现有公开接口不会删除(no removal policy),但行为细节可能随版本变化,上生产前务必在自己的数据上验证。安装一行命令:pip install bool-hybrid-array,项目在 Gitee 和 GitHub 上都有,MIT 协议,核心类 BoolHybridArr,API 和 numpy 高度兼容。别信我,信你自己的测量。

25

被折叠的 条评论
为什么被折叠?



