布隆过滤器能廉价地排除某个元素属于它所表示的集合,而肯定答案只能解释为“可能存在”。这种不对称依赖更新正确、哈希规则保持一致。对于已经插入的元素,普通只增不减算法没有假阴性;但过时或损坏的过滤器仍可能把数据库里真实存在的键判断成不存在,不能把算法性质无限扩大成应用保证。

位图保存的是候选线索,不是记录

先准备一张包含 m 个零位的位图,并为每个键计算 k 个哈希位置。插入时将这些位置全部置一;查询时,只要任意所需位置为零,就返回否定,否则返回“也许”。不同键可能共用位置,所以全部位置都是一,并不能证明查询的键真的被插入过。

再次插入同一个键,不会改变已经置好的位。过滤器没有保存原始键和值,也不能枚举原集合。如果误判会影响正确性,精确集合或数据库仍然是权威答案;不能仅凭“也许存在”就授予权限、扣款,或者丢弃必须处理的工作。少一次查询的收益不能替代业务判断。

按预计元素规模分配空间

在哈希近似均匀且相互独立的常用模型下,插入 n 个不同元素后的假阳性概率近似为 p = (1 − exp(−kn/m))^k。固定 m 和 n 时,k 接近 (m/n) ln(2) 可以使这个近似值最小。哈希次数并不是越多越好:除了增加计算,它也会更快地把位图填满。

选择合适的哈希数量后,百分之一的误判目标大约需要每个元素九点六位,尚未计算实现开销。一千万个元素因此需要大约一千二百万字节的位图,并不是几百万个位就够用。讨论空间时必须区分位、字节、位图本体以及整个进程的内存,避免把不同口径混在一起。

假阳性率针对的是确定不存在的元素查询,不是所有肯定答案中错误答案所占的比例;后者还取决于调用方有多大概率查询真实存在的元素。应使用已知非成员测量,并将有限样本的观察值与概率模型区分开来,不能看到一次误判较少就宣称达到了严格概率保证。

让表示的集合跟上权威数据

如果准备根据否定答案跳过数据库查询,过滤器就必须包含所有相关数据库插入。先发布新行、稍后才更新过滤器,会产生可见性缺口。重建过程也需要定义快照和增量更新如何交接;过滤器的一次查询成功,并不能证明应用层这些步骤已经原子完成。

写入方和读取方还必须保持位数、哈希构造、种子或域前缀以及字节编码一致。改变这些输入就会改变位置。普通布隆过滤器不能通过清除某个元素对应的位来安全删除,因为其他元素可能依赖同一位;计数型或其他支持删除的结构,使用的是不同契约,不能把接口混为一谈。

坏掉的样子

向固定大小的位图插入过多元素,会提高假阳性率,逐渐失去减少昂贵查询的收益。正确插入的元素仍不会被误判为不存在,但查询越来越难得到否定答案。扩大固定过滤器需要重建或采用相应增长设计,不能只修改取模用的位数,否则已有位的位置含义就变了。

真实产品不一定像下面的固定大小模型。Redis 的 BF.RESERVE 在允许扩展时可以增加子过滤器,禁止扩展时则在达到容量后拒绝插入。子过滤器会增加内存和查询工作;应检查实际配置与实现,不能假定产品会像没有容量检查的插入循环一样静默变得饱和。

更新丢失、编码不一致、快照落后和错误删除,都可能破坏应用期望的否定答案安全性。除了观察假阳性,还要记录插入失败与过滤器新鲜度。过滤器是一项优化,它的维护仍属于数据生命周期的一部分,不是创建一次之后就可以忽略的永久索引。

快速验证

保存为 bloom_check.py,用 Python 3.10 以上版本执行 python3 bloom_check.py。实验已在 Python 3.14.4 上运行,仅用标准库。七个带域前缀的 SHA-256 摘要、明确的 UTF-8 编码和固定键序列保证测试可重现,不需要外部服务、网络访问或文件写入。

from hashlib import sha256
from math import exp


M, K, N = 10000, 7, 1000


def offsets(value):
    data = value.encode("utf-8")
    return tuple(int.from_bytes(sha256(index.to_bytes(2, "big") + data).digest(), "big") % M
                 for index in range(K))


def add(bits, value):
    for position in offsets(value):
        bits[position // 8] |= 1 << (position % 8)


def maybe(bits, value):
    return all(bits[position // 8] & (1 << (position % 8)) for position in offsets(value))


bits = bytearray((M + 7) // 8)
inserted = [f"item:{index}" for index in range(N)]
truth = set(inserted)
for value in inserted:
    add(bits, value)
assert all(maybe(bits, value) for value in inserted)
before = bytes(bits)
add(bits, inserted[0])
assert bytes(bits) == before

absent = [f"absent:{index}" for index in range(10000)]
assert truth.isdisjoint(absent)
false_positives = [value for value in absent if maybe(bits, value)]
assert 0 < len(false_positives) < len(absent)
estimate = (1 - exp(-K * N / M)) ** K
print(f"{N} inserted; bitmap={len(bits)} bytes; estimate={estimate:.4%}")
print(f"absent probes: {len(false_positives)}/{len(absent)} false positives")
assert false_positives[0] not in truth
print("a maybe still needs exact lookup:", false_positives[0])

victim = inserted[0]
victim_offsets = set(offsets(victim))
retained = next(value for value in inserted[1:] if victim_offsets.intersection(offsets(value)))
broken = bytearray(bits)
for position in victim_offsets:
    broken[position // 8] &= ~(1 << (position % 8))
assert retained in truth and maybe(bits, retained) and not maybe(broken, retained)
print("clearing bits for", victim, "incorrectly rejects retained", retained)

new_value = next(f"new:{index}" for index in range(1000) if not maybe(bits, f"new:{index}"))
truth.add(new_value)
assert new_value in truth and not maybe(bits, new_value)
add(bits, new_value)
assert maybe(bits, new_value)
print("an origin insertion is invisible until the filter is also updated")

# Use a separate fixed bitmap so the capacity experiment has exactly 5000 items.
overfull = bytearray((M + 7) // 8)
for index in range(5000):
    add(overfull, f"item:{index}")
overfull_positives = sum(maybe(overfull, value) for value in absent)
assert overfull_positives > len(false_positives)
assert all(maybe(overfull, f"item:{index}") for index in range(5000))
print(f"5000 inserted in the same bit budget: {overfull_positives}/{len(absent)} false positives")

一万位的位图本体占用一千二百五十字节。插入一千个元素后,所有已插入元素均返回肯定,重复插入不改变位图;一万个已知不存在的查询中,有七十三个返回“也许”。公式近似值约为百分之零点八一九四,本次有限样本的观察值为百分之零点七三,二者不要求逐位相等。

清除 item:0 对应的位,会错误拒绝仍然保留的 item:79。新加入权威集合的元素在更新过滤器之前也不可见。另一张同样大小的位图插入五千个元素后,对同一组不存在查询产生八千零一十六次假阳性,同时仍识别所有已经插入的元素;这展示了固定空间被过度使用的代价。

这些断言将算法允许的假阳性,与维护错误引入的假阴性分开验证。实验没有测量生产内存开销,没有测试并发更新、序列化兼容性,也没有执行 Redis 的布隆过滤器命令。不能从这段标准库模型推断某个服务的实际容量、时延或故障恢复保证。

延伸阅读

来源