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