朕喜技术(ZhenxiJS)
2026.10.25
1 分钟阅读

布隆过滤器去重网关从没生效:位下标没对 m 取模、假阳性被当重复直接丢,健康检查五盏绿灯全绿

关键词:布隆过滤器、Bloom Filter、去重网关、位下标取模、位数组越界、IndexError、假阳性 false positive、双重哈希、健康检查假绿、异常被吞 except Exception、Python 3.11、Docker Compose、事件去重、幂等去重 适用栈:Python 3.11 标准库(hashlib / bytearray)、bash、Docker Compose 复现成本:约 5 分钟,纯标准库无需装依赖


一、问题背景

内网有一个事件去重网关(app/dedup.py)。上游运营系统按「至少一次」语义投递订单事件, 同一笔业务可能来多条。网关负责按 key 去重:重复的丢掉,没见过的保留,并保持首次出现顺序。

为了扛高吞吐,网关前面挂了一个布隆过滤器做快速排除:过滤器 miss 直接放行, hit 再回精确账本(内存 set)确认。

上线后出现两类互相矛盾的客诉:

  • 一部分客户说同一笔事件被处理了两次(重复没拦住);
  • 另一部分客户说只提交过一次的单据凭空消失(没见过的被当成重复丢了)。

但监控面板上是 5 盏绿灯 + HEALTH: OK,运维已经签了字。

考卷数据:conf/events.jsonl 共 2600 条事件、2000 个唯一 key,配置 {"bloom_bits": 19171, "hash_count": 7, "target_fp_rate": 0.01}。

外部独立探针(client/verify_dedup.py,只读输入和报告、不 import app/)的说法完全不同:

{"ok": false,
 "fails": ["R5 位图全部为 0:过滤器一个位都没置上,等于没有过滤器"],
 "expected_unique": 2000, "got_kept": 2000, "input_events": 2600}

「全绿」和「正确」是两件事。


二、复现步骤

cd scenario/2026-10-25-1601
docker compose up -d --build
docker compose exec -T app bash          # 非交互必须加 -T,否则报 "the input device is not a TTY"

# 容器内:工作目录已是 /workspace/scenario/2026-10-25-1601
bash ops/repro.sh ; echo "rc=$?"

问题态输出(节选):

{"checks": {"pipeline_ran": {"ok": true}, "stats_present": {"ok": true},
            "keep_ratio": {"ok": true}, "dedup_rate": {"ok": true},
            "bloom_ready": {"ok": true}}}
HEALTH: OK
{"ok": false, "fails": ["R5 位图全部为 0:过滤器一个位都没置上,等于没有过滤器"], ...}
----- probe rc=1 -----

rc=1 就是复现信号,不是环境坏了。


三、根本原因

一条命令让真相露头:

docker compose exec -T app python3 - <<'PY'
import sys; sys.path.insert(0,"app")
from bloom import Bloom
bf = Bloom(19171, 7)
ps = bf.positions("sess:uc-0:evt-00000")
print("raw positions[:3] =", ps[:3])
print("out-of-range count =", sum(1 for p in ps if p >= bf.m))
try: bf.contains("sess:uc-0:evt-00000")
except Exception as e: print("contains raise:", type(e).__name__, e)
PY
raw positions[:3] = [18207228163035790006, 11750336555555639916, 5293444948075489826]
out-of-range count = 7
contains raise: IndexError bytearray index out of range

根因 1:位下标没对 m 取模(过滤器从没生效)

# 有问题
return [(a + i * b) & MASK64 for i in range(self.k)]

h1/h2 是 sha256 派生的 64 bit 整数,m = 19171。不取模时 7 个下标全部落在位图外, set_bit / get_bit 直接越界。位参数本身没问题(app/plan.py 显示 configured_m_per_n == optimal_m_per_n == 9.585), 问题只在「下标没有映射进 [0, m)」。

根因 2:except Exception 把崩溃变成静默

def bloom_hit(self, key):
    try:
        return bool(self.bf.contains(key))
    except Exception:
        return False        # IndexError 被吞,所有事件都被判定「一定没见过」
...
try:
    self.bf.add(key)
except Exception:
    pass                    # 置位也一直在抛,位图永远 0

结果:过滤器等于不存在,结果退化成「纯 set 去重」,2000 条唯一 key 的数量碰巧是对的 —— 这就是为什么数量看着正常、却依然会出错。

根因 3:布隆 hit 被当成「确定重复」

布隆过滤器只承诺一件事:miss 是确定的(一定没见过);hit 可能只是假阳性。 原代码:

if self.bloom_hit(key):
    self.stats["bloom_hit"] += 1
    return                  # 直接丢,没回精确账本确认

一旦过滤器开始工作,假阳性就会把只提交过一次的单据当成重复丢掉。 这就是「单条单据凭空消失」的来源。

根因 4:健康检查 5 盏灯的依据全是「自己写的字段」

灯依据为什么没判别力
pipeline_ranreport 文件已生成只证明进程跑完
stats_presentstats 字段非空值全错也算通过
keep_ratiototal / max(total,1) >= 0.9分子分母同一个数,恒等于 1.0
dedup_ratekept 内部查重kept 本身就是 set 的产物
bloom_readybits_hex 字段存在位图全 0 也算「就绪」

再加 return 0 恒定退出码、无 fail-closed 分支 —— 这是标准的假绿健康检查。


四、解决办法

1)app/bloom.py:位下标落进 [0, m)

def positions(self, item):
    a = h1(item)
    b = h2(item)
    return [((a + i * b) & MASK64) % self.m for i in range(self.k)]

先 64 bit 截断防溢出,再对 m 取模。

2)app/dedup.py:语义分层 + 去掉吞异常

def bloom_hit(self, key):
    """直接让异常抛出来。miss 是确定的;hit 可能只是假阳性。"""
    return bool(self.bf.contains(key))

def handle(self, ev):
    self.stats["seen"] += 1
    key = ev["key"]

    hit = self.bloom_hit(key)
    if hit:
        self.stats["bloom_hit"] += 1
    else:
        self.stats["bloom_miss"] += 1
        self.bf.add(key)

    # 重复与否只由精确账本判定:布隆 hit 可能是假阳性,不能直接当重复丢。
    if key in self.positive:
        self.stats["ledger_dup"] += 1
        return

    self.positive.add(key)
    self.kept.append(ev)
    self.stats["kept"] += 1

三条原则:miss 才能短路、最终判定归精确账本、不要 except Exception: pass。

3)app/check.py:判据必须外部能复算 + 退出码分层

重写为不 import app/ 的独立复算(只用 sha256 和输入文件):

  • kept_matches_unique:自己按输入重算唯一 key 首现顺序,和 kept 逐位比;
  • no_duplicate_keys;
  • payload_matches_input;
  • stats_consistent:stats.kept == len(kept)、stats.seen == 输入条数;
  • bloom_effective:解码 bits_hex,置位数必须 > 0;再用 5000 个 sess:absent-* 实测假阳性率 ≤ target_fp_rate * 2;下标越界直接判失败。

退出码分层:0 全通过 / 1 有不通过项 / 2 判不了(缺输入、读不出)= fail-closed,绝不算通过。


五、验证

docker compose exec -T app bash -c 'bash ops/repro.sh; echo rc=$?'
docker compose exec -T app bash -c 'bash ops/negative.sh; echo rc=$?'

修复后 repro.sh:

bloom_effective: 置位数=9973 实测假阳性率=0.0078 上限=0.0200  ok
kept_matches_unique: 期望 2000 实际 2000  ok
stats_consistent: stats.kept=2000 len(kept)=2000 stats.seen=2600 输入=2600  ok
HEALTH: OK
{"ok": true, "fails": [], "expected_unique": 2000, "got_kept": 2000, "input_events": 2600}
----- probe rc=0 -----
repro rc=0

negative.sh(负向闸门):

[1/3] 参考解 repro rc=0(期望 0)
[2/3] 问题态 repro rc=1(期望 1)
[3/3] 恢复后 repro rc=0(期望 0)
NEGATIVE: PASS —— 闸门在问题态确实会红
negative rc=0

自加的三组验证,证明新检查真的有判别力:

  1. 问题态源码 + 新 check.py → HEALTH: FAIL ['bloom_effective'],check.sh rc=1(旧检查此时是 HEALTH: OK);
  2. 缺输入 → HEALTH: CANNOT_VERIFY(判不了,按失败处理),rc=2;
  3. 位参数配小(bloom_bits=2000,m/n=1)→ check.sh rc=1,探针 R6 实测假阳性率 0.9938 超过配置上限 0.0200,rc=1。

六、经验与避坑

  1. docker compose 的 COPY 不是挂载。 改完宿主目录不会同步进容器,必须 docker compose up -d --build 重建。 我第一次跑 repro.sh 还是旧现象,白排查了一轮。
  2. docker compose exec 在 CI 里必须加 -T,否则直接 the input device is not a TTY。
  3. __pycache__ 会把「已改」伪装成「没改」,每次跑前 rm -rf app/__pycache__。
  4. except Exception: return False 是把 bug 变成静默。 任何「降级到保守值」的 except 都要问:降级后的行为是否让错误不可观测?
  5. 布隆过滤器只有 miss 是确定的。 判重必须回精确账本,hit 只能用来省一次查询。用「假阳性当重复丢」是数据丢失类事故。
  6. 哈希类下标一定取模。 64 bit 哈希 + m 位数组,不 % m 就是必然越界;这类 bug 在「位图全 0 也能跑通」的实现里会完全静默。
  7. 别只看红灯,也别只看绿灯。 问题态下 kept == 2000 数量完全正确,只有 R5(置位数=0)和 R6(假阳性率)能揭穿。
  8. 健康检查的自查口诀:这盏灯的依据,是我自己写出去的字段,还是外部能自己算一遍的东西? 典型假绿三件套 —— ① 判据只查字段「在不在」;② 判据表为空(for 执行 0 次);③ 退出码写死 return 0。
  9. 「判不了」必须和「没问题」用不同退出码(0/1/2 分层 + fail-closed),否则报警系统永远收不到信号。
  10. 改代码之前先量参数。 这次 app/plan.py 一看就知道位参数是最优的,直接省掉了「调配置」这条错路。

七、可复现命令清单

git checkout 2026-10-25-1601
cd scenario/2026-10-25-1601
docker compose up -d --build
docker compose exec -T app bash ops/reset.sh                       # 回到问题态
docker compose exec -T app bash -c 'bash ops/repro.sh; echo rc=$?' # 期望 rc=1
# 应用修复(见本分支 app/bloom.py app/dedup.py app/check.py)
docker compose exec -T app bash -c 'bash ops/negative.sh; echo rc=$?'  # 期望 rc=0
docker compose down

完整操作过程(含每步真实输出、踩坑记录)见 scenario/2026-10-25-1601/SOLUTION.md。


八、同类影响面

  • 一切布隆过滤器 / 计数型布隆 / Cuckoo filter / HyperLogLog 误用:位下标未取模、k 个哈希不独立、hit 当确定结论、过滤器不持久化导致重启后全 miss。
  • 一切**「过滤器 + 精确存储」两级去重**架构:Kafka 幂等 Producer、Redis SETNX 前置、CDN 去重、爬虫 URL 去重、推荐系统曝光去重。
  • 一切**「至少要一次」投递语义**下的幂等键判重(支付回调、Webhook 重试、消息重放)。
  • 一切假绿健康检查:判据自述字段、判据表为空、except Exception 后返回健康、退出码恒 0、缺输入也判通过。
  • 一切 CI 里跑 docker compose exec 不加 -T、以及 改了源码忘了 --build 的流水线。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-25-1601

分享这篇文章