Skip to content

位图、布隆过滤器与前缀树:海量数据的判存与过滤 ​

「40 亿个 QQ 号,1GB 内存,判断某个号码在不在」——这类问题的解法都指向同一件事:放弃存原始数据,只存足够回答问题的信息。实测在 2000 万的范围内标记 1000 万个整数,HashSet 占 522MB,位图只占 2.4MB。

本文实测三种判存结构的内存与误判率,以及敏感词过滤中前缀树相对逐词匹配的差距。实验在 JDK 21 上完成,内存用 JOL 按对象图统计,见文末配套实验。

一、先说结论 ​

  • 键是连续整数、要求精确 → 位图。 实测 2000 万范围的位图占 2.4MB,是同样数据的 HashSet 的 1/219;40 亿个整数的位图约 0.47GB,能放进 1GB 内存。
  • 键是任意字符串、能接受误判 → 布隆过滤器。 实测 100 万个 URL,目标误判率 1% 时占 1.1MB,实际误判率 1.011%,且已加入的元素不会被判为不存在。
  • 数据稀疏时位图很浪费,改用 RoaringBitmap 这类压缩位图。
  • 敏感词过滤用前缀树(DFA),实测 1 万个词、20 万字文本:前缀树 4.9ms,逐词 indexOf 788.1ms,相差 160 倍。
  • 组合使用最实用:布隆过滤器挡住绝大多数不存在的请求,剩下的再查精确存储。

二、三种判存结构 ​

位图 BitSet标记 1000 万个整数 → 2.4MB布隆过滤器100 万 URL → 1.1MBHashSet同样 1000 万个整数 → 522MB精确,无误判可能误判,不会漏判精确,可存任意对象要求键是连续整数不支持删除内存是位图的 219 倍实测误判率:目标 1% → 实际 1.011%;目标 0.1% → 实际 0.104%;已加入元素的漏判数为 040 亿个整数的位图约 0.47GB,可以放进 1GB 内存
图 1 · 位图适合连续整数且要求精确;布隆过滤器用可控的误判率换极小的空间;哈希表精确但最占内存

2.1 位图:一个整数一个 bit ​

java
BitSet bits = new BitSet(20_000_000);
bits.set(qqNumber);                  // 标记存在
boolean exists = bits.get(qqNumber); // 判断
int count = bits.cardinality();      // 有多少个

实测 2000 万范围内标记 1000 万个整数:

结构内存倍数
BitSet(20_000_000)2,441 KB1×
HashSet<Integer>534,286 KB219×

差距来自存储方式:HashSet 每个元素都要存 Integer 对象(16 字节)、Node 对象(32 字节)和数组槽位,而位图每个数字只占 1 bit。

40 亿个 QQ 号:位图需要 4,000,000,000 / 8 = 500MB(实际 0.47GB),1GB 内存放得下;如果用 long[] 存原始值则需要 29.8GB。

位图的限制也很明确:

  • 键必须能映射成有限范围的整数。 字符串 ID、UUID 要先映射,映射表本身可能比位图还大。
  • 稀疏数据浪费严重。 只标记 1000 个用户但 ID 分布在 40 亿范围内,位图仍然要 500MB。这时用 RoaringBitmap:它把整数空间分块,稠密块用位图、稀疏块用有序数组,在稀疏场景下能省几个数量级。
  • 只能回答「在不在」,不能存额外信息。

2.2 布隆过滤器:用误判率换空间 ​

布隆过滤器用 k 个哈希函数把元素映射到位数组的 k 个位置并置 1。查询时如果 k 个位置都是 1,说明「可能存在」;只要有一位是 0,说明「一定不存在」。

实测 100 万个 URL:

目标误判率位数组哈希函数个数实测误判率漏判
1%9,585,059 bit(1,170 KB)71.011%0
0.1%14,377,588 bit(1,755 KB)100.104%0

对比:同样 100 万个 URL(约 40 字节)放进 HashSet<String>,实测占 117,567KB,约 115MB。实测的误判率用另外 100 万个没有加入的 URL 统计。

两个关键性质:

  • 不会漏判(no false negative):实测 100 万个已加入元素,判断为「不存在」的有 0 个。所以它适合做「快速排除」。前提是没有删除元素、没有重建过滤器,写入和查询用的是同一组哈希函数与参数。
  • 会误判(false positive):判断为「存在」的可能实际不存在,误判率与位数组大小和哈希个数有关。

参数怎么定(n 是元素数量,p 是目标误判率):

text
位数组大小 m = -n × ln(p) / (ln2)²
哈希个数   k = (m / n) × ln2

实际工程中直接用 Guava 的 BloomFilter 或 Redis 的布隆过滤器模块,不用自己实现。

标准布隆过滤器不支持删除(把某一位清零会影响其他元素)。需要删除时用 Counting Bloom Filter(每位换成计数器,空间翻几倍),或者定期重建。

2.3 组合使用:黑名单网址过滤 ​

实际系统通常是三层:

text
请求 → 本地布隆过滤器(1MB,挡住 99% 的正常网址)
     → Redis 精确集合(命中布隆后再查,排除误判)
     → 数据库(Redis 未命中时兜底,并回填)

这样既控制了内存,又保证了结果准确:布隆过滤器说「不存在」就直接放行(不会漏判),说「可能存在」才继续查精确存储。

三、敏感词过滤 ​

敏感词 10,000 个,文本 20 万字逐词 indexOf把文本扫 10,000 遍前缀树(DFA)文本只扫一遍788.1 ms4.9 ms相差 160 倍,命中结果完全一致(均为 226 次)构建代价:前缀树需要预先构建,词库更新后要重建或增量更新
图 2 · 逐词匹配要把整篇文本扫 N 遍;前缀树沿着文本走一遍,每个位置最多向下走词的长度

3.1 逐词匹配为什么慢 ​

java
for (String word : words) {          // 1 万个敏感词
    if (text.contains(word)) { ... } // 每次都要扫描整篇文本
}

文本长度 L、词数 N,复杂度是 O(N × L)——文本被扫描了 1 万遍。

3.2 前缀树(DFA) ​

把所有敏感词构建成一棵树,公共前缀共享节点。扫描时沿文本走一遍,每个位置最多向下走「最长词的长度」:

java
static class Node {
    Map<Character, Node> next = new HashMap<>();
    boolean end;
}

static int countHits(Node root, String text) {
    int hits = 0;
    for (int i = 0; i < text.length(); i++) {
        Node cur = root;
        for (int j = i; j < text.length(); j++) {
            cur = cur.next.get(text.charAt(j));
            if (cur == null) break;             // 这个起点走不下去了
            if (cur.end) hits++;                // 命中一个词,继续向下找更长的词
        }
    }
    return hits;
}

实测 1 万个敏感词、20 万字文本:

方式耗时命中次数
前缀树扫描4.9ms226
逐词 indexOf788.1ms226

两种方法都统计所有出现位置,结果一致,速度相差 160 倍。耗时是预热 2 次后 5 次的中位数;词和文本随机生成(每个词 3—5 字,平均每千字埋一个词)。

3.3 实际系统还要处理什么 ​

前缀树只解决了「精确匹配」,真实的内容过滤还要应对对抗:

  • 变形词:拼音、同音字、繁体、字符间插入符号(敏*感*词)。常见做法是先归一化(转小写、去除非字母数字、繁转简、全角转半角),再匹配。
  • 误伤:正常词汇包含敏感词片段时要有白名单。
  • 词库更新:前缀树需要重建,可以用「双缓冲」——后台构建新树,构建完原子替换引用。
  • 分级处理:不同级别的词对应不同动作(替换、拦截、人工审核),而不是一刀切。

如果需求复杂,成熟的开源实现(如 Aho-Corasick 自动机的各种 Java 实现)比自己写更可靠——它在匹配失败时能跳到最长公共后缀继续,避免回退,复杂度更优。

四、怎么选 ​

问题结构
连续整数,要求精确,数据稠密位图
整数稀疏RoaringBitmap
任意键,能接受少量误判布隆过滤器
任意键,要求精确且数据量不大HashSet / Redis Set
大量关键词的文本匹配前缀树 / Aho-Corasick
只要基数(有多少个不同的)HyperLogLog,见 Redis 数据结构与编码

选择的依据是三个问题:键能不能变成整数、能不能接受误判、数据稠不稠密。

五、常见误区 ​

  • 「布隆过滤器会漏判」:不会。它只会把不存在的判成存在,实测已加入元素的漏判数为 0。
  • 「误判率可以调到 0」:不能,只能趋近;代价是位数组线性增长。
  • 「位图一定省内存」:数据稀疏时非常浪费,要用压缩位图。
  • 「敏感词过滤就是遍历词库」:实测慢 160 倍,且真实场景还要处理变形词。
  • 「用了布隆过滤器就不用查数据库」:命中「可能存在」时仍然要查精确存储。

小结 ​

这类问题的思路是统一的:先想清楚要回答什么问题,再选择「只够回答这个问题」的结构。判断「在不在」不需要存原始值——位图用一个 bit,布隆过滤器用几个 bit 换一点误判率;匹配大量关键词不需要逐个比较——前缀树把公共前缀合并,一遍扫描就够。内存和时间的数量级差距,往往就来自这一次选择。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权