消息幂等网关「四盏绿灯、账全错」:重放窗口锚点、非法行与假绿灯排查实录
关键词:消息幂等网关、幂等去重、重放窗口、dedupe window、幂等键、(tenant, source, dedupe_key)、
accepted_total对不上、ok写死、退出码恒 0、健康检查假绿、fail-closed、Decimal定点金额、sha256checksum、Python 去重对账、mutation testing 负向闸门技术栈:Python 3.11 / 纯标准库 /
decimal.Decimal/ Docker Compose / bash 判定脚本 场景:scenario/2026-10-25-0001(分支2026-10-25-0001,第 146 题)
问题背景
msgidem 是支付侧的消息幂等网关:上游 pay-gw / erp-v1 / wms / crm / iot 会重投消息,
网关按 (tenant, source, dedupe_key) 识别重放、拦下重复、给出「接受了多少、拦了多少、哪些键进 LRU 账本」。
对账同学提了三个疑问,但运维面板上一切正常:
- 上游说
order-1005/order-1007根本不存在,网关却把它们的金额计进了accepted_total; tenant-alpha月末合计和上游账单差 13.00,但每行都「看起来合理」;ok: true、service退出码0、app.health输出{"degraded": false, "issues": []}且rc=0、ops/repro.sh也没报错 —— 四盏绿灯,账全错。
只有外部独立探针 client/probe.py(不 import app/,按 docs/contract.md 独立复算)说了真话。
这类问题的典型形态是:程序自证清白(报告 + 健康检查 + 复现脚本)三处互相担保, 而它们共享同一套错口径,于是「一致地错」看起来像「对」。
复现步骤
cd scenario/2026-10-25-0001
docker compose up -d --build
docker compose exec -T app bash # 非交互必须加 -T
容器内一条命令拿到全部信号:
bash ops/repro.sh; echo "repro rc=$?" # RESULT: OK(假绿)
bash ops/verify.sh # PASS=21 FAIL=18 / RESULT: BAD
python3 client/probe.py; echo "probe rc=$?" # result: BAD, rc=1
python3 -m app.service; echo "service rc=$?" # rc=0 ← 契约要求 2
python3 -m app.health; echo "health rc=$?" # {"degraded":false,"issues":[]} rc=0
宿主机没有 docker 时:
export MSGIDEM_ROOT=$PWD PYTHONPATH=$PWD
bash ops/verify.sh
修复前关键报错(可直接搜索对照):
FAIL service rc 期望 [2] 实际 [0]
FAIL counts 期望 [{"accept":22,"reject":6,"unknown_key":4,...}] 实际 [{"accept":23,"reject":4,"unknown_key":2,...}]
FAIL accepted_total 期望 [249.30] 实际 [259.30]
FAIL tenant_totals 期望 [{"tenant-alpha":"217.40",...}] 实际 [{"tenant-alpha":"230.40",...}]
FAIL window_hits 期望 [5] 实际 [6]
FAIL ok 期望 [False] 实际 [True]
FAIL health checks 太少:[0]
FAIL 报告被篡改 health 仍 rc=0(假绿)
FAIL 缺 LRU_MIN_HITS 期望 [3] 实际 [0]
FAIL policy.tsv 缺失 期望 [3] 实际 [0]
根本原因
五个缺陷叠在一起,每个都单独能让账错,合起来还互相掩护:
1)重放窗口锚点取「本组最早偏移」而不是「前驱事件」
anchor = first_seen[k] # ❌ 本键最早一次 seen_at
gap = r["seen_at"] - anchor
契约要求锚点是前驱事件的 seen_at。用最早时间当锚点,窗口越拉越大:
order-1001 第二条 gap=120 > 100 本应「不是重放」,却被判成重放,
tenant-alpha 因此多算/错算,最终账单差 13.00。
同时 SCOPE_STRICT 完全没实现 —— schema 与 wire 的跨口径重复没有被豁免。
2)冲突表把非法行放了进来
if len(p) != 6:
continue
table[(p[0], p[1], p[2])] = p[3] # ❌ scope/窗口/容量都没校验
order-1005(scope=bogus)、order-1007(lru_capacity=abc)、order-1008(scope=WIRE)
本该整行跳过,却进了冲突表。于是「上游说根本不存在的单」被当成认识的键参与判定,
金额进了 accepted_total —— 这就是 accept 多 1、unknown_key 少 2 的来源。
3)ok 写死 True、退出码写死 0
"ok": True, # ❌ 契约:ok = (reject == 0)
return EXIT_OK # ❌ 契约 §7 要分层:0 / 2 / 3
报告里的 ok 和进程退出码是两个独立的自证通道,两个都写死,
等于「我说我健康」变成「我说我健康且我说我健康」—— 没有任何信息量。
4)健康检查是空判据表
BAD_FIELDS = ["counts", "checksum", "ok"]
if rep.get(f) in (None, "", [], {}): # ❌ 只查字段在不在
issues.append(f"missing:{f}")
- 不校验值、不输出
checks计数、从不复算; - 更致命的是:如果健康检查 import
app/复算,被测 bug 会被「一致地」复现, 两边对上就判健康。所以健康检查必须独立实现口径。
5)附带的小口径错:fmt() 写死 2 位小数(应按组内最大小数位)、
lru 排序键用了 seq(应按 lru_rank, seen_at, seq)、tenant_totals 没排序。
解决办法
只改 app/ 下 4 个文件,考卷(docs/ conf/ client/ ops/)一字未动。
app/contract.py —— fail-closed
class ContractError(Exception):
pass
def load(path=None):
try:
raw = open(path, encoding="utf-8").read()
except OSError as e:
raise ContractError(f"policy missing: {e}")
for line in raw.splitlines():
...
if len(parts) != 2:
raise ContractError(f"policy bad columns: {line!r}")
if k in cfg:
raise ContractError(f"policy duplicate key: {k}")
if k not in POLICY_KEYS:
raise ContractError(f"policy unknown key: {k}")
cfg[k] = _int(k, v)
miss = POLICY_KEYS - set(cfg)
if miss:
raise ContractError(f"policy missing keys: {sorted(miss)}")
# 再校验 BLOCK_WINDOW_S >= 0 / SCOPE_STRICT ∈ {0,1} / LRU_MIN_HITS >= 0
要点:读不到就用默认值继续跑是这类「配置驱动」程序最常见的假绿来源。
app/settle.py —— 合法行才进表,锚点取前驱
if scope not in SCOPES: # 非法 scope 整行跳过
continue
window, cap = _int(p[4]), _int(p[5])
if window is None or cap is None or window < 0 or cap < 0:
continue
table[(tenant, source, key)] = scope # 同键取最后一条
# 判定
prev = last.get(k)
if prev is not None:
same_scope = (not strict) or prev["scope"] == r["scope"]
anchor = prev["seen_at"] # ✅ 前驱的 seen_at
gap = r["seen_at"] - anchor
replay = same_scope and gap <= window
app/service.py —— 退出码分层
try:
cfg = contract.load()
except contract.ContractError as e:
return EXIT_CONTRACT # 3
try:
table = settle.load_dedupe(_read("conf/dedupe.tsv"))
rows = settle.load_events(_read("conf/events.tsv"))
except OSError:
return EXIT_NOT_OK # 2:事实不可读
...
return EXIT_OK if ok else EXIT_NOT_OK # 0 / 2
注意 fail-closed 时不写报告(否则下游会读到上一次的旧报告)。
app/health.py —— 独立复算 + 判据表
FIELDS = ["counts", "accepted_total", "deduped_amount", "tenant_totals",
"window_hits", "lru", "checksum", "ok"]
for f in FIELDS:
checks += 1
if f not in got:
issues.append(f"missing:{f}")
if exp[f] != got.get(f):
issues.append(f"mismatch:{f}: expect={exp[f]!r} actual={got.get(f)!r}")
res = {"checks": checks, "degraded": bool(issues), "issues": issues,
"rc": EXIT_OK if not issues else EXIT_MISMATCH}
_recompute() 在 health.py 内重新实现一遍契约口径(读 policy/dedupe/events,
自己算 counts/totals/checksum),不 import settle。这样被测代码错了健康检查才会叫。
验证
docker compose up -d --build
docker compose exec -T app bash
bash ops/verify.sh # PASS=39 FAIL=0 / RESULT: OK
python3 client/probe.py # {"result":"OK","checksum":"2660a9cb39db73b0","accepted_total":"249.30"}
python3 -m app.service; echo rc=$? # rc=2(ok=false,契约 §7)
python3 -m app.health # {"checks":8,"degraded":false,"issues":[],"rc":0}
bash ops/negative.sh # NPASS=12 NFAIL=0 / RESULT: OK(闸门有牙)
业务锚点全部命中:
counts = {accept:22, bad_amount:2, dedupe:5, duplicate_done:4,
duplicate_pending:1, events:33, reject:6, unknown_key:4}
accepted_total = 249.30
deduped_amount = 12.20
window_hits = 5
tenant_totals = {tenant-alpha:217.40, tenant-bravo:31.00, tenant-charlie:0.90}
lru = order-1003(rank 14), ship-3003(rank 38)
checksum = 2660a9cb39db73b0
负向闸门(ops/negative.sh 做手工版 mutation testing)10 个变异全部让 verify.sh 转红,
且还原后仍 OK —— 说明判定脚本有牙,不是「改了什么都能过」。
fail-closed 逐项验证(都应为 rc=3 且不写报告):
rm conf/policy.tsv && python3 -m app.service; echo rc=$? # 3
printf '...LRU_MIN_HITS\t2\nLRU_MIN_HITS\t3\n' > ... && python3 -m app.service; echo rc=$? # 3 键重复
printf '...SCOPE_STRICT\t2\n...' > conf/policy.tsv && python3 -m app.service; echo rc=$? # 3 越界
printf '...BLOCK_WINDOW_S\tabc\n...' > ... && python3 -m app.service; echo rc=$? # 3 非整数
经验与避坑
- 「四盏绿灯」要逐个拆开看谁在担保谁:报告字段、进程退出码、健康检查、复现脚本, 如果它们共享同一套错口径,就会「一致地错」。判定的唯一靠山应该是独立实现的第三方探针/复算。
- 健康检查绝不能复用被测代码。复用 = 把 bug 复制到裁判席,两边对上就判健康。 要么独立实现口径,要么调外部探针。
- 配置加载必须 fail-closed。
except OSError: return DEFAULT是最隐蔽的假绿: 配置读错了,程序照样跑出「合理」结果。缺键 / 重复键 / 未知键 / 值越界都要显式报错。 - 窗口类逻辑,锚点定义一定要落到契约文字:「前驱的
seen_at」「本组最早」「CLOCK_EPOCH」 是三种完全不同的语义,差一个窗口就把合法再次投递拦成重放、把重放放过去。 - 非法行要「整行跳过」而不是「跳过那个字段」。TSV 里混进
scope=bogus/capacity=abc, 只要没整行丢,键就会进表,unknown_key立刻算少,账单立刻对不上。 - 金额一律
Decimal/整数最小单位,禁float;输出统一「按组内最大小数位 quantize → 删尾随 0 → 至少补 2 位」。否则0.1+0.2 != 0.3和对不上的分位会同时出现。 - 退出码也是 API:
0/2/3分层后,「服务挂了」和「算出来了但不 ok」才能被上游区分。 注意修好后python3 -m app.service返回 2,在set -e或&&链里会「看起来失败」, 验证脚本里用cmd > log 2>&1 || true接住。 - 负向闸门(mutation testing)是判定脚本的体检:
ops/negative.sh用文本锚点把缺陷注回去, 如果锚点对不上会ANCHOR_MISS,或者注入了但 verify 仍绿(闸门没牙)。 本轮就踩到:我在health.py里多加了一条counts_conservation自洽检查, 它把if exp[f] != got.get(f):被注入if False:之后的篡改又抓了回来, 导致 N8「注入后 verify 仍 OK」。健康检查只留契约要求的判据,别加「顺手」的兜底。 - 写实现时要照顾锚点:
negative.sh依赖anchor = prev["seen_at"]、replay = same_scope and gap <= window这类具体字符串。把逻辑拆成不同变量名会让注入静默失效。 ops/repro.sh恒 OK 是考卷设计,别为了让复现脚本变红去改ops/(改考卷 = 造假)。 判定一律看ops/verify.sh与client/probe.py。
相关文件
scenario/2026-10-25-0001/
├── docs/contract.md # 契约(唯一口径,先读它)
├── conf/{policy,dedupe,events}.tsv
├── app/{contract,settle,service,health}.py # 本次修复的 4 个文件
├── client/{ref,probe}.py # 独立复算探针
├── ops/{repro,verify,negative}.sh # 复现 / 判定 / 负向闸门
└── SOLUTION.md # 完整操作过程记录
- 完整操作过程:
scenario/2026-10-25-0001/SOLUTION.md
