一条存储请求通常经过路由、副本交互、本地存储和响应四层。分区决定找谁,复制协议决定等谁,本地持久化决定单节点如何恢复,读写语义决定成功意味着什么。这些职责共同组成系统,但不要求每个系统同时使用一致性哈希、Raft 和同一套 WAL。
先看 复制策略 可区分 leader 与 leaderless。本文用两条独立路径说明其差异。
无主写入:Coordinator 不是固定 Leader
客户端可联系某个节点充当请求协调者。协调者按分区元数据找到 key 的副本集合,把写请求发给副本,等待指定数量的成功响应。下一次请求可以由不同节点协调,并不需要先完成领导权选举。
设副本数 N=3,写确认数 W=2。A、B 完成后可以返回成功,C 可能仍在追赶。这意味着本次操作达到声明的确认条件,不意味着所有副本同时更新,也不意味着同一个 key 的并发写已被全局排序。
每个副本通常将变更记入本地日志并更新内存结构,之后刷成磁盘数据文件。ACK 是否等待刷盘,取决于存储引擎和配置;不能把所有实现统一画成每条写都先 fsync。日志保证本地恢复,W 决定副本确认门槛,两者必须一起分析。
Quorum 交集如何推导
在固定的 N 个副本内,写集合大小 W,读集合大小 R。若两集合不相交,总大小至多 N;因此 R + W > N 保证它们至少共享一个副本。
这个集合结论需要固定副本集合、响应确实代表所要求的写入状态等前提。它不是一个完整的线性一致寄存器算法:
- 并发写可能各自在部分节点成功,需要定义如何比较或保留版本。
- 一次超时写可能已传播到少数节点,后续读可能看到它;超时不等于回滚。
- 若响应版本由不可靠的物理时钟排序,数值更大不一定代表业务上更晚。
- sloppy quorum 若写入临时替代节点,原副本集合中的读不再必然与实际写集合相交。
所以,不能仅凭 R+W>N 写下“任何情况下都读到最新值”。应继续说明版本顺序、读修复/写回、失败写与成员变更规则。
一个失败写,怎样让两次 quorum 读取发生倒退
初始 A、B、C 都保存版本 0。写版本 1 只到达 A,没获得 W=2 个确认便超时。接下来没有任何新写:第一次读 A、B,取到较新的版本 1;第一次读完成后,第二次读 B、C,却只能取到版本 0。两次读取都满足 R=2,且 R+W>N,仍然发生“先见新值,再见旧值”。
from itertools import combinations
replicas = {"A": 1, "B": 0, "C": 0} quorums = list(combinations(replicas, 2))
assert all(set(w) & set(r) for w in quorums for r in quorums)
def read(nodes):
return max(replicas[node] for node in nodes)
assert read(("A", "B")) == 1
assert read(("B", "C")) == 0
chosen = read(("A", "B"))
for node in ("A", "B"):
replicas[node] = max(replicas[node], chosen)
assert all(read(nodes) == 1 for nodes in quorums)
集合论没有失效:那个超时写根本没有形成 W=2 的成功集合。第一次读若想把局部版本作为稳定可观察结果,需要额外传播规则;上例写回多数派之后,后续每个读多数派才必然碰到该版本。
这解释了 Cassandra 阻塞读修复维护单调 quorum 读取的动机,具体保证见 Read Repair。模型只包含单值、有序版本、固定副本且不再发生新写的场景;不能据此把“加一次修复”概括成完整的多写者线性一致算法。多写者还要解决唯一版本生成、并发排序与所有读写阶段的协议条件。
两次都读多数,值为什么会倒退?
先在关闭写回时依次读 AB、BC,可以复现 1→0;重置后打开写回,再做相同读取,B 在第一次返回前获得版本 1,第二次读也就能碰到它。这里稳定的是一个已经被读取的版本:失败写本身从未得到 W 个确认,不能把它偷偷算成成功写来套用交集证明。
正在呈现知识画面
两次都读多数,值为什么会倒退?R+W>N 只保证与成功写集合相交。失败写可能只到达少数节点;读取若返回该版本却未传播,下一次读可能看不到它。
{"id":"quorum-read-repair","title":"两次都读多数,值为什么会倒退?","summary":"R+W>N 只保证与成功写集合相交。失败写可能只到达少数节点;读取若返回该版本却未传播,下一次读可能看不到它。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div id=\"replicas\" class=\"track\"></div><label class=\"check\"><input type=\"checkbox\" id=\"repair\"><span data-i18n=\"repair\"></span></label><div class=\"actions\"><button id=\"readAB\" data-i18n=\"readAB\"></button><button id=\"readBC\" data-i18n=\"readBC\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><div class=\"metrics\"><div><span data-i18n=\"lastAB\"></span><strong id=\"lastAB\"></strong></div><div><span data-i18n=\"lastBC\"></span><strong id=\"lastBC\"></strong></div></div><p class=\"hint\" data-i18n=\"note\"></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","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 v,last;function reset(){v=[1,0,0];last=[null,null];draw();}function draw(){cells('replicas',v.map((x,i)=>({text:'ABC'[i]+' · v'+x,cls:x?'on':'empty'})));set('lastAB',last[0]??'—');set('lastBC',last[1]??'—');$('#replicas').dataset.versions=v.join(',');}function read(which){const ids=which===0?[0,1]:[1,2],x=Math.max(...ids.map(i=>v[i]));if($('#repair').checked)ids.forEach(i=>v[i]=Math.max(v[i],x));last[which]=x;draw();}$('#readAB').onclick=()=>read(0);$('#readBC').onclick=()=>read(1);$('#reset').onclick=reset;reset();","audio":false,"strings":{"heading":"两次都读多数,值为什么会倒退?","intro":"失败写只把版本 1 留在 A,B/C 仍为 0。任意读取 AB 或 BC,并比较是否在返回前写回读取结果。","lastAB":"上次 AB 返回值","lastBC":"上次 BC 返回值","note":"读取结果保留为当时返回的值。切换写回选项只影响后续读取,重置可重新比较同一初始历史。","readAB":"读取 A、B","readBC":"读取 B、C","repair":"读取返回前写回到两个参与者","reset":"重置","scope":"固定三副本、R=W=2、单个失败写、全序版本,无并发新写。选中写回时,将本次读的最大版本同步保存到两个参与者再返回;不是完整多写者寄存器算法。","scopeLabel":"模型范围与约定"}}
无主读取:收集版本,而非抢到最快响应就返回
协调者收集满足读一致性级别的响应,再按系统规则归并版本。若一个版本因果上覆盖另一个,可以丢弃旧版本;若两个版本并发,向量时钟只能识别并发,无法凭空决定哪个业务值正确。
例如两个客户端同时修改购物车,一个增加商品,一个删除商品。按集合并集可能让已删除商品重新出现;按最后时间戳选择可能丢掉并发新增。存储读路径必须与 冲突解决 的业务规则衔接。
读修复可在发现落后副本后补数据,但可能阻塞读,也可能在后台进行,不能统一称为异步。Cassandra 的行为由相应表配置和读路径决定,参见 Read Repair。
Leader 复制:先确定日志顺序,再执行状态变化
Raft 同样使用部分副本确认,通常是多数派;不同之处在于日志一致性、任期、选举约束和提交规则共同维护一个已提交前缀。它不是“必须所有副本确认”的协议。
Leader 将当前任期日志复制到多数派后,按协议推进提交;此前任期条目的提交还受额外规则约束。committed 与 applied 是两个位置:日志已经确定,但本地状态机可能仍未执行到那里。参见 Raft 论文 与本库 Raft。
读请求也不能只因为“发给 leader”就认定线性一致。旧 leader 在分区后可能不知道新 leader 已产生;安全读需要确认领导权、确定读位置,并等待本地状态机应用到该位置。允许读 follower 可以降低延迟,但必须明确是陈旧读、按时间戳的快照读,还是有额外同步条件的强一致读。
已提交 43,为什么本地读仍可能只看到 41
假设本地已经应用到日志 41,余额为 50;日志 42 加 5,43 减 2,二者都已提交,但 apply 线程还没执行。一次强一致读取通过协议确定需要至少读到 43,不能直接读取当前余额 50。仅知道 commit_index=43 也不能把内存状态瞬间变成 53。
commands = {42: 5, 43: -2, 44: 100}
applied, balance, committed = 41, 50, 43
def read_if_ready(leadership_confirmed, read_index):
if not leadership_confirmed or applied < read_index:
return None return balance
assert read_if_ready(True, 43) is None
while applied < committed:
applied += 1
balance += commands[applied]
assert read_if_ready(True, 43) == 53
assert read_if_ready(False, 43) is None
assert applied == 43 and balance != 153
模型把有效的领导权确认与读屏障作为输入,没有实现 ReadIndex 协议。实际系统还必须保证确认属于当前读请求所依赖的任期和协议过程,满足相应提交前提;不能把上次成功心跳缓存成永久有效的 True。Raft 的只读请求条件见 Raft 论文第 8 节。
一个确认解决“这个节点是否有资格确定本次读的安全位置”,另一个等待解决“本地状态机是否已经执行到那里”。即使共识网络延迟很低,apply 队列积压也会拖慢强一致读取;排查时应同时看提交到应用的距离及执行耗时。
Raft 日志与本地 WAL:逻辑职责可分,物理实现可合
共识日志保存集群决定的操作序列,本地 WAL 帮助存储引擎恢复尚未落入稳定数据文件的状态。状态机可以是内存结构、B-tree、LSM 引擎或其他实现,不必都再套一份 WAL。
若系统选择跳过状态机层 WAL,必须证明快照、已持久化的共识日志与 apply 进度足以恢复,且日志截断不会先删除唯一恢复来源。不能从“已经有 Raft 日志”直接推导“本地持久化可以随意放宽”。
同样,批量 fsync、日志合并和 apply 批处理都是实现选择。一次业务写对应几次磁盘同步,应按具体系统测量与源码解释,不能给所有分布式数据库套固定倍数。
Hints、修复与替代副本
Hinted handoff 保存目标暂时不可达时的待补写信息,恢复后回放。它是有保留期限和容量限制的恢复机制,不是任意一致性级别都可用的额外正式副本。Cassandra 中 hints 是否能满足请求,取决于一致性级别;普通 quorum 不能把存下 hint 自动当成目标副本确认。Hints 文档 给出了明确边界。
Sloppy quorum 则允许使用正常副本集合之外的可达节点承接临时副本。这会改变读写交集的前提,需要后续迁回与修复。它是 Dynamo 论文中的设计,不应从 DynamoDB 的计费模式反推其内部复制机制。
如何读一张系统架构图
沿一条请求标出:路由依据是什么;谁决定写顺序;ACK 覆盖日志追加、持久化、复制还是 apply;请求超时后可能留下什么;读取能看到哪个边界;缺失副本由谁补齐。
吞吐还要考虑复制网络、日志写入、状态机写入、压缩合并和后台修复。每一层放大针对的资源不同。下一篇 反熵与数据修复 继续说明后台恢复如何闭合这条链路。
参考
关键词:quorum、N/W/R、coordinator、leader、committed、applied、WAL、read repair、hinted handoff