2026-10-20 布隆过滤器误判率实测
书:《数学之美》(吴军),布隆过滤器那一节。
本文所有数字都是我自己跑出来的,代码在文末。跑了两轮,第二轮改了什么,第四节会说。
一、先说书里的主张(我的概括,非原文)
布隆过滤器想解决一个问题:判断"某个东西在不在集合里",但不想真的存这个集合。
做法是准备一个很长很长的位数组,每个位置只有 0 和 1。插入一个元素时,用 k 个哈希函数算出 k 个位置,全置 1。查询时同样算 k 个位置,然后看:
- 有任何一位是 0 → 一定不存在
- 全是 1 → 可能存在(不能确定)
它不存元素本身,所以极省内存。代价就是"可能存在"这四个字——它会撒谎,但只朝一个方向撒。
二、我关心的是:它到底能错到什么程度
书里给了理论公式:
$$
p \approx \left(1 - e{-kn/m}\right)k
$$
其中 m 是位数,n 是已插入元素数,k 是哈希次数。
公式很干净,但我更想看一眼真实曲线。于是我写了三十行:一个 long[] 位数组(一个 long 管 64 位),两个哈希合成 k 个位置(双哈希法:第 i 个位置 = (h1 + i * h2) mod m),然后拿 10 万个从未插入过的字符串去查,数有多少被误判成"存在"。
参数:m = 100000 位(12.2 KB),k = 3。
三、第一轮跑出来是这样
n(插入数) 实际误判率 理论误判率 位填充率
1000 0.00% 0.00% 2.96%
5000 0.38% 0.27% 13.88%
10000 2.58% 1.74% 25.90%
20000 9.35% 9.18% 40.08%
50000 53.67% 46.89% 74.93%
n=10000 时实测 2.58%,理论 1.74%,差了 1.5 倍。n=50000 时更明显:53.67% vs 46.89%。
第二轮改 k(n 固定 10000),结果更奇怪:
k 实际误判率
1 14.26%
2 5.87%
3 2.78%
4 2.59%
5 2.02%
6 2.63%
7 2.47%
8 3.27%
k 从 4 到 8 之间来回跳,不但没降,反而在 k=8 时升到 3.27%。理论上 k 越大(在一定范围内)误判率越低,不该是这个形状。
四、问题出在我的两个哈希上
我一开始以为是查询次数太少、样本不够。算了一下:2.6% 的误判率、5 万次查询,标准差大约 0.07 个百分点——不足以解释 1 个百分点的跳动。所以不是噪声,是系统性的。
回去看位填充率那一列:25.90%,和理论值 1 - (1-1/m)^(kn) ≈ 25.9% 完全一致。也就是说,"置 1 了多少位"是对的,"1 分布得均不均匀"是错的。
原因在这里:我用的两个哈希是 String.hashCode() 和 FNV-1a。而我的测试字符串是 item-1234 这种——只差末尾几个数字。这类字符串经过这两个哈希后,低位分布有相关性。双哈希法 h1 + i*h2 的前提是这 k 个位置相互独立;一旦 h1、h2 相关,k 个位置就会挤在一块,等价于有效位数变少。位还是被填满了,只是填成了团。
于是我在两个哈希后面各加了一步混淆——一个 splitmix 风格的 mix 函数,把输入的每一位摊到输出上:
private static int mix(int x) {
x ^= x >>> 16;
x *= 0x7feb352d;
x ^= x >>> 15;
x *= 0x846ca68b;
x ^= x >>> 16;
return x;
}
再跑一遍:
n(插入数) 实际误判率 理论误判率 位填充率
1000 0.00% 0.00% 2.96%
5000 0.25% 0.27% 13.89%
10000 1.69% 1.74% 25.89%
20000 9.14% 9.18% 45.05%
50000 47.05% 46.89% 77.65%
四行全部贴住了理论值。改 k 的那一轮也变干净了:
k 实际误判率
1 9.68%
2 3.38%
3 1.77%
4 1.17%
5 0.98%
6 0.89%
7 0.89%
8 0.89%
单调下降,到 k=6 之后基本走平。
两次唯一的差别就是"哈希够不够散"。这件事让我对公式的态度变了一点:公式描述的是理想哈希下的行为;写代码时,哈希质量决定了你能不能走到公式那一行。
五、结论
它确实省内存:12.2 KB 装下 5 万个字符串,平均每个元素 0.25 字节。同样的数据装进 HashMap,光对象头和引用就要几 MB。
但省得有代价,而且代价不是线性的。填充率一旦过 50%,误判率就往上飙——n=50000 时填充率 77.65%,一半的查询都会给出错误答案。所以用它之前得先算好能装多少,不是装上了再说。
k 也不是越大越好。理论最优是 (m/n)·ln2,这里算出来约 6.93;实测到 6 之后就不再降了。再往上加,只是每次查询多算几遍哈希。
最要紧的是它不对称:能保证"一定不在",但永远保证不了"一定在"。所以它能用的地方,必须是可以接受偶尔误报的地方。
六、什么场景能用,什么场景不能用
| 场景 | 能不能用 | 误判的后果 |
|---|---|---|
| 爬虫 URL 去重 | ✅ | 漏抓一个页面,可以接受 |
| 缓存穿透防护 | ✅ | 多查一次数据库,可以接受 |
| 判断用户名是否被注册 | ❌ | 误判 = 告诉用户"已被占用",而它其实没被占 |
| 判断文件是否真的存在 | ❌ | 误判 = 该读的文件读不到 |
判断标准其实就一条:误判的代价,是否低于省下的内存。
附:完整代码
public class BloomFilterTest {
static final int M = 100_000; // 位数组大小(bit)
static final int K = 3; // 哈希次数
static final int QUERIES = 100_000; // 每轮用多少个不存在的字符串去查
public static void main(String[] args) throws IOException {
StringBuilder sb = new StringBuilder();
for (int n : new int[]{1000, 5000, 10000, 20000, 50000}) {
BloomFilter f = new BloomFilter(M, K);
for (int i = 0; i < n; i++) f.add("item-" + i);
int falsePositive = 0;
for (int i = 0; i < QUERIES; i++) {
if (f.mightContain("other-" + i)) falsePositive++;
}
double actual = (double) falsePositive / QUERIES;
double theory = Math.pow(1 - Math.exp(-(double) K * n / M), K);
sb.append(String.format("%-12d %-14s %-14s %-12s%n",
n, pct(actual), pct(theory), pct(f.fillRatio())));
}
System.out.print(sb);
Files.writeString(Path.of("report.txt"), sb.toString(), StandardCharsets.UTF_8);
}
}
class BloomFilter {
private final long[] words;
private final int m, k;
BloomFilter(int m, int k) {
this.m = m; this.k = k;
this.words = new long[(m + 63) >>> 6];
}
void add(String s) {
for (int pos : positions(s)) words[pos >>> 6] |= 1L << (pos & 63);
}
boolean mightContain(String s) {
for (int pos : positions(s)) {
if ((words[pos >>> 6] & (1L << (pos & 63))) == 0) return false;
}
return true;
}
double fillRatio() {
long ones = 0;
for (long w : words) ones += Long.bitCount(w);
return (double) ones / m;
}
private int[] positions(String s) {
int h1 = mix(s.hashCode());
int h2 = mix(fnv1a(s));
int[] pos = new int[k];
for (int i = 0; i < k; i++) pos[i] = Math.floorMod(h1 + i * h2, m);
return pos;
}
private static int mix(int x) {
x ^= x >>> 16; x *= 0x7feb352d;
x ^= x >>> 15; x *= 0x846ca68b;
x ^= x >>> 16; return x;
}
private static int fnv1a(String s) {
int h = 0x811C9DC5;
for (byte b : s.getBytes(StandardCharsets.UTF_8)) {
h ^= (b & 0xFF);
h *= 0x01000193;
}
return h;
}
}
浙公网安备 33010602011771号