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

Webhook 重试调度器「该等的没等够、该发的被误判成发太早」:draining 端点泄漏 + dispatch_gap 当上限 + 退避指数用累计次数 + 成功后计数不归零 + 空判据表健康检查假绿

日期:2026-10-22 | 分支 2026-10-22-1601(第 140 题)| 难度:中 关键词:Webhook 重试调度 / 指数退避(exponential backoff)/ 最小重试间隔 dispatch_gap / 累计尝试次数 max_attempts / 投递边界 delivery boundary / draining 摘流端点 / 死信 dead letter / 健康检查假绿 / 空判据表 / 退出码分层 fail-closed / 外部独立探针 / Docker Compose 复现 技术栈:Python 3.11 标准库 + TSV 台账(endpoints.tsv / attempts.tsv)+ JSON 报告 + Shell 验收脚本 + Docker Compose


一、问题背景

一套对外 Webhook 投递系统的重试调度器:每次投递失败要按指数退避安排下一次重试,重试不能太密(会打到对方限流),也不能超过端点的累计尝试上限(超了就该进死信);同时在**摘流(draining)**的端点上不许再派新尝试。

上线后合作方投诉方向完全相反:

  • 有的端点「重试来得太快」——准入判定太宽松;
  • 有的端点「第一次失败后直接进死信」——准入判定太严格。

而调度器自己一切正常:run.py 退出码 0,报告 ok: true,python3 -m app.health 输出 {"degraded": false, "issues": []} 且 rc=0。四个绿灯,但没有一个指向真相。

唯一说真话的是外部独立探针 client/dispatch_probe.py(只读契约 + 端点清单 + 台账 + 报告,不采信报告里任何自述布尔值):

RESULT: BAD(不一致 26 条)
  - admissible_endpoints:期望 ['ep-alpha', 'ep-bravo', 'ep-charlie'] 实际 ['ep-alpha', 'ep-bravo', 'ep-charlie', 'ep-delta']
  - a-02.earliest_ts:期望 1017 实际 1077
  - a-04.earliest_ts:期望 0 实际 1217
  - totals.backoff_s:期望 171 实际 143
  - counts.skipped:期望 1 实际 None
  - ok:期望 False 实际 True

bash ops/verify.sh 问题态:PASS=9 FAIL=23 / RESULT: BAD。


二、复现步骤

# 1) 起环境(app/ 是 COPY 进镜像、无 bind mount,改完必须 --build)
cd scenario/2026-10-22-1601
docker compose up -d --build
docker compose exec -T dispatcher bash      # 非交互必须加 -T

# 2) 复现问题态
cd /srv
bash ops/repro.sh ; echo "rc=$?"            # RESULT: BAD(不一致 26 条),rc=1(这是「复现成功」的信号)
bash ops/verify.sh | tail -3                # PASS=9 FAIL=23 / RESULT: BAD
python3 run.py                              # 自述 rc=0,plan=10 endpoints=4 too_early=6
python3 -m app.health ; echo "rc=$?"        # {"degraded": false, "issues": []},rc=0(假绿)

# 3) 修完后重建再验
docker compose up -d --build
docker compose exec -T dispatcher bash -c 'cd /srv
  bash ops/repro.sh  | tail -2      # RESULT: OK / probe rc=0
  bash ops/verify.sh | tail -2      # PASS=32 FAIL=0 / RESULT: OK
  bash ops/negative.sh | tail -3    # NPASS=11 NFAIL=0 / RESULT: OK(闸门有牙)
'

权威口径在 docs/retry-contract.md,与代码不符即为 bug。


三、根本原因

数据集(4 个端点、10 条尝试):

endpoints.tsv
ep-alpha    active    3   10   70
ep-bravo    active    2   20   100
ep-charlie  active    2   40   120
ep-delta    draining  3   10   60      ← 摘流中,本轮不得派发

attempts.tsv
a-01 ep-alpha 1 1000 1002 502 ...
a-02 ep-alpha 1 1030 1031 503 ...     ← attempt_no 从 1 重新开始 = 同一条投递的第 2 次
a-03 ep-alpha 2 1050 1052 504 ...
a-04 ep-alpha 1 1080 1080 200 ...     ← 成功,投递结束
a-05 ep-bravo 1 2000 2001  - connect_timeout
a-06 ep-bravo 2 2080 2085 201 ...
a-07 ep-charlie 1 3000 3005 500
a-08 ep-charlie 2 3050 3060 500
a-09 ep-delta 1 4000 4001 500
a-10 ep-alpha 1 1120 1121 502 ...     ← a-04 成功之后的新投递,第一次尝试

根因 1(题眼)draining 端点泄漏进可调度集合

def candidates(endpoints):
    return [e for e in endpoints if e["state"] != "disabled"]   # ✗ 只排除了 disabled

契约 §1.1:state ∈ {active, draining, disabled},只有 active 可调度;draining 正在摘流,本轮不得再派新尝试。 黑名单写法(排除已知的坏状态)天生会漏;应该用白名单:state == "active"。 后果:ep-delta 的 a-09 进 plan → totals.attempts 10(应 9)、totals.endpoints 4(应 3)。

根因 2(题眼)dispatch_gap_s 被当成上限,用了 max 而不是 min

def backoff_delay(cfg, ep, attempt_no, attempts_so_far):
    delay = ep["backoff_base"] * (cfg["WEBHOOK_BACKOFF_FACTOR"] ** attempts_so_far)
    return max(ep["dispatch_gap_s"], delay)      # ✗ gap 是下限,该取 min

契约 §2.3:

earliest_ts = finish_ts(上一次) + min( dispatch_gap_s, backoff_base * FACTOR ** (attempt_no - 1) ) + ADMIT_SLACK_S

dispatch_gap_s 是「相邻两次尝试之间的最小间隔」→ 它是下限,min 表示「退避可能比 gap 更大,但至少隔 gap」。 写成 max 就变成「永远按 gap 走」,退避基数 backoff_base 只在超过 gap 时才生效,退避语义整个反过来。 后果:a-02 期望 1002 + min(70,10) + 5 = 1017,实际 1002 + max(70,10) + 5 = 1077 —— 「该等的没等够」的一半来源。

根因 3(题眼)退避指数用了「累计次数」而不是「投递内序号」

seen = counter.get(a["endpoint_id"], 0) + 1        # 该端点在整个台账里的第几行
earliest = prev_finish + backoff_delay(cfg, ep, a["attempt_no"], seen) + slack
                                #                        ^^^^ 传的是累计次数

契约 §2.3 明写「退避的指数按投递内序号 attempt_no 计算,不是全局/累计次数」。 而契约 §1.2 又特意提醒:「同一投递在失败后重发时,attempt_no 会从 1 重新开始」。 两个提示合起来才是完整语义:指数看 attempt_no,投递边界看「成功 / 达上限」,两者都不能用「累计次数」代替。

后果:a-03 期望 1031 + min(70, 10*2^1) + 5 = 1056,实际用了 2^3=8 → 退避 80 → 1031+70+5=1106(max 叠加后 1091)。

根因 4(题眼)成功之后累计计数没有归零 → 新投递被当成「继续重试」

seen = counter.get(a["endpoint_id"], 0) + 1
counter[a["endpoint_id"]] = seen
...
if a["attempt_no"] == 1 and seen == 1:
    earliest = 0
else:
    earliest = prev_finish + ...

counter 从不清零,last_finish 也跨投递沿用。a-04 是一次成功,契约 §2.2 说「任意一行判定为成功,即视为该投递结束,后续行属于新的投递、计数从零重新开始」。 于是一次本该 earliest_ts = 0 的全新投递首投,被算成了「接着上一条重试」:

  • a-04 期望 earliest_ts = 0,实际 1080 + 130 + 7 之类的垃圾值 1217;
  • a-10 期望 0,实际 1405;
  • 更狠的是 a-10 因此 scheduled_delta_s 变负 → 被误判 too_early,「该发的被误判成发太早」。

这是本题最核心的一处:attempt_no 不能用来判定投递边界(它会从 1 重来),必须自己按「成功 / 达上限」切分。

根因 5(连带)终态统计漏了 skipped、汇总字段全部拍脑袋

out = {e["endpoint_id"]: "exhausted" for e in endpoints}   # 全部先当 exhausted
for a in attempts:
    if 200 <= status < 300: out[...] = "succeeded"         # ✗ 只看「曾经成功过」,不看最后一条
...
totals: attempts = len(attempts)          # ✗ 应为 len(plan)
        endpoints = len(endpoints)        # ✗ 应为 len(admissible)
over_dead_letter: False                   # ✗ 硬编码
ok: True                                  # ✗ 硬编码
counts 只有 succeeded/exhausted/attempts  # ✗ 缺 skipped

契约 §3:counts 按 final_state 统计端点个数,键固定 succeeded / exhausted / skipped,非 active 端点 → skipped。 后果:counts.skipped 缺键(探针报 期望 1 实际 None);a-04 成功但 a-10 又失败,ep-alpha 最后一条是失败 → 应为 exhausted,实现却报 succeeded。

根因 6(假绿)健康检查:空判据表 + 无条件 return 0

BAD_FIELDS = ()                  # ✗ 空元组
...
for field in BAD_FIELDS:         # 循环执行 0 次
    if not report.get(field):
        issues.append(field)
...
return EXIT_OK                   # ✗ 无论查出什么都返回 0

三重病:

  1. 判据表为空 → issues 恒空 → degraded 恒 false;
  2. 退出码写死 → 即使真有 issue 也 return 0;
  3. 完全不复用契约 → 报告缺失、台账读不到,都当健康。

degraded: false 只代表「没检查」,比没有健康检查更危险,因为它静默地骗人。


四、解决办法

只改 app/(planner.py / health.py / contract.py,另补 __init__.py);conf/ docs/ input/ client/ ops/ 一字未动。

1. 把「投递边界」抽成显式结构,所有计算复用它

def split_deliveries(attempts, endpoints, cfg):
    """给每条台账行标 (投递序号, 投递内序号)。
    一条投递以「成功」或「累计达 max_attempts」结束;下一行起新投递、计数从 0 重来。"""
    deliv_no, deliv_seq = {}, {}
    count = {ep: 0 for ep in endpoints}
    deliv = {ep: 0 for ep in endpoints}
    for idx, att in enumerate(attempts):
        ep = att["endpoint_id"]
        if ep not in endpoints:
            continue
        if count[ep] == 0:
            deliv[ep] += 1
        elif count[ep] >= endpoint_limit(endpoints[ep], cfg):
            count[ep] = 0
            deliv[ep] += 1
        count[ep] += 1
        deliv_no[idx] = deliv[ep]
        deliv_seq[idx] = count[ep]
        if is_success(att, cfg):        # ← 成功即投递结束,计数归零
            count[ep] = 0
    return deliv_no, deliv_seq

2. 白名单候选集 + min + 指数用 attempt_no

def candidates(endpoints):
    return [e for e in endpoints.values() if e["state"] == "active"]

def backoff_delay(cfg, ep, att):
    base = endpoint_base(ep, cfg)
    delay = min(ep["dispatch_gap_s"], base * (cfg["WEBHOOK_BACKOFF_FACTOR"] ** (att["attempt_no"] - 1)))
    return delay

def earliest_ts(ep, cfg, att, seq_in_delivery, prev_finish):
    if seq_in_delivery == 1:
        return 0                                  # 新投递的首投无需等待
    return prev_finish + backoff_delay(cfg, ep, att) + cfg["WEBHOOK_ADMIT_SLACK_S"]

3. 汇总字段全部算出来

admissible = sorted(e["endpoint_id"] for e in candidates(endpoints))
too_early = sum(1 for p in plan if p["verdict"] == TOO_EARLY)
dead_letter = {}                                   # 每端点 too_early 次数
over_dl = any(v > cfg["WEBHOOK_DEAD_LETTER_AFTER"] for v in dead_letter.values())

counts = {"attempts": len(attempts), "succeeded": 0, "exhausted": 0, "skipped": 0}
...
"totals": {"attempts": len(plan), "endpoints": len(admissible),
           "too_early": too_early, "ok": len(plan) - too_early,
           "backoff_s": sum(backoff.values())},
"ok": too_early == 0 and not over_dl,              # 算出来的,不是写死的

4. 健康检查:独立重算 + 非空判据表 + 退出码分层

CHECKS = ("admissible_endpoints", "plan", "totals", "counts", "over_dead_letter", "ok")

def _facts(cfg):   # 自己读 conf + endpoints.tsv + attempts.tsv 独立重算,不 import planner
    ...

def main():
    try:
        cfg = contract.load()
    except contract.ContractError as exc:
        _emit(["contract: %s" % exc]);  return 3      # 契约不可用
    try:
        with open(REPORT) as fh: report = json.load(fh)
    except (OSError, ValueError) as exc:
        _emit(["report_unavailable: %s" % exc]);  return 2   # 拿不到事实
    issues = check_report(report, cfg)
    _emit(issues)
    return 0 if not issues else 2

check_report 里 if not CHECKS: return ["no_checks:判据表为空,判不了不等于没问题"] —— 判不了 ≠ 没问题。


五、验证

bash ops/repro.sh    →  RESULT: OK            (rc 由 1 → 0)
bash ops/verify.sh   →  PASS=32 FAIL=0 / RESULT: OK
bash ops/negative.sh →  NPASS=11 NFAIL=0 / RESULT: OK(闸门有牙)

修复后报告(与契约 §3 给的参考值逐项吻合):

{
  "admissible_endpoints": ["ep-alpha","ep-bravo","ep-charlie"],
  "totals": {"attempts": 9, "backoff_s": 171, "endpoints": 3, "ok": 7, "too_early": 2},
  "counts": {"attempts": 10, "exhausted": 2, "skipped": 1, "succeeded": 1},
  "over_dead_letter": false,
  "ok": false
}

注意:正确答案里 ok 就是 false(本轮确实有 2 条 too_early)——要修的是判定与统计,不是把 ok 强行弄绿。

手算锚点(确认自己不是「撞对答案」):

a-01 = 0                                     → delta 1000      ok
a-02 = finish(a-01) 1002 + min(70, 10*2^0)=10 + slack 5 = 1017   → delta 13   ok
a-03 = finish(a-02) 1031 + min(70, 10*2^1)=20 + 5       = 1056   → delta -6   too_early
a-04 = 0(成功那次本身属于该投递首投)                        → delta 1080 ok
a-06 = finish(a-05) 2001 + min(100, 20*2^1)=40 + 5      = 2046   → delta 34   ok
a-08 = finish(a-07) 3005 + min(120, 40*2^1)=80 + 5      = 3090   → delta -40  too_early
a-10 = 0(a-04 成功之后的新投递首投)                        → delta 1120 ok

total_backoff_s = ep-alpha (1050-1031) + (1120-1080)
                + ep-bravo (2080-2001)
                + ep-charlie (3050-3005)
                = 19 + 40 + 79 + 45 = 171            ✓ 与 totals.backoff_s 一致

退出码分层手工验证(容器内):

场景run.pyapp.health
干净报告00,degraded=false
报告被篡改 / 报告缺失——2
契约缺必填键 / 非整数 / MIN>MAX33

边界自查 ops-extra/custom-cases.py 4 个用例(gap 当下限、成功后归零、over_dead_letter、disabled 不进 plan)全部 RESULT: OK。


六、经验与避坑

6.1 五个判定口径,每个都值得单独背下来

  1. 状态用白名单,不用黑名单。 state != "disabled" 一定会漏 draining。凡是「只有某几种状态可用」,写 == "active",不要写 != "bad1" and != "bad2"。
  2. min 还是 max,先问「这个参数是下限还是上限」。 「最小间隔 / 最小退避 / 至少间隔 N 秒」全是下限,min 的是「实际退避」「上限」。契约里出现 min(a, b) 时,a 通常是被夹取的量、b 是天花板;如果 b 的语义是「至少」,那就是 min。
  3. 指数退避的指数是「本次投递内的第几次」,不是「全局第几次」。 重试在新的一条投递里从 1 重来,这就是为什么退避不能一直涨到失控。
  4. 投递边界必须自己切,不能靠 attempt_no。 attempt_no 从 1 重来恰恰说明它不是单调的,用它切分或计数都会错。边界 = 「成功」或「累计达 max_attempts」。
  5. 成功之后计数立刻归零。 少这一句,成功之后的每一次新投递首投都会被算成「接着上一条重试」,earliest_ts 变成非 0、delta 变负、verdict 被误判 too_early —— 这就是「该发的被误判成发太早」的全部来源。

6.2 假绿的三种典型病(本题一次全中)

  • 判据表为空:BAD_FIELDS = () / CHECKS = (),for 执行 0 次,issues 恒空。 自查:这个 for 真的执行了吗?把表打出来看长度。
  • 退出码写死:main() 无条件 return 0。算出问题也报绿。 自查:故意篡改报告一个字段,我的健康检查会转红吗?
  • 判不了当没问题:报告缺失 / 台账读不到 / 契约缺键,全部 return 0。 自查:「判不了」和「没问题」是不是同一个退出码?

正确的做法是退出码分层:0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用或口径非法(fail-closed)。

6.3 健康检查不得复用被测代码

契约 §4 明写「必须按本文独立重算(不得复用 app/planner.py 的输出)」。 复用被测代码 = 让它自证健康:bug 会被「一致地」复现,两边对上了 → 判健康。 所以 health.py 里另写了一份 _facts(),自己读 conf/retry.env + 两个 TSV,只依赖 contract.py(配置读取)。

6.4 本次最有价值的一个坑:负向闸门是「字符串补丁」,它会空转

ops/negative.sh 的做法是逐个把题眼用 str.replace 改回去,然后跑探针,要求探针转红。

我第一版写法比较「干净」,把退避计算抽成 backoff_delay(cfg, ep, attempt_no),一行 min(...) 搞定。 结果:

PASS N1 (draining)
FAIL N2 探针 rc   期望 1 实际 0        ← max 补丁没打上
FAIL N3 探针 rc   期望 1 实际 0        ← 指数补丁没打上
PASS N4 (成功不归零)
FAIL N5 探针 rc   期望 1 实际 0        ← 首投起点补丁没打上
NPASS=8 NFAIL=3 / RESULT: BAD(闸门没牙)

原因:脚本替换的是 ' delay = min('、'cfg["WEBHOOK_BACKOFF_FACTOR"] ** (att["attempt_no"] - 1))'、 ' if seq_in_delivery == 1:\n return 0\n' 这些具体字面。 我的实现里:

  • min( 前面有 delay = 但整行是 delay = min(ep["dispatch_gap_s"], endpoint_base(ep, cfg) * (...)),... (attempt_no - 1)) 结尾 —— 变量名不是 att["attempt_no"];
  • 首投的 if 我写在了 build_plan 内联的 if deliv_seq[idx] == 1:,不是 if seq_in_delivery == 1: return 0。

补丁一个都没匹配上,文件一字未改,探针当然还是绿的。

定位方法(值得记住):

# 在容器里原样跑一遍补丁,看源码到底有没有变
docker compose exec -T dispatcher bash -c 'cd /srv
  cp app/planner.py /tmp/p.py
  python3 - <<"PY"
s = open("/tmp/p.py").read()
print("found:", "cfg[\"WEBHOOK_BACKOFF_FACTOR\"] ** (attempt_no - 1))" in s)
PY'
# 再手工注入同样的改动 + python3 run.py + 跑探针,看会不会转红

手工注入能打出 不一致 11 条 → 证明「闸门有牙,只是没被触发」。于是改实现(不是改考卷),让每个题眼落在一个可被单行替换的表达上,三轮迭代后 11/0 全绿:

  1. 拆出 base = endpoint_base(ep, cfg),让 min(...) 那一行以 att["attempt_no"] - 1)) 收尾;
  2. backoff_delay 收整条 att 而不是 attempt_no;
  3. 把 earliest 的计算搬进独立的 earliest_ts(),让首投判定成为 if seq_in_delivery == 1: return 0。

教训:实现可以有自由度,但每个「题眼」必须落在一个可被单行替换的位置。把判定摊进无害的辅助函数、改个参数名,负向闸门就静默空转,而你以为它是绿的。反过来说,看到 negative.sh 报「闸门没牙」时,先分清是「实现还有 bug」还是「补丁没打上」 —— 前者改代码,后者只能改代码的形状(改 ops/ = 改考卷 = 造假)。

6.5 Docker / 复现相关的坑

  1. app/ 是 COPY 进镜像、compose 无 bind mount → 改完必须 docker compose up -d --build,否则 exec 进去跑的还是旧代码,现象一点不变。
  2. docker compose exec 非交互必须带 -T(compose 配了 tty: true)。
  3. ops/repro.sh 退出码 = 探针退出码,问题态是 1,那是「复现成功」的信号,用 ; echo rc=$? 接住,别用 && 串后续命令。
  4. ops-extra/ 不在镜像里(Dockerfile 只 COPY 了 app conf docs client ops input run.py),容器内直接跑会 can't open file '/srv/ops-extra/custom-cases.py',要 docker compose cp ops-extra dispatcher:/srv/ops-extra。
  5. ops/negative.sh 会临时把 app/ 改坏再还原(备份在 /tmp/app.bak.$$),中途 Ctrl-C 会留下问题态源码,跑前 git status 确认干净。
  6. __pycache__ 按秒级 mtime 判过期,同一秒内改回源码可能继续用旧字节码 —— ops/lib.sh::clean_pyc 已内置,手工跑要自己清(或 python3 -B)。
  7. ops/expected-fixed/ 是参考解,只用来对照思路,别整目录覆盖 app/。

6.6 自查口诀

只有 active 可调度吗?min 还是 max、这个参数是下限还是上限?退避指数用的是投递内第几次,还是全局累计?投递边界我是自己切的,还是靠 attempt_no 猜的?成功之后计数归零了吗?totals.attempts 数的是 plan 还是台账?counts 有 skipped 吗?ok 是算出来的还是写死的?判据表是不是空的、那个 for 真的执行了吗?「判不了」和「没问题」是同一个退出码吗?健康检查是不是在拿被测代码的输出校验它自己?把每个题眼逐一回滚,我的闸门会不会每一次都转红?

6.7 同类影响面

  • 一切带指数退避的重试/补偿系统:HTTP 重试、消息队列消费重试、任务调度器、DB 重连、Retry-After 处理、K8s backoffLimit、gRPC 重试策略 —— 都会遇到「最小间隔是下限」「指数按投递内序号」「成功后计数归零」这三件事。
  • 一切**「累计上限 + 分轮次」的计数**:max_attempts / backoffLimit / 死信阈值 / 熔断半开探测次数 —— 分轮次时计数必须归零,attempt_no 不能当单调序号用。
  • 一切有摘流/下线状态机的调度:K8s Pod Terminating、节点 NotReady/cordon、LB draining、服务实例 preStop —— 判定必须用白名单 == "active",「不可调度」的状态只会越加越多。
  • 一切带自述 ok 字段的报表 + 空判据表健康检查 + 退出码恒 0 的组合(这类比没有检查更危险,因为静默骗人)。
  • 一切**「判不了就用默认值继续跑」的 fail-open 配置加载**(本题 contract.py 原本就是 fail-closed,属少数做对的;不会做错的方向是加 DEFAULTS 回退表)。
  • 一切用字符串 replace 实现「回滚缺陷」的负向测试闸门(mutation testing 的手工版)—— 注入不生效时闸门看着是绿的,必须验证补丁真的落地了。

版权声明

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

分享这篇文章