qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

布隆过滤器删掉一项,为什么另一项也突然查不到了?

一个亮起来的格子可能属于多条记录。先观察清零造成的漏报,再看计数器为什么仍需要“确实插入过”的依据。

展开本文目录
  1. 下载完整练习包
  2. 格子6由两条记录共同使用,却只存了一个1
  3. 用计数代替一个位,能记录还有多少贡献
  4. 计数全为正,也不证明这条记录真的来过
  5. 运行完整程序,把两种错误与守卫对照起来
  6. 额外的精确名单是有成本的,不能悄悄当成免费能力
  7. 固定哈希让反例可重现,但没有证明理论误判率
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
  • 知道哈希能把输入映射到编号
  • 理解数组下标与集合成员
  • 会运行Python文件
  • 需要补基础时:先分清哈希的用途
遇到不熟悉的词,先看这 2 个解释
假阴性
实际存在的项被检查器错误地说成不存在。
计数布隆过滤器
把格子里的开关改成计数,记住还有多少插入贡献尚未被抵消。
打开完整术语小抄 ↗

布隆过滤器把一条记录映射到几个格子,插入时把这些格子置为1。查询时,如果有一个格子仍为0,就可以排除;全部为1只能说可能存在,因为别的记录也会点亮同样的位置。

那删除时把对应格子清成0不就行了吗?关键在于一个格子没有写着它属于谁。本文用固定哈希位置构造两次删除失误:普通位图误删共享位,以及计数版误删从未插入过的记录。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • bloom_delete.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B bloom_delete.py

解压到独立练习文件夹,在含 bloom_delete.py 的目录打开终端运行。16格、3次固定SHA-256映射的原创布隆删除教学实验;每键映射位置去重并对称加减。精确成员集合是演示真值与删除前提来源,不是免费内存;未测理论误判率、生产性能或并发一致性。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

格子6由两条记录共同使用,却只存了一个1

本例只有16个格子。固定SHA-256映射让alpha使用位置[0,6,12],gamma使用[3,6,9]。插入两者后,位置6是1,但从这个1无法知道它被两条记录共同使用。普通位图保存存在迹象,没有保存每项贡献的次数。

如果为了删除alpha而清空0、6、12,gamma依赖的6也没了。随后查gamma就得到否定,尽管gamma从未被删除。这是假阴性:把确实存在的项说成不存在。正常只插入不错误清位的布隆结构不会凭空抹掉这条证据,错误删除破坏了那个前提。

用计数代替一个位,能记录还有多少贡献

计数版把每个格子改为整数。插入alpha与gamma后,位置6的计数是2。删除已确认插入的alpha时,只减去它的贡献,6从2变成1,因此gamma还保有全部所需的正计数。

本篇每个键做3次固定映射,若同一键的多个映射碰到同一格,就先去重;插入和删除都用同一组去重后位置。也可以选择按每次映射计数,但两边必须遵守一致规则。Python整数在这里不会像小位宽计数器那样溢出,生产实现的容量与溢出策略需要单独设计。

计数全为正,也不证明这条记录真的来过

程序继续搜索一个没有插入、但所有映射位置都已有正计数的名字。固定参数下找到ghost-130,它使用位置[0,6],因此查询会说可能存在。可是那些计数实际来自alpha和gamma。

若仅凭“可能存在”就执行删除,位置0会从1变成0,alpha随即被错误排除。过程中没有任何负计数,所以下溢检查也抓不到这个问题。remove_known_inserted这个名字强调调用前提:调用者必须有一个尚未抵消的真实插入事实;布隆查询自身不能提供这份事实。

运行完整程序,把两种错误与守卫对照起来

保存为bloom_delete.py,执行python -B bloom_delete.py。报告会列出三个名字的映射位置,显示清零后gamma查不到、按计数删除后gamma仍可查到,以及错误删除ghost-130后alpha丢失。

guarded_remove先检查独立的精确成员集合,因此会拒绝ghost-130且保持计数不变。它在本例中假设每个键只插入一次。程序还遍历六个固定键的全部子集,对每个已插入键执行删除并核对所有剩余成员、每一格的精确贡献,以及重复插入的成对计数,共872项核对。

展开收起代码 · 小实验python · 126 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Fixed SHA-256 positions; show why deletion needs counts AND membership facts."""
import hashlib
import itertools
import json


def positions(key, width=16, hashes=3):
    if type(key) is not str or width < 1 or hashes < 1:
        raise ValueError("invalid key or filter parameters")
    return sorted({int.from_bytes(hashlib.sha256((str(i) + ":" + key).encode("utf-8")).digest()[:8],
                                  "big") % width for i in range(hashes)})


class BitFilter:
    def __init__(self):
        self.bits = [0] * 16

    def add(self, key):
        for index in positions(key):
            self.bits[index] = 1

    def maybe(self, key):
        return all(self.bits[index] for index in positions(key))

    def wrong_clear(self, key):
        for index in positions(key):
            self.bits[index] = 0


class CountingFilter:
    def __init__(self):
        self.counts = [0] * 16

    def add(self, key):
        for index in positions(key):
            self.counts[index] += 1

    def maybe(self, key):
        return all(self.counts[index] > 0 for index in positions(key))

    def remove_known_inserted(self, key):
        """Caller must prove an unmatched insertion exists; positive counts do not prove it."""
        indexes = positions(key)
        if any(self.counts[index] <= 0 for index in indexes):
            raise ValueError("counter underflow")
        for index in indexes:
            self.counts[index] -= 1


def guarded_remove(filter_, exact_members, key):
    if key not in exact_members:
        return False
    filter_.remove_known_inserted(key)
    exact_members.remove(key)
    return True


def main():
    first, survivor = "alpha", "gamma"
    bits = BitFilter()
    for key in (first, survivor):
        bits.add(key)
    assert bits.maybe(first) and bits.maybe(survivor)
    before_bits = list(bits.bits)
    bits.wrong_clear(first)
    assert not bits.maybe(survivor)
    counts = CountingFilter()
    for key in (first, survivor):
        counts.add(key)
    counts.remove_known_inserted(first)
    assert counts.maybe(survivor)
    checks = 3
    # A false positive whose deletion steals at least one unique contribution.
    unsafe = CountingFilter()
    members = {first, survivor}
    for key in members:
        unsafe.add(key)
    ghost = next("ghost-" + str(i) for i in range(10000)
                 if unsafe.maybe("ghost-" + str(i))
                 and any(unsafe.counts[j] == 1 for j in positions("ghost-" + str(i))))
    assert ghost not in members
    saved = list(unsafe.counts)
    assert not guarded_remove(unsafe, members, ghost) and unsafe.counts == saved
    checks += 2
    unsafe.remove_known_inserted(ghost)  # Deliberate precondition violation.
    lost = sorted(key for key in members if not unsafe.maybe(key))
    assert lost
    checks += 1
    # All subsets of six fixed keys; delete each known member and recheck all survivors.
    keys = ["key-" + str(i) for i in range(6)]
    for size in range(7):
        for subset in itertools.combinations(keys, size):
            for deleted in subset:
                filter_ = CountingFilter()
                truth = set(subset)
                for key in subset:
                    filter_.add(key)
                assert guarded_remove(filter_, truth, deleted)
                checks += 1
                for key in truth:
                    assert filter_.maybe(key)
                    checks += 1
                expected = [sum(index in positions(key) for key in truth) for index in range(16)]
                assert filter_.counts == expected
                checks += 1
    duplicate = CountingFilter()
    duplicate.add(first)
    duplicate.add(first)
    duplicate.remove_known_inserted(first)
    assert duplicate.maybe(first)
    duplicate.remove_known_inserted(first)
    assert not duplicate.maybe(first)
    checks += 2
    print(json.dumps({"checks_passed": checks,
                      "positions": {first: positions(first), survivor: positions(survivor), ghost: positions(ghost)},
                      "shared_positions": sorted(set(positions(first)) & set(positions(survivor))),
                      "bit_vector_before_wrong_delete": before_bits,
                      "survivor_after_wrong_clear": bool(bits.maybe(survivor)),
                      "survivor_after_counted_delete": bool(counts.maybe(survivor)),
                      "never_inserted_false_positive": ghost, "lost_after_unsafe_ghost_delete": lost,
                      "guard_rejected_ghost": True,
                      "boundary": "tiny deterministic example; exact truth set is test/guard data, not free memory"}, indent=2))


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B bloom_delete.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。16格、3次固定SHA-256映射的原创布隆删除教学实验;每键映射位置去重并对称加减。精确成员集合是演示真值与删除前提来源,不是免费内存;未测理论误判率、生产性能或并发一致性。

运行后对照结果
{
  "checks_passed": 872,
  "positions": {
    "alpha": [
      0,
      6,
      12
    ],
    "gamma": [
      3,
      6,
      9
    ],
    "ghost-130": [
      0,
      6
    ]
  },
  "shared_positions": [
    6
  ],
  "bit_vector_before_wrong_delete": [
    1,
    0,
    0,
    1,
    0,
    0,
    1,
    0,
    0,
    1,
    0,
    0,
    1,
    0,
    0,
    0
  ],
  "survivor_after_wrong_clear": false,
  "survivor_after_counted_delete": true,
  "never_inserted_false_positive": "ghost-130",
  "lost_after_unsafe_ghost_delete": [
    "alpha"
  ],
  "guard_rejected_ghost": true,
  "boundary": "tiny deterministic example; exact truth set is test/guard data, not free memory"
}

额外的精确名单是有成本的,不能悄悄当成免费能力

演示里使用set保存真实成员,是为了让失败与正确行为能够独立核对,也让删除入口得到可靠依据。这个set本身会占用存储;不能一边保留全部键,一边把整个方案宣称为只用了16个格子。真实系统可能从数据库删除结果或已确认的事件记录得到事实,但需要明确同步关系。

若同一个键允许插入多次,就必须定义删除一次究竟抵消一次插入,还是删除该键全部出现。普通set无法表达次数;本文单独的重复插入核对只证明两次增加后应对称减少,不把只存唯一键的guarded_remove冒充多重集合实现。

固定哈希让反例可重现,但没有证明理论误判率

为了跨进程复查位置,代码用明确的UTF-8输入和SHA-256,没用可能随进程改变种子的Python内置hash。固定同一算法和参数后,反例中的位置保持一致;改变格子数或映射规则后,要重新构建已有数据的表示。

这张16格小图没有证明哈希独立性,也没有测量真实工作负载的误判率。Python列表更不是紧凑位图内存布局,程序也未处理并发加减、持久化或计数饱和。本篇只建立删除必须保护共享贡献、并且依赖真实插入依据的两条规则。

记住这三点

  • 一个置为1的格子可能由多条记录共同使用,直接清零会制造假阴性。
  • 计数版能保留其他项的贡献,但只能扣除真实存在且未被抵消的插入。
  • 可能存在不等于确实插入,正计数与下溢检查都不能替代成员事实。

别把例子用过头

  • 把普通位图的插入过程逐位反过来,当作安全删除。
  • 因为每个计数都大于0,就允许删除一个仅被近似查询命中的键。
  • 保存完整真值集合却不计算其存储成本,或把小图验证当作生产误判率和性能证明。

自己试一小步

如果同一个文件名可以被登记三次,你会怎样规定并验证删除一次?

  1. 先写明对象是集合还是多重集合,再设计精确插入次数的来源。
  2. 对同一键执行多次登记、逐次取消以及额外一次取消,预测每一步是否允许修改计数。
  3. 再加入一个共享位置的不同键,确认它的有效存在性没有被连带破坏。

做到这些,就算完成

  • 每次扣减都有尚未抵消的插入依据。
  • 重复取消与从未插入的键不能修改已有计数。
  • 说明额外真值记录的存储与一致性成本;本节不提供实现答案。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Broder、Mitzenmacher:Network Applications of Bloom Filters (新标签页)↗Andrei Broder、Michael Mitzenmacher / Harvard作者页面 · 英文

    普通布隆不能反向清位删除,以及计数布隆用增加/减少贡献支持删除,并需要处理计数溢出。

    2026-10-17 实际打开核对:实际读取第2.4节Counting Bloom filters及邻近定义;未套用论文示例参数或概率作本篇实测结论。

  • NIST算法词典:Bloom Filter (新标签页)↗NIST / Paul E. Black · 英文

    多个哈希函数映射到同一个位数组,以近似方式检测集合成员的基本结构。

    2026-10-17 实际打开核对:Definition与结构条目;不采用页面其他延伸应用作为本文保证。

  • Python3.14:hashlib.sha256 (新标签页)↗Python Software Foundation · 英文

    用确定的字节输入计算SHA-256摘要,作为可复核位置映射的实现依据。

    2026-10-17 实际打开核对:Usage与sha256构造器;取摘要再取模是本文教学映射,不声称由此证明独立哈希或生产误判率。