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

消息幂等网关「四盏绿灯、账全错」:重放窗口锚点、非法行与假绿灯排查实录

关键词:消息幂等网关、幂等去重、重放窗口、dedupe window、幂等键、(tenant, source, dedupe_key)、 accepted_total 对不上、ok 写死、退出码恒 0、健康检查假绿、fail-closed、 Decimal 定点金额、sha256 checksum、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 非整数

经验与避坑

  1. 「四盏绿灯」要逐个拆开看谁在担保谁:报告字段、进程退出码、健康检查、复现脚本, 如果它们共享同一套错口径,就会「一致地错」。判定的唯一靠山应该是独立实现的第三方探针/复算。
  2. 健康检查绝不能复用被测代码。复用 = 把 bug 复制到裁判席,两边对上就判健康。 要么独立实现口径,要么调外部探针。
  3. 配置加载必须 fail-closed。except OSError: return DEFAULT 是最隐蔽的假绿: 配置读错了,程序照样跑出「合理」结果。缺键 / 重复键 / 未知键 / 值越界都要显式报错。
  4. 窗口类逻辑,锚点定义一定要落到契约文字:「前驱的 seen_at」「本组最早」「CLOCK_EPOCH」 是三种完全不同的语义,差一个窗口就把合法再次投递拦成重放、把重放放过去。
  5. 非法行要「整行跳过」而不是「跳过那个字段」。TSV 里混进 scope=bogus/capacity=abc, 只要没整行丢,键就会进表,unknown_key 立刻算少,账单立刻对不上。
  6. 金额一律 Decimal/整数最小单位,禁 float;输出统一「按组内最大小数位 quantize → 删尾随 0 → 至少补 2 位」。否则 0.1+0.2 != 0.3 和对不上的分位会同时出现。
  7. 退出码也是 API:0/2/3 分层后,「服务挂了」和「算出来了但不 ok」才能被上游区分。 注意修好后 python3 -m app.service 返回 2,在 set -e 或 && 链里会「看起来失败」, 验证脚本里用 cmd > log 2>&1 || true 接住。
  8. 负向闸门(mutation testing)是判定脚本的体检:ops/negative.sh 用文本锚点把缺陷注回去, 如果锚点对不上会 ANCHOR_MISS,或者注入了但 verify 仍绿(闸门没牙)。 本轮就踩到:我在 health.py 里多加了一条 counts_conservation 自洽检查, 它把 if exp[f] != got.get(f): 被注入 if False: 之后的篡改又抓了回来, 导致 N8「注入后 verify 仍 OK」。健康检查只留契约要求的判据,别加「顺手」的兜底。
  9. 写实现时要照顾锚点:negative.sh 依赖 anchor = prev["seen_at"]、 replay = same_scope and gap <= window 这类具体字符串。把逻辑拆成不同变量名会让注入静默失效。
  10. 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

版权声明

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

分享这篇文章