布隆过滤器去重网关从没生效:位下标没对 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_ran | report 文件已生成 | 只证明进程跑完 |
stats_present | stats 字段非空 | 值全错也算通过 |
keep_ratio | total / max(total,1) >= 0.9 | 分子分母同一个数,恒等于 1.0 |
dedup_rate | kept 内部查重 | kept 本身就是 set 的产物 |
bloom_ready | bits_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
自加的三组验证,证明新检查真的有判别力:
- 问题态源码 + 新
check.py→HEALTH: FAIL ['bloom_effective'],check.sh rc=1(旧检查此时是HEALTH: OK); - 缺输入 →
HEALTH: CANNOT_VERIFY(判不了,按失败处理),rc=2; - 位参数配小(
bloom_bits=2000,m/n=1)→check.sh rc=1,探针R6 实测假阳性率 0.9938 超过配置上限 0.0200,rc=1。
六、经验与避坑
docker compose的COPY不是挂载。 改完宿主目录不会同步进容器,必须docker compose up -d --build重建。 我第一次跑repro.sh还是旧现象,白排查了一轮。docker compose exec在 CI 里必须加-T,否则直接the input device is not a TTY。__pycache__会把「已改」伪装成「没改」,每次跑前rm -rf app/__pycache__。except Exception: return False是把 bug 变成静默。 任何「降级到保守值」的except都要问:降级后的行为是否让错误不可观测?- 布隆过滤器只有
miss是确定的。 判重必须回精确账本,hit只能用来省一次查询。用「假阳性当重复丢」是数据丢失类事故。 - 哈希类下标一定取模。 64 bit 哈希 +
m位数组,不% m就是必然越界;这类 bug 在「位图全 0 也能跑通」的实现里会完全静默。 - 别只看红灯,也别只看绿灯。 问题态下
kept == 2000数量完全正确,只有 R5(置位数=0)和 R6(假阳性率)能揭穿。 - 健康检查的自查口诀:这盏灯的依据,是我自己写出去的字段,还是外部能自己算一遍的东西?
典型假绿三件套 —— ① 判据只查字段「在不在」;② 判据表为空(
for执行 0 次);③ 退出码写死return 0。 - 「判不了」必须和「没问题」用不同退出码(
0/1/2分层 + fail-closed),否则报警系统永远收不到信号。 - 改代码之前先量参数。 这次
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的流水线。
