复制把新写入传播到副本,修复把已经出现的差异找出来并消除。节点长期离线、提示记录过期、迁移中断或介质损坏,都可能留下正常请求路径未覆盖的问题。
读写路径 中的读修复只覆盖被访问的数据与参与读取的副本。反熵通过主动比较共同负责的数据范围,把冷数据也纳入检查。它不是某一个特定命令,而是一类让副本重新趋于一致的机制。
收敛需要哪些条件
设两个副本最终都收到同一组更新。如果它们使用相同且适当的冲突合并规则,就应产生相同状态。要实现这个目标,至少需要更新最终可达、差异可发现、修复有资源执行,以及删除和版本信息没有过早丢弃。
因此,最终一致性并不等于“放着总会自己好”。网络永久分区、修复始终被饿死、版本比较规则不同,都可能阻止收敛。对这些前提的运维保障,本身就是一致性设计的一部分。
Merkle tree:用摘要排除相同范围
逐条交换整个数据集可以发现差异,但传输成本高。Merkle tree 将相同划分下的数据范围映射为叶摘要,再逐层对孩子摘要求哈希。若根摘要相同,在哈希碰撞可忽略和编码一致的前提下,可以跳过整个范围;否则向不同的子树继续下降。
比较双方必须使用相同的 key 范围、顺序、编码与版本口径。若一方将墓碑纳入哈希,另一方只哈希当前值,摘要差异可能来自比较规则而非副本故障。
树的作用是减少不必要的交换,不是把所有修复成本变成 O(log n)。构建 n 条记录的摘要通常至少需要扫描相应数据;单个局部差异的定位可沿树高下降,但大量差异会触及很多节点,最后还要传输实际记录。树若持久维护,更新也有额外成本。
用八个固定范围,实际定位一个差异
下面让双方预先约定八个范围,每个范围保存按 key 排序的记录,记录格式为 (key, version, value)。模型只使用字符串、整数和 null;双方共享 JSON 编码约定,叶节点与内部节点分别加类型前缀。这样比较的不只是当前 value,还包括版本与删除信息。
from hashlib import sha256
import json
def tree(ranges):
n = len(ranges)
if n == 0 or n & (n - 1):
raise ValueError("这个示例要求范围数为 2 的幂")
nodes = [b""] * (2 * n)
for i, records in enumerate(ranges):
data = json.dumps(sorted(records, key=lambda r: r[0]),
ensure_ascii=True, separators=(",", ":")).encode("ascii")
nodes[n + i] = sha256(b"leaf:" + data).digest()
for i in range(n - 1, 0, -1):
nodes[i] = sha256(b"node:" + nodes[2*i] + nodes[2*i+1]).digest()
return nodes
def differences(left, right):
if len(left) != len(right):
raise ValueError("树的范围划分必须一致")
n, compared, changed = len(left) // 2, 0, []
def visit(i):
nonlocal compared
compared += 1
if left[i] == right[i]:
return
if i >= n:
changed.append(i - n)
else:
visit(2*i)
visit(2*i+1)
visit(1)
return changed, compared
A = [[(f"k{i}", 1, "old")] for i in range(8)]
B = [list(records) for records in A]
B[6] = [("k6", 2, "new")]
assert differences(tree(A), tree(B)) == ([6], 7)
assert differences(tree(A), tree(A)) == ([], 1)
C = [[(f"k{i}", 2, "new")] for i in range(8)]
assert differences(tree(A), tree(C)) == (list(range(8)), 15)
assert tree([[("k", 1, "same")]]) != tree([[("k", 2, "same")]])
assert tree([[("k", 2, None)]]) != tree([[]]) assert tree([[('b', 1, 'v'), ('a', 1, 'v')]]) == tree([[('a', 1, 'v'), ('b', 1, 'v')]])
一个差异时比较 7 对摘要:根一次,每层比较两个孩子,共三层。范围 0–3 和 4–5 的相同子树不再展开,最终只定位到范围 6。八个范围都不同时,需要比较全部 15 个节点;“树高只有 3”并不能让所有差异都只花 3 次比较。
计数只包含两棵已经构建好的树之间的摘要比较,没有包含每个范围的扫描、排序、哈希,以及差异记录的传输。单个叶范围也可能有百万条记录,定位到叶子后仍需要进一步比较。范围边界必须由相同元数据固定,不能因为两边记录数不同就各自按数量平分,否则相同位置的叶子已不是同一批 key。
差异变多时,树还能跳过多少范围?
摘要相同意味着可以跳过相应子树,摘要不同则只提供继续比较的理由,并不告诉我们谁更新。下面用真实 SHA-256 计算两棵树;改变一个叶子可看到比较路径,改变全部叶子会触及全部 15 个节点。短哈希只用于显示,比较使用完整摘要,计数也只统计已建好树之间的比较。
正在呈现知识画面
差异变多时,树还能跳过多少范围?相同子树可整段跳过。单个差异只沿相关路径展开;大量差异会触及整棵树,不能把全部修复成本写成 O(log n)。
{"id":"merkle-difference-pruning","title":"差异变多时,树还能跳过多少范围?","summary":"相同子树可整段跳过。单个差异只沿相关路径展开;大量差异会触及整棵树,不能把全部修复成本写成 O(log n)。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div id=\"ranges\" class=\"choices\"></div><div id=\"tree\"></div><div class=\"metrics\"><div><span data-i18n=\"compared\"></span><strong id=\"compared\"></strong></div><div><span data-i18n=\"different\"></span><strong id=\"different\"></strong></div></div><p class=\"hint\" data-i18n=\"hint\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n#ranges{grid-template-columns:repeat(8,minmax(0,1fr))}.tree-level{display:grid;gap:4px;margin:7px 0}.tree-level .cell{font:10px/1.5 var(--viz-font-ui);min-height:53px;white-space:pre-line}#tree{min-height:244px}.tree-level:last-child .cell{font-size:8px;padding:5px 0}.metrics strong{min-height:1.6em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let mask=0,generation=0;const enc=new TextEncoder;async function digest(s){return [...new Uint8Array(await crypto.subtle.digest('SHA-256',enc.encode(s)))].map(x=>x.toString(16).padStart(2,'0')).join('');}async function tree(m){const a=Array(15);await Promise.all(Array.from({length:8},async(_,i)=>a[7+i]=await digest('leaf:'+JSON.stringify([i,(m>>i)&1]))));for(let i=6;i>=0;i--)a[i]=await digest('node:'+a[i*2+1]+':'+a[i*2+2]);return a;}const base=tree(0);for(let i=0;i<8;i++){const b=document.createElement('button');b.textContent=i;b.dataset.range=i;b.onclick=()=>{mask^=1<<i;draw();};$('#ranges').append(b);}async function draw(){const version=++generation,m=mask;document.querySelectorAll('[data-range]').forEach(b=>b.setAttribute('aria-pressed',String(!!(m&(1<<+b.dataset.range)))));const [a,b]=await Promise.all([base,tree(m)]);if(version!==generation)return;const visited=new Set;let leaves=0;function walk(i){visited.add(i);if(a[i]===b[i])return;if(i>=7){leaves++;return;}walk(i*2+1);walk(i*2+2);}walk(0);$('#tree').replaceChildren(...[0,1,2,3].map(level=>{const row=document.createElement('div');row.className='tree-level';row.style.gridTemplateColumns='repeat('+(2**level)+',minmax(0,1fr))';for(let j=0;j<2**level;j++){const i=2**level-1+j,e=document.createElement('div');e.className='cell '+(!visited.has(i)?'empty':a[i]===b[i]?'on':'gold');e.textContent=a[i].slice(0,4)+'\\n'+b[i].slice(0,4);e.title=a[i]+' / '+b[i];row.append(e);}return row;}));set('compared',visited.size);set('different',leaves);$('#tree').dataset.mask=m;}draw();","audio":false,"strings":{"compared":"比较的摘要对数","different":"差异叶范围数","heading":"差异变多时,树还能跳过多少范围?","hint":"蓝色:相同,停止下降;金色:不同,继续定位;虚线:被相同祖先跳过。每格为 A/B 摘要。","intro":"点击八个范围,改变 B 的记录版本。实际计算 SHA-256,从根向下只展开摘要不同的子树。","reset":"重置","scope":"八个固定范围,每范围一个字符串记录;叶子和内部节点采用不同编码前缀,双方编码一致。画面截短哈希,但比较完整摘要。计数不含建树和记录传输。","scopeLabel":"模型范围与约定"}}
从发现不同到决定如何修
假设某个范围里,A 有 key=X, version=7,B 有 version=5。只有当版本规则证明 7 覆盖 5 时,才可把 7 当作新状态。两个向量版本并发时,摘要只告诉我们不同,不能替应用选择赢家。
修复需要与正常写入使用同一套合并规则。若业务保留并发 sibling,修复应传播必要版本;若采用 LWW,就要接受其时间戳排序与丢弃并发值的语义。详见 冲突解决。
Gossip 可以传播成员或调度信息,反熵负责数据差异;两者可以组合,但不是所有 Merkle 修复都由 gossip 驱动。理想化消息扩散的轮数也不能直接当成包含扫描、传输和合并的数据库修复耗时。
删除为什么也要复制:Tombstone
删除如果只是在本地移除记录,就无法告诉一个仍持有旧值的副本“这条记录被删过”。墓碑保留删除事实,使合并规则能判定旧值应被压过。
考虑 A、B 已记录删除,C 离线仍保留旧值。若 A、B 在 C 回来之前清除了唯一删除证据,后续修复可能把 C 的旧值重新传播回来。这就是删除复活问题。
墓碑保留期、最长离线时间、修复覆盖周期和节点重加入策略必须协同。并不是把一个过期参数设得很大就万事大吉:需要证明相关副本已经收到删除,或者在重加入时强制重建。Cassandra 的修复与 tombstone 约束见 Repair。
墓碑保留的是一条有版本的事实
为简化,只考虑同一 key 的严格递增版本:版本 5 是旧值,版本 6 是删除。None 表示该副本没有任何记录,(6, None) 才表示版本 6 的删除事实。
def merge(a, b):
candidates = [record for record in (a, b) if record is not None]
return max(candidates, key=lambda record: record[0]) if candidates else None
old = (5, "value")
deleted = (6, None)
assert merge(deleted, old) == deleted
assert merge(None, old) == old assert merge(deleted, (7, "recreated")) == (7, "recreated")
第三个断言也很关键:墓碑不是“以后永远禁止这个 key”。业务允许重新创建时,新的有效版本可以覆盖删除;被阻止的是旧值复活。实际系统若允许同版本不同内容,还必须定义确定的冲突规则,不能直接套用这个只比较版本号的函数。
安全清理需要覆盖证据:相关旧副本已收到删除,或被排除并在重新加入时完整重建。只看到两个在线副本摘要相同,不代表那个长期离线的第三副本已经安全。修复完成记录因此要包含副本集合及范围,不能只保存一个集群级“最近成功时间”。
全量、增量与节点重建
| 操作 | 检查范围 | 适用问题 |
| 全量修复 | 选定范围内全部相关数据 | 建立完整覆盖、发现长期差异 |
| 增量修复 | 按系统定义处理未修复或变化部分 | 降低重复扫描成本 |
| 节点重建 | 从其他副本取得负责范围的数据 | 本地副本缺失或不可信 |
| 校验/scrub | 检查本地数据完整性 | 发现损坏,未必能独立修复 |
这些名称在不同产品中有不同实现。Cassandra 的 repair 与 rebuild 不能互相替代,增量修复也不等同于读取一个通用变更日志。操作范围和版本约束应查部署版本文档。
在持续写入下做摘要比较,还需要一致的数据视图或版本截点。否则刚比较完根摘要,数据又变化,反复扫描可能把活跃写入误当成无法修复的差异。快照、分阶段协议或重新验证用于界定本轮覆盖边界。
强一致复制也需要恢复与完整性检查
共识协议在自身故障模型下维护日志顺序和提交安全,落后 follower 可以补日志或安装快照。但静默磁盘损坏、错误的软件 apply、备份损坏等,可能超出协议假设。
所以不能写成“有 Raft 就不需要数据校验”。日志追赶、状态快照、校验和与副本一致性检查解决不同层面的问题。修复成功还应核对逻辑状态,而不只是网络传输返回成功。
修复预算如何估算
假设一轮需要传输 600 GB 的差异数据,给修复分配 50 MB/s 的持续带宽,仅传输理论上就约需 12000 秒,即 3.3 小时,尚未包含摘要构建、磁盘读取和合并。若需要在两小时内完成,当前预算显然不足。
若修复过程中仍以 10 MB/s 产生需要补齐的差异,净消除速率只有 40 MB/s。把所有量按十进制 GB、MB 计算:
from fractions import Fraction
backlog = 600 * 10**9
repair_rate, new_difference_rate = 50 * 10**6, 10 * 10**6
assert Fraction(backlog, repair_rate) == 12000
assert Fraction(backlog, repair_rate - new_difference_rate) == 15000
hours = Fraction(15000, 3600)
assert hours == Fraction(25, 6) required_rate = Fraction(backlog, 2 * 3600) + new_difference_rate
assert required_rate == Fraction(280_000_000, 3)
因此两小时清空积压需要约 93.33 MB/s 的有效修复吞吐,且尚未计入额外 I/O 瓶颈。如果差异生成速率达到或超过修复速率,这个稳态模型没有有限的追平时间。这里的“新增差异”是尚未送达目标副本、确需修复的字节,不能直接拿整个业务写入吞吐替代;覆盖更新和压缩还可能改变实际待传字节。
这类估算用于反推并行度、运行窗口与资源余量。修复限流过低可能永远追不上新增差异,过高则与线上读写争用磁盘和网络。需要分别监测扫描、streaming、合并和未覆盖范围,不能只看“任务已启动”。
完整验收应回答:哪些 token/key 范围在何时被比较;哪些副本参与;是否有跳过或失败;删除保留期限之前能否覆盖完;修复后摘要或版本检查是否通过。
参考
关键词:anti-entropy、Merkle tree、收敛、tombstone、删除复活、full repair、incremental repair、rebuild