复制让同一份逻辑数据存在于多个位置。副本可以承担故障恢复、就近读取和负载分担,但多保存几份数据不会自动得到强一致性:必须定义写入由谁排序、成功要等到什么程度、读取允许看到什么,以及故障后谁有权继续写。
先拆开四个独立维度
| 维度 | 要回答的问题 |
| 拓扑与入口 | 一个 leader、多位写入者,还是由协调者联系多个副本? |
| 确认条件 | 等本地持久化、远端收到、远端落盘,还是应用完成? |
| 读取语义 | 允许旧值,要求读己之写,还是要求线性一致性? |
| 失败与切换 | 哪些故障可容忍,如何保留已确认写入并隔离旧主? |
“同步复制”主要描述确认等待条件,“单主”主要描述写入组织方式。不能只根据其中一个词推断整个系统的保证。
单 leader:在一个复制组内集中排序
客户端把写入交给 leader,leader 形成日志顺序并将变化复制给 followers。副本再按协议应用日志,形成可查询状态。这里的 leader 通常是一个分片或复制组的 leader,不一定是整个集群唯一的写入节点;不同分片可以由不同机器担任 leader。
同步与异步:成功响应发生在哪个边界
异步复制允许 leader 在无需等待远端副本的条件下返回成功。如果 leader 随后永久丢失,而接替者还没收到这些更新,客户端已经收到成功的写入也可能丢失。若只是短暂宕机且本地持久日志仍在,数据可能随后恢复,需区分故障模型。
同步复制要求成功前满足指定远端确认条件,但“同步”并不统一等于“至少一个副本落盘”。协议可能等待指定数量、指定成员或 quorum,也可能等待不同进度阶段。写延迟包括日志处理、网络往返、排队与存储开销,不能仅用两个 fsync 时间的最大值概括。
例如 PostgreSQL 区分远端接收、持久化和应用等等待语义;选择 remote_apply 才把等待推进到备用端应用阶段。具体行为应结合配置,而不是看到“同步”就假设任意备用端都已可读。PostgreSQL 复制文档
对同一条提交记录,三个副本可能分别达到不同阶段
用简化整数表示日志位置,待确认提交记录在 108。当前备用端进度如下,write 表示已写到操作系统文件层,flush 表示持久化,apply 表示已重放:
| 副本 | write | flush | apply | 对位置 108 的状态 |
| A | 110 | 108 | 104 | 已持久化,尚未应用 |
| B | 110 | 106 | 106 | 已写文件层,尚未持久化到 108 |
| C | 107 | 107 | 107 | 尚未写到 108 |
progress = {
"A": {"write": 110, "flush": 108, "apply": 104},
"B": {"write": 110, "flush": 106, "apply": 106},
"C": {"write": 107, "flush": 107, "apply": 107},
}
def reached(stage, position):
return {node for node, stages in progress.items() if stages[stage] >= position}
assert reached("write", 108) == {"A", "B"}
assert reached("flush", 108) == {"A"}
assert reached("apply", 108) == set()
progress["A"]["apply"] = 108
assert reached("apply", 108) == {"A"}
assert "B" not in reached("apply", 108)
在 PostgreSQL 中,配置了有效同步备用端时,remote_write 等待的是远端写入文件系统,不能只理解为网络包已经收到;on 等待相应备用端持久化,remote_apply 再推进到应用阶段。remote_write 对备用数据库进程崩溃与备用端操作系统崩溃的保护不同,后者仍可能丢失尚未持久化的数据。synchronous_commit 定义
模型只列出每个阶段已经到达哪些副本,没有实现 FIRST/ANY 备用端选择或完整提交条件。真正能否返回,还取决于哪些备用端有资格计数、需要多少个确认,以及本地持久化是否完成。即使 A 的应用确认足以满足当前配置,也不能据此把立即读取路由到仍落后的 B。
故障切换不是把任意 follower 改名为 leader
安全切换要保留协议要求的已提交历史,选择有资格的候选者,并阻止旧 leader 恢复后继续成功写入。若确认过数据的副本存在,却选择一个落后的副本接管,单凭“之前用了同步复制”仍不能证明不丢数据。
共识协议将选举资格、日志匹配和提交规则结合起来。普通主备复制则需要检查其故障切换机制是否提供相应保证。怀疑旧主失联和获得新写入权限是两个问题,详见故障检测。
副本读取为什么可能破坏读己之写
用户修改昵称后立即刷新页面,若新读取被分配给尚未应用该日志的副本,可能看到旧昵称。可让相关读取经过有足够新鲜度保证的入口,或者在写入响应中返回版本 token,读取端等待自身进度至少达到该 token。
token 必须有明确的作用域与顺序关系。一个分片的日志位置不能随意与另一个分片比较;跨 leader 任期也需要有效的历史标识。固定“写后五秒读主”只能作为经验策略,不能证明所有延迟情况下的读己之写。
新鲜度 token 必须同时约束位置、历史和读取快照
假设一次写返回 (orders, history-1, 108):它属于订单分片的特定日志历史,读取至少要包含位置 108。库存分片已到 999,不能因此承接订单读取;另一个不兼容历史的位置 200,也不能直接与 108 比较。
from dataclasses import dataclass
@dataclass(frozen=True)
class Token:
shard: str
history: str
position: int
def can_read(applied, required, snapshot_position=None):
if (applied.shard, applied.history) != (required.shard, required.history):
return False
visible = applied.position
if snapshot_position is not None:
visible = min(visible, snapshot_position)
return visible >= required.position
required = Token("orders", "history-1", 108)
assert not can_read(Token("orders", "history-1", 106), required)
assert can_read(Token("orders", "history-1", 108), required)
assert not can_read(Token("inventory", "history-1", 999), required)
assert not can_read(Token("orders", "other-history", 200), required)
assert not can_read(Token("orders", "history-1", 110), required, 100)
最后一个断言是容易遗漏的边界:副本已应用到 110,但当前事务固定在 100 的历史快照,等待 apply 也不会自动刷新这个快照。若读取必须体现刚才的写入,需要在允许的隔离语义下建立足够新的读取视图。
这是把“读取视图对应一个日志前缀”的抽象模型,不是 PostgreSQL 的实际 MVCC 快照格式。真实系统需要实现 token 到读取条件的可靠映射;不同历史若能由协议证明继承关系,可以转换验证,而不是像示例一样全部拒绝。客户端也不能自己编造 token 作为授权凭据。
等待某个已知写入被应用,只提供这个依赖所要求的新鲜度,不等于读取到所有客户端的最新写入。要承诺线性一致读取,还需要当前领导权或相应读协议;读己之写与全局实时顺序是不同保证。
收到、持久化、应用,是三条不同的线
比较等待策略时,先固定“到底在等哪一个副本”,再比较它的阶段。下面只选一个同步备用端,假设本地条件已满足;write、flush、apply 三条边界保持有序。把等待条件从 flush 改成 apply,改变的是本次成功承诺,不会主动替副本执行重放。
apply≤flush≤write
正在呈现知识画面
收到、持久化、应用,是三条不同的线apply≤flush≤write。远端文件层写入、持久化和应用确认提供不同保证;成功条件不等于任意副本立即可读。
{"id":"replica-progress-gates","title":"收到、持久化、应用,是三条不同的线","summary":"apply≤flush≤write。远端文件层写入、持久化和应用确认提供不同保证;成功条件不等于任意副本立即可读。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"write\" data-i18n=\"write\"></button><button data-mode=\"flush\" data-i18n=\"flush\"></button><button data-mode=\"apply\" data-i18n=\"apply\"></button></div><div class=\"control\"><label for=\"write\" data-i18n=\"write\"></label><output for=\"write\" id=\"write-value\"></output><input id=\"write\" type=\"range\" min=\"104\" max=\"110\" value=\"110\" step=\"1\"></div><div class=\"control\"><label for=\"flush\" data-i18n=\"flush\"></label><output for=\"flush\" id=\"flush-value\"></output><input id=\"flush\" type=\"range\" min=\"104\" max=\"110\" value=\"108\" step=\"1\"></div><div class=\"control\"><label for=\"apply\" data-i18n=\"apply\"></label><output for=\"apply\" id=\"apply-value\"></output><input id=\"apply\" type=\"range\" min=\"104\" max=\"110\" value=\"104\" step=\"1\"></div><div id=\"bars\" class=\"plot\"></div><div class=\"metrics\"><div><span data-i18n=\"visible\"></span><strong id=\"visible\"></strong></div></div><p class=\"status\" id=\"outcome\" role=\"status\"></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 mode='flush';function draw(changed){let w=+$('#write').value,f=+$('#flush').value,a=+$('#apply').value;if(changed==='write'){f=Math.min(f,w);a=Math.min(a,f);}if(changed==='flush'){f=Math.min(f,w);a=Math.min(a,f);}if(changed==='apply')a=Math.min(a,f);const vals={write:w,flush:f,apply:a};for(const k in vals){$('#'+k).value=vals[k];set(k+'-value',vals[k]);}$('#bars').replaceChildren(...Object.entries(vals).map(([k,v])=>{const e=document.createElement('div');e.className='plot-row';const i=document.createElement('i');i.style.width=(v-104)/6*100+'%';const b=document.createElement('b');b.textContent=k+' '+v;e.append(i,b);return e;}));set('visible',t(a>=108?'yes':'no'));set('outcome',t(vals[mode]>=108?'ready':'wait'));$('#outcome').dataset.ready=String(vals[mode]>=108);pressed(mode);}document.querySelectorAll('input').forEach(e=>e.oninput=()=>draw(e.id));document.querySelectorAll('[data-mode]').forEach(e=>e.onclick=()=>{mode=e.dataset.mode;draw();});draw();","audio":false,"strings":{"apply":"apply","flush":"flush","heading":"收到、持久化、应用,是三条不同的线","intro":"提交记录位于 108。分别推进备用端的 write、flush、apply,再选择等待条件。","mode":"等待哪一阶段","no":"否","ready":"满足所选远端条件","reset":"重置","scope":"单个已选中的同步备用端,本地提交条件已满足。模型使用连续整数位置代替 LSN;上游进度后退时同时收缩下游,表示选择新的独立快照。","scopeLabel":"模型范围与约定","visible":"副本可读到 108","wait":"尚未到达提交记录 108","write":"write","yes":"是"}}
多 leader:允许多个位置独立产生变化
异地独立写入能降低本地写入延迟,并在某些网络中断时继续处理请求。代价是不同位置可能在尚未看到彼此更新时修改同一对象。例如 A 和 B 同时修改联系人地址,恢复同步后必须保留、选择或合并这两个版本。
这不是“两个节点都会写”就必然出现冲突:按租户固定写入站点或把操作限制为可合并类型,可以减少冲突。但跨站点迁移写入权、离线编辑和并发创建仍需要明确规则。若必须在成功前跨站点协调唯一性,就不能同时把所有写入都当作完全独立的本地操作。
无 leader:协调多个副本,而不是没有协议
Dynamo 风格复制允许请求协调者联系 key 的多个副本。协调者不一定持有该 key 的副本,只有真正满足确认条件的副本才计入 W;不能机械地“收到 W−1 个回复再加自己”。
设固定副本集合大小为 N,一次写入得到 W 个副本确认,一次读取等待 R 个副本。当 R+W>N 时,两组集合必相交。但相交只说明读取可能接触到某次已确认写入留下的信息;如何选择版本、处理并发、未完成写入和读修复,仍然属于协议的一部分。
例如 N=3、W=2、R=2,写入 1 只到达 A 后写请求超时;第一次读取 A、B 看见 1,第二次读取 B、C 却看见 0。如果协议只“选本次最大版本后返回”,没有完成必要的传播或读回写,读值就可能倒退。单独的 quorum 数量不能证明线性一致性。
某些无固定 leader 的寄存器算法确实可以用 quorum 加版本与读回写实现线性一致性。因此,无主拓扑也不意味着只能提供弱一致性;应判断完整算法。
故障期间的暂存与修复
Hinted handoff 用于暂存原本应发往某个副本的更新,待其恢复后补送。它依赖提示持久性、保留期限和后续修复,不是永久丢失任意副本后仍必然可恢复的保证。
Sloppy quorum 允许临时替代节点参与接收,可能提高可写性。但一旦读写使用的候选集合不同,就不能直接套用同一个 N 上的交集证明。详细路径见分布式读写路径。
选择时先写出业务要求
| 要求 | 需要进一步检查 |
| 成功后的写入不能因允许范围内故障而丢失 | 持久化、确认集合、切换资格与故障域 |
| 写后立即读到自己的修改 | 路由、应用进度和会话 token |
| 多地断网时仍能修改 | 并发冲突语义及无法本地验证的约束 |
| 扩展总写吞吐 | 分片与 leader 分布,不只是副本数量 |
继续阅读:冲突解决与分布式事务。
参考
Keywords: replication, leader, follower, quorum, synchronous replication, read-your-writes, failover