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

一致性哈希环分片下发:离线分片也进环、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

根本原因

三个互相掩护的实现缺陷 + 一个假绿层:

  1. 离线分片也进环:members = shard_def.get("shards") or [] 拿了全部分片(含 offline/draining),旁边正确的 shards.online_shards() 没人调用。变更被派到不接单的分片,而容量只按 3 个在线分片算——落点与容量是两张皮。
  2. VNODES 被当「全局总点数」摊薄:per_shard = max(1, vnodes // max(len(members), 1))(160//5=32),且 Ring 内部还二次 vnodes // len(shards)。契约语义是「每个分片占 VNODES 个点」。虚拟节点越少环越粗,落点偏移越大——这是落点大面积跑偏的直接原因。
  3. 负载只记条数不记大小:load[target] = load.get(target, 0) + 1,而契约的 load 是变更 size 之和。两个都叫 load 的整数,量纲不同。
  4. 假绿层: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 是预期信号,别用 && 链接误判环境坏了。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-15-1000-consistent-hash-shard-dispatch-fake-green

分享这篇文章