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

Redis Streams 消费者组 XCLAIM min-idle-time 把「毫秒当秒」:崩溃实例遗留的僵尸消息永远认领不了,而自述/健康检查/退出码全绿

日期:2026-10-07 | 分支:2026-10-07-0000(第109题) 技术栈关键词:Redis Streams、消费者组(Consumer Group)、PEL(Pending Entries List)、 XCLAIM、XAUTOCLAIM、min-idle-time、XPENDING idle、僵尸消息(stale/zombie pending message)、 时间刻度错位 / 毫秒当秒(unit mismatch, ms vs s)、惊群(thundering herd)、 fail-closed / fail-open 健康检查、假绿(false green)、at-least-once 投递、Python 3、Docker Compose

一句话:认领器自述 ok:true、健康检查 degraded:false、退出码 0,但 3 条空闲 30000ms (契约阈值 5000ms)的僵尸消息一条都没被认领 —— 因为代码把毫秒当成了秒。


一、问题背景

订单事件走 Redis Stream pending:orders,消费者组 order-workers 由多个 worker 共同消费。

某个实例 worker-dead 崩溃了,它手上还有 3 条消息没 ACK。这 3 条消息躺在消费者组的 PEL(Pending Entries List) 里,归属仍然是已经死掉的 worker-dead。

于是有「认领器(reclaimer)」:定期扫 PEL,把「原 owner 已不在活跃清单、且空闲够久」的 消息用 XCLAIM / XAUTOCLAIM 认领回来重新投递,避免消息永远卡在死实例名下。

现象:认领器每轮都跑完了,自述指标全绿,退出码 0 —— 但一条消息都没出去。

$ python3 -m app.demo
{"round": 1, "ok": true, "claimed": 0, "stats": {"scanned": 3, "claimed": 0,
 "skipped_not_idle": 3, ...}, "self_consistent": true, "pending_left": 3}

$ python3 -m app.health
{"degraded": false, "issues": [], "self_consistent": true}
  health rc=0

而外部独立探针(只读契约 + PEL 事实 + 账本,不读认领器任何自述字段)说的是另一回事:

$ python3 client/reclaim_probe.py
契约阈值 min_idle_ms=5000(刻度:毫秒)
应被认领(按契约重算认领前的 PEL)=3 ['e-1', 'e-2', 'e-3']
账本里真实认领=0 []
漏认领=3 ['e-1', 'e-2', 'e-3']
RESULT: BAD        # rc=1

这就是典型的监控假绿(false green):所有闸门都在「自我引用」,没有一个落在事实上。

二、复现步骤

本场景是纯 Python(无第三方依赖)+ Docker Compose,可以在任何有 docker 的机器上跑。

cd scenario/2026-10-07-0000
docker compose up -d --build

# 一键复现:末尾 RESULT: BAD
docker compose exec -T reclaimer bash ops/repro.sh

# 验收(A~H 组逐条判定)
docker compose exec -T reclaimer bash ops/verify.sh
# 问题态:PASS=8 FAIL=5 / RESULT: BAD
# 目标态:PASS=13 FAIL=0 / RESULT: OK

不想用 Docker 也行(纯 Python,无外部依赖):

cd scenario/2026-10-07-0000
bash ops/repro.sh     # 一键复现,末尾 RESULT: BAD
bash ops/verify.sh    # 问题态 PASS=8 FAIL=5 / RESULT: BAD

最小复现的核心(关键就在于单位):

# PEL 里每条记录带 idle,单位是毫秒(与真实 Redis XPENDING idle 一致)
idle_ms = 30000                    # 崩溃实例留下的消息已空闲 30 秒
min_idle_ms = 5000                 # 契约阈值:5000 毫秒

# ✗ 错误写法:先把 ms 换算成「秒」,再拿去和「毫秒阈值」比
30 >= 5000        # False → 判为 not_idle → 永远认领不了

# ✓ 正确写法:同刻度直接比
30000 >= 5000     # True

三、根本原因

一共 4 处根因,核心是第 1 处。

根因 1(题眼):idle 换算「毫秒当秒」,且 XCLAIM 参数反向放大 1000 倍

def idle_seconds(self, idle_ms):
    """把 PEL 报回来的空闲时长换算成秒。"""
    return idle_ms // 1000                    # 30000ms -> 30

def claimable(self, info):
    # ★ 契约阈值被当成「秒」用,但 PEL 报回来的是「毫秒」
    if self.idle_seconds(info["idle_ms"]) >= self.cfg["min_idle_ms"]:
        return "idle_ok"
    return "not_idle"

def claim_min_idle(self):
    """喂给 redis XCLAIM 的 min-idle-time(口径同 claimable)。"""
    return self.cfg["min_idle_ms"] * 1000     # ★ 5000 -> 5_000_000

契约 RECLAIM_MIN_IDLE_MS=5000 的刻度就是毫秒,Redis XPENDING 返回的 idle也是毫秒。代码却先把 ms 除 1000 变成「秒」,再拿秒去和毫秒阈值比:

  • 30000 // 1000 = 30;30 >= 5000 → False → 永远 not_idle,一条都认领不了;
  • 并且 claim_min_idle() 反向乘 1000,把喂给 XCLAIM min-idle-time 的参数放大到 5_000_000ms。

两个错误方向相反、量级都是 1000,单独看每一处都不抛异常、类型也完全正确, 这是最难发现的一类 bug —— 它不报错,只是静默地永远判否。

注意真实 Redis 的坑:XCLAIM 的 min-idle-time 参数单位是毫秒(XAUTOCLAIM 的 min-idle-time 同样是毫秒),而 XPENDING 的 idle 也是毫秒。所以两边本来就同刻度, 任何"换算"都是多余的。反过来,如果你的契约文件里阈值写的是「秒」, 就必须显式乘 1000,且只乘一次。

根因 2:健康检查判据表是空的,且完全不读契约

BAD_FIELDS = ()                       # ★ 空表 = 关掉整个检查

def main():
    rep = load_report()
    issues = []
    if rep is not None:
        for field, expected in BAD_FIELDS:    # 空表 → 循环体永不执行
            ...
    print(json.dumps({"degraded": False, "issues": issues,
                      "self_consistent": True}))
    return EXIT_OK                    # ★ 恒 0,永远 degraded:false
  • 不校验任何事实 → 漏认领 3 条也照绿;
  • 不 import 契约 → 契约文件缺失/非法时也返回 0,该大声失败的地方静默通过(fail-open)。

根因 3:ok / self_consistent 是自证的,不是算出来的

"ok": True,                                  # ★ 硬编码
"rendered_ok": self._rendered_ok,            # 恒 True
"rounds_verified": self.stats["rounds"],     # 只证明"跑过一轮"
"self_consistent": self._self_consistent,    # 恒 True

self_consistent 没有和账本真实条数比对。它验证的是「自己和自己一致」, 而不是「自己和事实一致」—— 所以自述 claimed=0 与账本 0 条"恰好一致", 自洽检查被完美骗过。

根因 4:demo 退出码不看认领结果

print(json.dumps(rep, ensure_ascii=False))
return EXIT_OK      # ★ 只要没崩就 0,漏认领也返回 0

「进程没崩」被当成了「业务没问题」。 这是整条假绿链的最后一环。

四、解决办法

1) app/reclaimer.py:零换算 + 自述改为算出来的

def claimable(self, info):
    """契约 RECLAIM_MIN_IDLE_MS 的刻度就是毫秒,两边同刻度直接比较;
    这里不做任何单位换算,避免「毫秒当秒」把阈值放大 1000 倍。"""
    if info["idle_ms"] >= self.cfg["min_idle_ms"]:
        return "idle_ok"
    return "not_idle"

def claim_min_idle(self):
    """喂给 redis XCLAIM 的 min-idle-time(与契约同刻度:毫秒)。"""
    return self.cfg["min_idle_ms"]          # 不再 *1000
def _is_ok(self):
    """ok 是算出来的:没有错误、没有不可认领的僵尸。"""
    return not self.errors and self.stats["unreclaimable"] == 0

def self_consistent(self):
    """自述 claimed 必须等于账本真实条数,不是自证。"""
    if self.errors:
        return False
    if self.ledger is not None and len(self.ledger.read()) != self.stats["claimed"]:
        return False
    return self._is_ok()

report 里改成 "ok": self._is_ok()、"self_consistent": self.self_consistent()。

2) app/health.py:判据落在可外部独立重算的事实上,fail-closed

BAD_FIELDS = (("ok", True), ("self_consistent", True))   # 非空

def main():
    try:
        cfg = contract.load()
    except contract.ContractError as exc:
        print(f"[FATAL] 契约不可用:{exc}")
        return EXIT_CONTRACT          # 3:契约不可用

    need = ["report.json", "pel_before.json", "pel_after.json"]
    missing = [n for n in need if not os.path.exists(os.path.join(STATE_DIR, n))]
    if missing:
        # ★ fail-closed:拿不到事实 = 判不了 = 不通过
        print(json.dumps({"degraded": True,
                          "issues": [f"拿不到运行期事实:{missing}"],
                          "self_consistent": False}, ensure_ascii=False))
        return EXIT_BAD               # 2

    # ★ 自己按契约重算「应当被认领」的集合,与账本比对(不读认领器自述)
    should = []
    for mid, info in sorted(before.items()):
        if info["consumer"] in cfg["active_consumers"]:      continue
        if info["idle_ms"] < cfg["min_idle_ms"]:             continue
        if len(info["payload"]) > cfg["max_payload_bytes"]:  continue
        should.append(mid)
    missed = [m for m in should if m not in ledger_mids]
    extra  = [m for m in ledger_mids if m not in should]

判据共 4 条:① 按契约重算的应认领集合 vs 账本;② 自述 claimed == 账本真实条数; ③ 认领后 idle 必须被重置(防惊群);④ 认领后归属必须真的挪出活跃实例。

3) app/demo.py:退出码跟随业务结果

print(json.dumps(rep, ensure_ascii=False))
if not rep.get("ok"):
    print("[FATAL] 认领结果不达标(存在漏认领 / 无依据认领 / 自述与事实不一致)")
    return EXIT_BAD
return EXIT_OK

退出码分层(约定)

退出码含义
0正常,事实达标
2有真问题,或拿不到事实(fail-closed)
3契约不可用 / 自相矛盾

五、验证

改完先清旧字节码(必做,否则 __pycache__ 里的旧 .pyc 会掩盖改动):

docker compose exec -T reclaimer bash -c 'rm -rf app/__pycache__ state; bash ops/repro.sh'
== 1) 跑一轮认领 ==
{"round": 1, "ok": true, "claimed": 3, "stats": {"scanned": 3, "claimed": 3,
 "skipped_not_idle": 0, "skipped_live_owner": 0, "skipped_oversize": 0,
 "reclaimed_from_dead_owner": 3, "unreclaimable": 0, "delivered": 3},
 "errors": [], "rendered_ok": true, "rounds_verified": 1, "self_consistent": true,
 "pending_left": 3}
== 2) 外部独立探针 ==
应被认领(按契约重算认领前的 PEL)=3 ['e-1', 'e-2', 'e-3']
账本里真实认领=3 ['e-1', 'e-2', 'e-3']
漏认领=0 []
无依据认领=0 []
认领后 idle 未重置=0 []
RESULT: OK
== 3) 健康检查 ==
{"degraded": false, "issues": [], "self_consistent": true, "claimed": 3,
 "ledger_entries": 3, "pending_left": 3}
  health rc=0
  demo rc=0  probe rc=0

验收:

docker compose exec -T reclaimer bash ops/verify.sh
# PASS=13 FAIL=0
# RESULT: OK

目标达成:ops/verify.sh 由 PASS=8 FAIL=5 / RESULT: BAD → PASS=13 FAIL=0 / RESULT: OK; client/reclaim_probe.py 由 RESULT: BAD(rc=1) → RESULT: OK(rc=0)。

复核 PEL 事实(归属与 idle 是否真的变了):

docker compose exec -T reclaimer bash -c 'rm -rf app/__pycache__ state; \
  python3 -m app.demo >/dev/null; python3 -c "
import json
d=json.load(open(\"state/pel_after.json\"))
for k,v in d.items(): print(k, v[\"consumer\"], \"idle_ms=\"+str(v[\"idle_ms\"]), \"dc=\"+str(v[\"delivery_count\"]))
"'
e-1 reclaimer-0 idle_ms=0 dc=2
e-2 reclaimer-0 idle_ms=0 dc=2
e-3 reclaimer-0 idle_ms=0 dc=2

归属 worker-dead → reclaimer-0,delivery_count 1→2,idle_ms 重置为 0。

别被 pending_left=3 骗了:契约是 at-least-once(RECLAIM_REQUIRE_ACK_BEFORE_CLEAR=true), 认领 ≠ 处理完成,消息必须等 XACK 之后才能从 PEL 消失。 判断是否认领成功,要看 consumer 和 idle_ms,不是看 PEL 条数。

负向验证(证明「真的修好了」而非「恰好绿了」):

docker compose exec -T reclaimer bash ops/negative.sh
=== 基线(参考答案)===                     PASS=13 FAIL=0 RESULT: OK
=== A 缺陷:认领判据改回「毫秒当秒」===      PASS=4 FAIL=9  RESULT: BAD
=== B 缺陷:判据表清空 + health 失去判据 === health rc=0(明明漏认领 3 条)/ probe rc=1 BAD
=== C 缺陷:契约阈值边界 ===                 阈值 60000ms→claimed=0;1000ms→claimed=3(随契约变)
=== D 缺陷:契约 idle 来源写错 ===           demo rc=3(RECLAIM_IDLE_SOURCE 非法)
=== E 缺陷:删掉契约文件 ===                 demo rc=3 / health rc=3
=== F 缺陷:认领后不重置 idle(惊群)===     认领后 idle 未重置=3 → RESULT: BAD

6 组缺陷全部按预期转红,说明每一道闸门都真的有牙。

六、经验与避坑

可复用的思路

  1. 「跑完一轮」不等于「认领成功」。 用「进程存活性」替代「业务结果」是假绿的头号来源。 健康检查必须落在能被外部按契约独立重算的事实上。
  2. 外部独立探针是假绿的照妖镜。 本题最值钱的一点:问题态下自述 ok:true / self_consistent:true 与探针 RESULT: BAD 可以同时成立。原因就是所有闸门都在自我引用。 修好后 health 与 probe 是两条互相独立实现的判据,都从 pel_before.json + 契约重算, 才可能真正发现问题。
  3. fail-closed 而非 fail-open。 拿不到事实(PEL 快照缺失、契约不可用)时必须判失败。 「判不了」绝不能被当成「没问题」。退出码要分层:正常 0 / 有真问题或拿不到事实 2 / 契约不可用 3。
  4. 契约里写明单位,代码里零换算。 契约字段带上 _MS / _SECONDS 后缀, 并在使用处的注释里把刻度重复一遍。任何未经声明的单位换算都是雷。
  5. 负向验证(mutation testing)。 把每一道闸门单独改坏一次,确认立刻转红且退出码非 0, 才能证明测试有牙。本题的 ops/negative.sh 就是这个套路。

具体避坑清单

  • Redis 单位速查:XPENDING 的 idle = 毫秒;XCLAIM min-idle-time = 毫秒; XAUTOCLAIM min-idle-time = 毫秒;XPENDING 的 delivery count = 次数。 两边本来就同刻度,不要画蛇添足做换算。
  • //1000 和 *1000 不报错、类型也对,只能靠「把契约刻度和数据刻度摆在一起看」发现。 量级差 1000 倍的 bug 往往表现为「永远判否」而不是崩溃。
  • 认领后必须重置 idle,否则下一轮立刻再次被认领 → 惊群(thundering herd)。 真实 Redis 里 XCLAIM 会重置 idle,但如果你自己实现了"认领"逻辑(比如用 XREADGROUP 重新投递),必须自己把 last_delivered 更新掉。
  • 活跃实例的消息不许被抢:判据里要先排除 owner ∈ active_consumers, 否则会从健康的 worker 手上把消息抢走,造成重复处理。
  • 超规消息(payload 超上限)必须留在 PEL,不能认领 —— 否则反复重投撑爆下游。
  • __pycache__ 会掩盖改动:改完源码先 rm -rf app/__pycache__ 再验证, 否则加载的是旧字节码,会出现「明明改了却还是旧行为」。
  • at-least-once 语义下,认领后消息仍留在 PEL:要等 XACK。 别把「PEL 里还有 pending」误判成「没认领成功」—— 看 consumer 和 idle_ms 才算数。

七、可复现命令汇总

cd scenario/2026-10-07-0000
docker compose up -d --build
docker compose exec -T reclaimer bash ops/repro.sh     # 复现:RESULT: BAD -> OK
docker compose exec -T reclaimer bash ops/verify.sh    # 验收:8/5 BAD -> 13/0 OK
docker compose exec -T reclaimer bash ops/negative.sh  # 负向验证:6 组缺陷全部转红

八、核心认知一句话

「跑完一轮」不等于「认领成功」;自述全绿不等于事实达标。 判据必须落在能被外部按契约独立重算的事实(PEL 快照 + 账本 + 契约)上, 拿不到事实时 fail-closed 判失败; 而单位(毫秒 vs 秒)必须在契约里写明、在代码里零换算 —— 差 1000 倍的刻度错位不会崩溃,只会让你永远认领不了。

版权声明

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

分享这篇文章