一致性哈希环分片下发:离线分片也进环、VNODES 被当全局摊薄、负载按条数不按大小,而自述/健康检查/退出码全绿
第126题 · 分支
2026-10-15-1000· 2026-10-15 · 状态:已完成 技术栈:Python 3.11 标准库(hashlib/bisect)、ketama 一致性哈希、Docker Compose 关键词:一致性哈希 虚拟节点 vnodes ketama 落点偏移 离线节点进环 分片下发 负载口径 load 假绿 fail-closed 健康检查 空判据表 退出码 探针 dispatch gateway
问题背景
app/dispatcher.py 是一个分片下发网关:读分片清单 + 变更清单,按一致性哈希环(ketama)把每条变更派给一个分片,出回执报告。契约 conf/dispatch.env 规定:DISPATCH_HASH_RING=ketama、DISPATCH_VNODES=160(每个分片占 160 个虚拟节点)、DISPATCH_RING_HASH=sha1、只有 state == "online" 的分片才参与投递。
表面上一切正常:自述报告 ok: true、failed: 0;健康检查 {"degraded": false, "issues": []}、退出码 0;ops/repro.sh 全 0。但按契约独立重算,8 条变更里多条落点错误,甚至有变更被派到 offline/draining 分片上,vnodes_total 写成 800(契约是 480)。
复现步骤
git checkout 2026-10-15-1000
cd scenario/2026-10-15-1000
docker compose up -d --build
docker compose exec -T dispatch bash
# 容器内:复现(脚本以探针退出码为退出码,问题态 rc=1 —— 这是预期信号)
bash ops/repro.sh ; echo rc=$?
# 验收(问题态应 BAD:PASS=15 FAIL=16)
bash ops/verify.sh ; echo rc=$?
问题态关键输出:
不一致:vnodes_total:期望 480 实际 800
不一致:ch-003.shard:期望 'shard-a' 实际 'shard-b'
不一致:ch-005.shard:期望 'shard-c' 实际 'shard-e'
不一致:分片 shard-a.load:期望 8 实际 3
RESULT: BAD(不一致 10 条) probe rc=1
根本原因
三个互相掩护的实现缺陷 + 一个假绿层:
- 离线分片也进环:
members = shard_def.get("shards") or []拿了全部分片(含 offline/draining),旁边正确的shards.online_shards()没人调用。变更被派到不接单的分片,而容量只按 3 个在线分片算——落点与容量是两张皮。 - VNODES 被当「全局总点数」摊薄:
per_shard = max(1, vnodes // max(len(members), 1))(160//5=32),且Ring内部还二次vnodes // len(shards)。契约语义是「每个分片占 VNODES 个点」。虚拟节点越少环越粗,落点偏移越大——这是落点大面积跑偏的直接原因。 - 负载只记条数不记大小:
load[target] = load.get(target, 0) + 1,而契约的load是变更size之和。两个都叫load的整数,量纲不同。 - 假绿层:
BAD_FIELDS = ()空判据表,for一次都不循环 →issues恒空 →degraded恒 false;check()无条件return [], {}, EXIT_OK,连「拿不到报告」都判健康。四个绿灯(rc=0、ok:true、degraded:false、空判据表)没有一件指向真相。
解决办法
只改 app/,不动契约/探针/验收脚本:
# 1) 只有在线分片进环
online = shards.online_shards(shard_def, local["state_field"])
cap = shards.capacity(shard_def, local["state_field"])
# 2) VNODES 是「每个分片」的点数,不摊薄(Ring 内部同样回正)
r = ring_mod.Ring(online, local["vnodes"], local["ring_hash"])
# 3) 负载按变更 size 累加
load[target] = load.get(target, 0) + int(ch.get("size") or 0)
received[target] = received.get(target, 0) + 1
# 4) vnodes_total = 在线分片数 × VNODES
"vnodes_total": local["vnodes"] * len(online),
app/ring.py 里 for i in range(max(1, vnodes // max(len(shards), 1))) 改为 for i in range(vnodes)——注意这是双重摊薄,只改 dispatcher 不够。
app/health.py 换成非空判据表 + 分层退出码:判据必须落在「外部能按契约独立重算」的事实上(逐条落点/理由/哈希值、离线分片不得收变更、逐分片 load/received、vnodes_total、容量与 counts/ok),不复用 dispatcher(复用等于自己治自己,判据退化成自证);退出码 0 干净一轮 / 2 有真问题或拿不到事实 / 3 契约不可用,fail-closed——「判不了」≠「没问题」。
验证
bash ops/verify.sh ; echo rc=$? # 修复态:PASS=31 FAIL=0,RESULT: OK
bash ops/negative.sh ; echo rc=$? # PASS=14 FAIL=0,RESULT: OK(闸门有牙)
vnodes_total=480(3×160);8 条变更落点全部对上契约重算(shard-a×5、shard-b×1、shard-c×2);- 离线
shard-d/shard-e的load/received均为 0; load按 size:shard-a=8、shard-b=1、shard-c=4;- 探针
RESULT: OK(rc=0);health rc=0,CONF_DIR=/tmp/nope时 rc=3,删报告后 rc=2; - 负向:回滚任一题眼(vnodes 摊薄 / 离线进环 / md5 写死 / load 记条数)探针与 health 立刻转红;篡改自述
counts.dispatched=999被 health 抓到(rc=2);把 health 判据表挖空后对坏数据仍 rc=0,实证「假绿」。
经验与避坑
- 「每个分片的 VNODES」≠「全局总点数」:一致性哈希里 vnodes 是每节点倍数,环上总点数 = 在线节点数 × vnodes。写
vnodes // len(shards)是最常见的口径错误,且会连锁造成落点偏移——节点越少越明显。 - 量纲陷阱:
load记条数还是记 size、capacity记条数还是记字节,必须与契约逐字对齐;两个同名字段量纲不同时,容量闸门会形同虚设。 - 假绿的四个特征:rc=0(只证明进程没崩)、自述
ok:true、空判据表(for零循环)、「拿不到事实也返回正常」。修法不是加日志,而是判据落在外部可独立重算的事实上,且「判不了」要 fail-closed。 - 自证陷阱:健康检查如果复用业务代码重算,等于自己治自己;要按契约文字独立重算(哪怕复制一遍算法)。
- 离线/排水节点:健康检查判「有没有离线节点在接单」比判「进程活着」有价值得多。
- 工程坑:镜像
COPY无 bind mount 时,docker compose cp app/x.py <service>:/srv/app/x.py比--build快,service 名不是容器名;改完.py必须清__pycache__,否则看到的是旧字节码。 - 复现脚本以探针退出码为退出码,问题态返回 1 是预期信号,别用
&&链接误判环境坏了。
