近似集合成员查询(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 了。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部