近似集合成员查询(approximate membership)是数据库、缓存与存储系统的隐形支柱:LSM-Tree 用布隆过滤器挡掉 99% 的"肯定不在"的磁盘探测,CDN 用它在回源前判断对象是否该缓存。但布隆过滤器有个甩不掉的包袱——为 1% 误报率它要吃掉约 9.6 位/元素,而且查询要做 k≈7 次哈希探测。2019 年 Graf 与 Lemire 提出的 Xor Filter,以及 2021 年 Dillinger 与 Walzer 提出的 Ribbon Filter,把这件事重做了一遍:用"异或数组"与"丝带矩阵",把空间压到逼近信息论下界、把查询固定成 3 次访存。本文从布隆的空间账讲起,逐层剖析 Xor Filter 的 3-部超图剥离构建、Ribbon 的稀疏矩阵高斯消元,以及它们在工程里如何替换布隆,并给出一份可运行的 Python 教学版实现。
一、布隆过滤器的空间账:1% 误报率要 ~9.6 位/元素
布隆过滤器维护一个 m 位的比特数组,插入时对元素做 k 次独立哈希、把对应位设 1;查询时看这 k 位是否全为 1。它永远不会漏报(在集合里的一定说在),但可能误报(不在集合的恰好 k 位都被别人置 1)。给定元素数 n 与目标误报率 ε,经典结论是所需位数:
m = n * log2(1/ε) * log2(e) ≈ 1.44 * n * log2(1/ε) # 位 k = log2(1/ε) # 最优哈希次数
代入 ε = 1%(log2(1/ε) ≈ 6.64):每个元素约 9.6 位,要做 k ≈ 7 次哈希探测。这带来两个老问题:
- 空间随 log(1/ε) 线性膨胀:要 0.1% 误报率,位宽直接翻倍到约 14.4 位/元素——越严格越贵,且常数 1.44 始终存在。
- 查询是 k 次随机访存:7 次独立哈希意味着 7 次可能跨缓存行的内存读取,在内存带宽吃紧的热点路径上是实打实的开销。
Xor 与 Ribbon 的思路是:既然集合是静态的(构建后不再增删),就别再用"每元素多哈希投票"的宽松结构,而是用代数方法把整张集合"编码"进一个紧凑数组,查询时只做一次定长异或。
二、Xor Filter 的核心洞察:把"集合"压进一个 XOR 数组
Xor Filter 的核心想法一句话就能说清:为集合 S 准备一个长度为 m 的数组 A(m 略大于元素数 n),每个槽存一个 w 位的"指纹"。对集合里每个元素 x,用三个哈希函数算出它在数组里的三个位置 p1、p2、p3,以及一个指纹 f(x)。我们要给数组赋值,使得对每条元素都满足:
A[p1] ^ A[p2] ^ A[p3] = f(x) # 三个槽值的异或 == 该元素指纹
查询时,对任意元素 y(无论是否在集合里),同样算出它的 p1、p2、p3 与 f(y),检查 A[p1] ^ A[p2] ^ A[p3] == f(y):
- 若不等 → y 一定不在集合里(必然正确,无漏报);
- 若相等 → y 可能在集合里(有约 1/2^w 概率误报)。
为什么"异或"这么关键?因为异或运算满足结合律与自反性(a ^ a = 0),使得我们能用一套剥离(peeling)算法在构建时反推每个槽的值,而查询时又只需把三个槽的值再异或回来比对。它把"成员判断"退化成了"三个固定位置的异或比较",与布隆的 k 次探测形成鲜明对比。
三、三个哈希、三个位置、一个指纹
为了让三个位置"均衡且独立",Xor Filter 通常把数组切成三段等长的子区间,每个哈希函数只在自己的那段里取一个位置——这构成一个 3-部超图:每个元素是一条连接三段各一个顶点的超边。指纹 f(x) 取 w 位,w 的选择决定误报率:
# 伪代码:对元素 key 取 (p1, p2, p3, fingerprint) seg = m // 3 h = hash64(key, seed) p1 = h % seg # 落在第 0 段 p2 = (h // seg) % seg + seg # 落在第 1 段 p3 = (h // (seg*seg)) % seg + 2*seg # 落在第 2 段 fp = hash64(key, seed+1) & ((1 << w) - 1) # w 位指纹
误报率近似为 1/2^w:想拿到 1% 误报率取 w ≈ 7,0.1% 取 w ≈ 10。注意 w 只由目标误报率决定,与集合大小 n 无关——这正是它比布隆省空间的根因:布隆的位宽随 log(1/ε) 走且带 1.44 常数,Xor 则逼近 1.23·log2(1/ε)。
四、构建即"剥洋葱":3-部超图的可剥离性
难点在构建:给定 n 条超边(每条连三个槽)和每条边的目标指纹 f,要解出 m 个槽值。这相当于在一个超图上做"贴标签",使每条边三个端点异或等于其指纹。解法是一个叫 peeling(剥离)的拓扑过程:
- 统计每个槽被多少条边碰到(度数)。
- 反复找度数为 1 的叶子槽:它只连一条边 e,把这条边从图上摘掉,记录"该槽是沿边 e 被剥离的",并把边 e 另外两个端点的度数各减 1。
- 当某个端点度数降到 1,它变成新的叶子,继续摘。
若整张图能被完全剥空(即它是树/森林,无环),剥离成功,我们得到一个"先摘的先算、后摘的后算"的逆序栈。随后逆序赋值:最后一个被摘的槽(根)可任意赋 0;对栈里每个槽 v,沿它记录的边 e,令 V[v] = f(e) ^ V[另两个端点]——由于另两个端点在逆序中更早算好,公式必然成立。只要图无环,所有边方程同时成立。
# 剥离主循环(伪代码)
q = 度数为 1 的槽队列
while q:
v = q.pop()
e = v 唯一相连的活边
live.remove(e); stack.push((v, e))
for p in e 的另两个端点:
degree[p] -= 1
if degree[p] == 1: q.push(p)
if live 非空: 失败(有环) # 换 seed 重试
# 逆序赋值
V = [0]*m
for (v, e) in reversed(stack):
V[v] = f(e) ^ V[e.另两个端点]关键工程点:数组要稍大于 n(典型 m = 1.23·n),留出的余量让 3-部图几乎总是无环。论文实测在 1.23× 余量下,单次构建成功率 > 99.9%,偶发失败只需换种子重算——这是一次性离线成本,不影响查询性能。
五、查询与删除:O(1) 三访存、为何原生不能删
查询是 Xor Filter 最亮眼的地方:无论集合多大,每次查询恰好 3 次数组访存、1 次异或、1 次比较,且这 3 次位置由哈希直接计算、彼此独立,对 CPU 流水线极其友好。对比布隆的 k≈7 次探测,Xor 在高并发缓存场景能明显降内存带宽压力。
但 Xor Filter 有个硬限制:原生不支持删除。因为数组里的每个槽值被多条边共享(一个槽可能服务于几十个元素),删掉一个元素无法"反向减去"它的贡献而不破坏其他元素的方程。需要删除时只能整体重建——这恰好契合 LSM-Tree 的分层合并语义:每层在 compaction 时一次性重建该层过滤器即可,而非就地增删。对需要原地增删的场景,Cuckoo Filter 仍是更合适的选择(见第九节对比)。
六、空间再算账:~1.23·log2(1/ε) 位/元素
把各部分的位宽摊开,Xor Filter 的总空间就是"数组长度 × 指纹位宽":
总位宽 ≈ 1.23 * n * w ,其中 w = ceil(log2(1/ε)) 每元素 ≈ 1.23 * log2(1/ε) 位
| 目标误报率 ε | log2(1/ε) | Xor(≈1.23×) | Bloom(≈1.44×) | Xor 节省 |
|---|---|---|---|---|
| 1% (0.01) | 6.64 | ≈ 8.2 位 | ≈ 9.6 位 | 约 15% |
| 0.1% (0.001) | 9.97 | ≈ 12.3 位 | ≈ 14.4 位 | 约 15% |
| 0.01% (0.0001) | 13.3 | ≈ 16.3 位 | ≈ 19.2 位 | 约 15% |
可以看到 Xor 在任意误报率下都稳定比布隆小约 15%,且查询访存从 7 次降到 3 次。但 1.23 这个常数仍不是极限——理论上静态过滤器的最低空间约为 log2(1/ε) 位/元素(信息论下界)。Xor 离下界还差那 0.23 的"图余量"。Ribbon Filter 正是为吃掉这最后一段差距而生。
七、Ribbon Filter:用"丝带矩阵"逼近信息论下界
Ribbon Filter(2021)换了一个更优雅的代数框架。它把构建看成求解一个稀疏线性方程组:把每个元素映射成矩阵 A 的一行,这一行在 r 个随机列上置 1(r 叫 ribbon 宽度,典型 64~256);右边向量 b 是该元素的指纹。我们要解出列向量 x(即过滤器数组),满足:
A · x = b (所有运算在 GF(2) 上,即异或) # 查询:对元素 y,取它对应的行(r 个列),把 x 中这些列的值异或,与 b[y] 比对
Xor Filter 其实是 r=3 且行结构被"强制均分三段"的特殊情形。Ribbon 的突破在于两点:
- 把随机矩阵变成"丝带状"(ribbon):构造时让每行第一个 1 的位置大致递增,矩阵就呈现出一条带宽仅 r 的对角带。普通高斯消元是 O(n²),而带状矩阵的消元只需 O(n·r)——r 是常数,于是构建复杂度回到线性。
- 用 r 个 1 替代固定 3 个:r 越大,随机矩阵越"满"、方程越易解(构建失败率越低),但每行要记录的列索引也越长。Ribbon 在二者间取平衡,把数组余量从 Xor 的 1.23× 压到逼近 1.0×。
八、Ribbon 的构建:稀疏矩阵上的高斯消元
Ribbon 的构建就是一次针对带状稀疏矩阵的 LU 式消元,分三步:
# Ribbon 构建(伪代码,GF(2) 运算)
# 1) 行按首个 1 的列号排序 → 矩阵呈带状(ribbon width r)
# 2) 正向消元:自上而下,把每行主元下方的 1 消去(只动带状窗口内)
# 3) 回代:自下而上解出 x(每一列的值)
# 查询:
def contains(y):
cols = row_of(y) # y 对应的 r 个列
acc = 0
for c in cols: acc ^= x[c]
return acc == fingerprint(y)因为矩阵是带状的,正向消元时每行只需与窗口内少数几行做异或,整体是 O(n·r) 的线性时间。代价是查询要做 r 次访存(r 大于 Xor 的 3),所以 Ribbon 用"稍多一点的查询成本"换来了"更接近下界的空间"。论文实测 Ribbon 比 Xor 再小约 10–20%,比布隆小 2–4 倍,是最紧凑的静态过滤器之一。
九、Xor / Ribbon / Cuckoo / Bloom 横向对比
| 结构 | 典型空间(1% FPR) | 查询访存 | 可删除 | 构建方式 |
|---|---|---|---|---|
| Bloom | ≈ 9.6 位/元素 | k≈7 次探测 | 否(标准) | 无状态散列置位 |
| Cuckoo | ≈ 7–9 位/元素 | 2 次桶探测 | 可(原地) | 布谷鸟散列 + 桶 |
| Xor | ≈ 8.2 位/元素 | 固定 3 次访存 | 否(需重建) | 3-部超图剥离 |
| Ribbon | ≈ 6.5–8 位/元素 | r≈64–256 次访存 | 否(需重建) | 带状稀疏矩阵高斯消元 |
选型的直觉很清晰:静态、追求最小空间与最稳查询延迟 → Xor/Ribbon;需要原地增删 → Cuckoo;生态兼容、无构建约束 → Bloom。Xor 与 Ribbon 的取舍则在于:Xor 查询更便宜(3 访存)、Ribbon 空间更极致(逼近下界)但查询更贵(r 访存)。
十、工程落地:谁在用、怎么选
这两个结构已经从论文走进了生产系统:
- RocksDB:在 2021 年前后的版本中实验性引入 Xor Filter 作为 SST 文件的过滤器替代布隆,官方基准显示在同等误报率下空间更小、点查更快;其
FilterPolicy已支持xff (xor filter)。 - ClickHouse:在部分跳数索引(skip index)与字典编码场景中采用 Xor Filter 做主键/分区键的近似判断,降低稀疏主键探测的 I/O。
- TiDB / TiKV:底层基于 RocksDB,随存储引擎升级间接受益于 Xor Filter 的更紧凑过滤器。
- 网络与存储中间件:大量需要在内存里挡掉无效查询的场景(缓存旁路、重复抑制、路由去重),正逐步用 Xor/Ribbon 替换布隆以省内存。
落地时常见的两个工程坑:一是集合必须静态——Xor/Ribbon 都在构建后冻结,增删只能用"分段过滤器 + 定期重建"的折中;二是指纹位宽 w 要随目标误报率显式设定,别用默认 16 位(那会给出 ~0.0015% 的过小误报率、浪费空间)。与上一轮剖析的 Roaring Bitmap 组合使用效果尤佳:Roaring 管"稠密整数集合的高速运算",Xor/Ribbon 管"成员存在性快筛",二者是列式与 LSM 引擎里互补的两道关卡。
十一、用 Python 复刻一个教学版 Xor Filter
下面这份实现保留了 Xor Filter 的全部精髓:三段位置划分、w 位指纹、3-部超图剥离构建、逆序赋值、O(1) 三访存查询。它可以直接运行,构建一张小型过滤器并验证成员查询与误报率。
import hashlib, random
from collections import deque
def _hash(key: bytes, salt: int) -> int:
h = hashlib.blake2b(key, digest_size=8,
salt=salt.to_bytes(8, 'big')).digest()
return int.from_bytes(h[:8], 'big')
class XorFilter:
def __init__(self, keys, w=16, scale=1.25, seed=0):
self.w = w
self.mask = (1 << w) - 1
self.keys = [k if isinstance(k, bytes) else str(k).encode()
for k in keys]
self.build(scale, seed)
def _fingerprint(self, key, salt):
return (_hash(key, salt) & self.mask) | 1 # 非零指纹
def _positions(self, key, salt, seg):
h = _hash(key, salt)
p1 = h % seg
p2 = (h // seg) % seg + seg
p3 = (h // (seg * seg)) % seg + 2 * seg
return p1, p2, p3
def build(self, scale, seed):
n = len(self.keys)
seg = max(1, int(scale * n) // 3)
m = seg * 3
for attempt in range(300):
s = seed + attempt
pos, fp = [], []
for k in self.keys:
pos.append(self._positions(k, s, seg))
fp.append(self._fingerprint(k, s + 999))
slot_edges = [[] for _ in range(m)]
for i, (p1, p2, p3) in enumerate(pos):
slot_edges[p1].append(i)
slot_edges[p2].append(i)
slot_edges[p3].append(i)
degree = [len(e) for e in slot_edges]
live = set(range(n))
q = deque(v for v in range(m) if degree[v] == 1)
stack = []
while q:
v = q.popleft()
if degree[v] != 1:
continue
e = next((c for c in slot_edges[v] if c in live), None)
if e is None:
continue
live.discard(e)
stack.append((v, e))
p1, p2, p3 = pos[e]
for p in (p1, p2, p3):
if p != v:
degree[p] -= 1
if degree[p] == 1:
q.append(p)
if live:
continue # 出现环,换种子重试
V = [0] * m
for v, e in reversed(stack): # 逆序赋值
p1, p2, p3 = pos[e]
acc = fp[e]
for p in (p1, p2, p3):
if p != v:
acc ^= V[p]
V[v] = acc
ok = all((V[a] ^ V[b] ^ V[c]) == fp[e]
for e, (a, b, c) in enumerate(pos))
if ok:
self.M, self.V, self.seed = m, V, s
return
raise RuntimeError("xor filter 构建失败:反复出现环")
def contains(self, key):
key = key if isinstance(key, bytes) else str(key).encode()
seg = self.M // 3
p1, p2, p3 = self._positions(key, self.seed, seg)
f = self._fingerprint(key, self.seed + 999)
return (self.V[p1] ^ self.V[p2] ^ self.V[p3]) == f
if __name__ == '__main__':
keys = [f"user:{i}".encode() for i in range(500)]
xf = XorFilter(keys, w=16)
print("数组长度 M =", xf.M, " 每元素 ≈",
round(xf.M * 16 / len(keys), 2), "位 (w=16)")
print("命中查询 user:0 :", xf.contains(b"user:0")) # True
print("命中查询 user:499 :", xf.contains(b"user:499")) # True
# 误报率实测:100 万个非成员里的误报比例应接近 1/2^16
fp = sum(1 for i in range(1_000_000)
if xf.contains(f"ghost:{i}".encode()))
print("百万非成员误报数 :", fp, " 实测 FPR ≈", fp / 1_000_000)跑起来你会看到:数组长度约为 1.25×n,成员查询 100% 命中、零漏报;而一百万个非成员里误报数通常落在 10~25 之间(理论期望 ≈ 15.26 = 10^6/2^16),与"FPR ≈ 1/2^w"完全吻合。把 w 调到 7、再算一遍空间,就能亲手验证第六节那张表里"Xor ≈ 8.2 位/元素 @ 1% FPR"的结论。
结语
Xor Filter 与 Ribbon Filter 代表了"静态近似集合"这条线的工程巅峰:它们用代数结构(异或数组、带状矩阵)替代了布隆的"多哈希投票",把空间从 1.44·log2(1/ε) 一路压到逼近 log2(1/ε) 的信息论下界,把查询从 k 次探测收敛成定长访存。代价是牺牲了原地增删能力——但对 LSM-Tree、列式引擎这类"批量构建、分段重建"的场景,这正是能接受、也最划算的取舍。当你的系统还在为每个布隆过滤器多付那 15% 内存与数倍查询带宽时,也许该把它换成一道 Xor 或 Ribbon 了。

发表评论 取消回复