用户刚修改收货地址,页面刷新后却显示旧地址。写入已经成功,读取也没有报错,两个结果仍然互相矛盾。
常见原因很简单:写请求进入主库,紧接着的读请求被分配到尚未追上进度的只读副本。复制让系统多了一份可用数据,也让“哪一份数据算最新”成为必须回答的问题。
Harshit Khosla 的分布式系统系列第三篇介绍了单主、多主和无主复制。本文沿着这三条路径,进一步讨论它们分别把协调成本放在哪里,以及工程中容易被简化掉的边界。
复制解决了什么,又增加了什么
保存多个副本通常有三个目的:
- 一台机器损坏时,数据和服务仍能恢复;
- 把读取分散到更多机器,或让数据靠近不同地区的用户;
- 在升级、补丁和重启期间保持服务可用。
复制一次并不难。持续有写入、网络时断时续、节点随时重启时,让副本保持可解释的状态才是难点。
设计复制方案时,真正需要决定的是:一次写入要等待多少副本,谁有权接受写入,冲突由谁发现和处理,以及节点恢复后如何补齐遗漏数据。三类复制模型给出的答案不同。
单主复制:先从最容易解释的方案开始
单主复制只允许一个节点接受写入。主节点按顺序记录变更,再把变更发送给从节点。读取可以留在主节点,也可以分配到从节点。
它的主要优点是写入入口唯一。同一条记录的更新先后通常由主节点确定,应用不必在每次写入时处理多个来源产生的冲突。对多数业务系统,这是一条稳妥的起点。
代价集中在两个地方。
第一处是复制确认。异步复制让主节点在本地提交后立即响应,延迟较低;如果主节点在变更传给从节点前永久损坏,最近的已确认写入可能丢失。同步复制等待指定从节点确认,能提高已提交数据的持久性,写入延迟也会增加。PostgreSQL 还允许按事务设置 synchronous_commit,说明“同步或异步”可以是一次事务的选择,无需成为整个集群唯一的固定模式。
第二处是主节点故障转移。系统需要判断旧主是否真的失去服务能力、选择进度足够新的从节点并完成提升。如果旧主仍在局部网络中接受写入,新主又已经开始工作,就会出现两个主同时存在的 split-brain(脑裂)。防止它通常需要租约、仲裁、隔离旧主或共识协议,单靠“检测不到心跳”并不够。
副本延迟会直接进入用户体验
把读取交给异步从节点后,系统可能遇到:
- 写后读不到自己的修改;
- 同一用户连续刷新,数据看起来先新后旧;
- 不同页面从不同副本读取,显示互相矛盾的状态。
常见处理方式包括让刚写过数据的会话暂时读主节点、记录写入位置并等待从节点追上,或只把允许短暂陈旧的数据分给从节点。选择前应先定义业务可以接受多旧的数据,不能只看平均复制延迟。
多主复制:用冲突换取就近写入
多主复制允许多个节点同时接受写入,常见形式是每个地区部署一个写入节点。东京用户可以写东京节点,欧洲用户可以写欧洲节点,跨洲网络延迟不再出现在每次本地写入的关键路径上。
难点随之转到冲突处理。两个地区在互相断开时都修改同一条记录,恢复连接后会得到两个并发版本。系统必须先识别它们没有明确的因果先后,再选择处理方式:
- 让业务字段按规则合并,例如购物车合并商品集合;
- 使用可交换的数据结构,让不同顺序的更新得到同一结果;
- 保留两个版本,让应用或用户决定;
- 使用 Last Write Wins(最后写入胜出),接受其中一个有效更新可能被静默覆盖。
向量时钟能帮助判断两个版本是祖先关系还是并发关系,但它只发现冲突,不替业务决定正确答案。账户余额、库存和用户名需要的合并规则完全不同。
因此,多主复制适合确实需要多地低延迟写入、离线写入或多个独立写入中心的系统。若业务无法清楚说明每类冲突如何处理,增加主节点只会把故障从“写不进去”变成“写进去了但无法解释”。
无主复制:让法定人数承担协调
无主复制没有固定写入主节点。客户端或协调节点把一次写入发送给多个副本,收到足够多确认后返回成功。Amazon Dynamo 推广了这类设计,Cassandra、Riak 等系统继承了其中许多思想。
假设一份数据有 N = 3 个副本:
写入等待 W = 2 个副本确认
读取等待 R = 2 个副本返回
W + R > N
读集合和已成功写入的集合至少会重叠一个副本。协调节点比较返回的版本,便有机会取得已确认写入的最新值。节点少量离线时,系统仍能继续工作。
W + R > N 不是一张万能保证书
这条公式经常被写成“读取一定看到最新写入”,实际还需要多个前提:
- 读写针对同一组
N个副本,不能随意换成互不相交的临时节点; - 写入已经达到
W并成功返回,而非超时后只写进少数副本; - 协调节点能识别版本先后,并按确定规则合并响应;
- 并发写入和时钟冲突已有处理方式;
- 跨数据中心的一致性级别确实覆盖了需要相交的副本范围。
Dynamo 为了提高可用性采用 sloppy quorum(宽松法定人数):目标副本不可用时,把写入暂存在其他健康节点。此时两次操作选择的临时节点可能没有传统法定人数预期的交集。Cassandra 的官方文档也使用“通常可见”描述 R + W > N 的效果,并把具体保证交给一致性级别、修复机制和版本协调共同完成。
法定人数是副本集合相交的数学条件,完整的一致性保证仍然来自整个协议。
读修复和提示移交负责追赶
无主系统允许部分副本短暂落后,因此需要持续收敛。
读修复发生在读取路径。协调节点比较多个副本的摘要或数据,发现差异后把较新的版本写回落后的副本。热门数据被频繁读取,通常会更快修复;长期无人读取的数据不能依赖这一机制自动恢复。
提示移交发生在写入路径。目标副本离线时,协调节点保存一份带目标信息的临时写入;目标恢复后,再把遗漏变更交还给它。它能缩短短暂故障造成的不一致时间。
两者都属于尽力而为。Cassandra 明确说明,提示有保存期限,读修复也只覆盖本次读取涉及的副本和数据范围。要保证副本长期收敛,仍需定期运行基于 Merkle Tree 的反熵修复。把“节点回来后会自己追上”当成无需维护的承诺,会让冷数据中的差异长期潜伏。
三类复制如何选择
| 模型 | 写入入口 | 主要收益 | 主要成本 | 常见场景 |
|---|---|---|---|---|
| 单主 | 一个主节点 | 顺序清楚、冲突少、运维成熟 | 主节点故障转移、从节点延迟 | 事务业务、读多写少的常规应用 |
| 多主 | 多个主节点 | 多地就近写入、可容忍地区断连 | 并发冲突、合并规则、调试复杂 | 多地区写入、离线协作 |
| 无主 | 多个副本 | 节点故障时仍可读写、一致性可调 | 版本协调、后台修复、陈旧读取 | 高可用键值存储、会话与购物车 |
可以先按以下顺序判断:
- 单主能否满足写入延迟和可用性目标?能满足时优先使用它。
- 多地都必须接受低延迟写入吗?若必须,先写清每种冲突的合并规则。
- 业务是否愿意用短暂不一致换取节点故障期间持续写入?若愿意,再评估无主方案。
- 哪些读取必须看到自己的写入,哪些允许陈旧几秒或几分钟?
- 节点恢复、数据校验和全量修复由谁执行,如何监控积压?
上线前检查这七件事
复制模型选定后,还需要把抽象保证变成可观测的运行规则:
- 明确写入成功的条件:本地落盘、一个远端确认,还是法定人数确认。
- 定义可接受的数据丢失量和恢复时间,并用故障演练验证。
- 监控复制延迟、未发送日志、提示积压、修复进度和副本差异。
- 为需要写后读一致性的接口指定读取路径。
- 记录版本、写入位置或因果信息,避免只靠墙上时间判断新旧。
- 测试网络分区、旧主复活、写入超时和节点长期离线。
- 给冲突和无法自动合并的数据准备审计与人工处理入口。
复制不会消除故障,它把一台机器的故障转化为副本之间的协调、延迟与修复问题。单主把顺序集中在一个入口,多主把冲突交给业务处理,无主用法定人数和后台修复维持可用性。先选择最简单且能满足目标的方案,再为真实故障补齐监控和恢复机制,通常比追求更复杂的拓扑可靠。
Aide Hub 会继续分享 AI 助手、开发工具和软件工程实践。