多副本一致性:读写路径、Quorum、版本时钟与冲突收敛
数据复制到多个节点可以提高可用性、读取能力和容灾能力,但副本越多,越需要回答:写成功以哪个确认点为准、读哪个副本、怎样判断新旧、并发更新怎样合并、落后副本怎样追平。
一、学习目标
学完后应能画出 Leader/Follower 与无主复制的读写路径,正确理解 N、W、R,区分常见一致性模型,并能使用版本和收敛机制定位陈旧读问题。
二、为什么需要副本
flowchart TD
A["业务写入一份逻辑数据"] --> B["复制协议传播到多个节点"]
B --> C["节点故障时仍有副本"]
B --> D["读取可分散到多个节点"]
B --> E["跨地域保留恢复副本"]
B --> F["代价:延迟、冲突和收敛机制"]“有三份副本”只说明有三份物理拷贝,不说明三份此刻相同,也不说明任意节点都能安全接受写入。
三、Leader/Follower 写入路径
flowchart TD
A["客户端把写请求发给Leader"] --> B["Leader校验并生成日志或变更"]
B --> C["Leader写本地持久化"]
C --> D["复制给Follower"]
D --> E{"满足配置的确认条件"}
E -- "满足" --> F["向客户端返回成功"]
E -- "未满足" --> G["等待、超时或失败"]3.1 同步复制
Leader 等待指定副本落盘后才返回。故障时丢失窗口小,但慢副本会抬高写延迟,跨地域同步尤其明显。
3.2 部分或多数确认
Leader 等待部分副本确认,在延迟和容错间折中。必须查具体组件的 ACK 到哪里:收到网络、写操作系统缓存、写 WAL,还是应用到状态机,语义完全不同。
3.3 异步复制
Leader 本地提交即可返回,Follower 后台追赶。写延迟低,但 Leader 在复制前永久故障时,已向客户端确认的数据可能丢失;Follower 读取还会出现复制延迟。
四、读取路径决定能看到什么
| 读取方式 | 优点 | 风险与适用范围 |
|---|---|---|
| 只读 Leader | 较容易提供最新值 | Leader 压力更大,仍要确认领导权 |
| 任意 Follower | 扩展吞吐、地域就近 | 可能陈旧,不适合刚写后的关键确认 |
| 等待副本追到版本 | 可提供读己之写 | 增加等待和版本传播逻辑 |
| 读取多个副本合并 | 可发现旧副本 | 读放大,需要版本和冲突规则 |
| 会话粘滞副本 | 较容易单调读 | 副本切换时仍需版本下限 |
Leader 本地读也不必然线性一致:旧 Leader 在网络分区后可能尚未发现自己失去多数派。Raft ReadIndex、租约读等机制正是为了证明读取位置和领导权。
五、Dynamo 风格 N、W、R
N:每个 key 的副本数。W:写成功需要多少副本确认。R:读返回前需要读取多少副本。
固定副本集合中若满足 W + R > N,任意写集合和读集合至少有一个交集。例如 N=3,W=2,R=2,读两个副本理论上至少会碰到一个已确认写的副本。
flowchart TD
A["写入副本A和B后确认"] --> B["读取副本B和C"]
B --> C["交集副本B携带已确认版本"]
C --> D["按版本规则选择并返回"]
D --> E["异步修复落后的副本C"]5.1 为什么公式不自动等于线性一致
交集还需要以下条件才能形成可靠语义:
- 读协调者等待足够响应,并按可靠版本规则选值。
- 写确认版本不能被更旧写覆盖。
- 并发写必须能识别,不能只看机器时间。
- 读写使用同一副本集合;Sloppy Quorum 可能临时写到替代节点。
- 故障恢复和成员变更不能破坏集合假设。
- 客户端超时后的未知写必须正确处理。
W=1,R=1 延迟低但容易陈旧;W=N 会让任一副本故障阻塞写。参数是在延迟、可用性和一致性之间做业务决策。
六、一致性模型必须按可观察行为区分
| 模型 | 对客户端承诺什么 | 商业例子 |
|---|---|---|
| 线性一致 | 操作像在真实时间上的单点瞬时生效 | 锁归属、配置主版本 |
| 顺序一致 | 所有进程看到同一操作顺序,不要求符合墙上时间 | 消息或协作场景 |
| 因果一致 | 有因果关系的操作保持顺序 | 评论后回复、协同编辑 |
| 最终一致 | 停止新写后副本最终收敛 | 搜索索引、缓存副本 |
| 读己之写 | 当前会话写入后自己能看到 | 修改资料后立即回显 |
| 单调读 | 读到版本 V 后不再读小于 V 的版本 | 已支付不能刷新回待支付 |
| 单调写 | 同一会话写入按发出顺序生效 | 先创建再更新资料 |
线性一致与可串行化不是同一维度
线性一致通常描述单个对象操作是否符合真实时间顺序;可串行化描述多个事务并发执行的结果是否等价于某个串行顺序。数据库可以提供可串行化隔离,却不保证跨副本陈旧读符合真实时间;严格可串行化才同时满足事务串行化和真实时间约束。
七、读己之写和单调读怎样落地
写接口返回业务版本:
{"orderNo":"O20260718001","status":"PAID","version":19}后续读取携带最低版本:
GET /orders/O20260718001
X-Min-Version: 19服务端处理:本地版本至少为 19 就返回;否则在预算内等待复制,等待不足时转读 Leader;仍无法满足则返回明确未同步语义,不能悄悄返回旧值。
仅使用会话粘滞可以降低倒退概率,但副本故障切换后仍可能读旧值;最低可接受版本把会话要求变成了可验证条件。
八、怎样判断版本先后与并发
8.1 单调版本号
单 Leader 或单数据库行可用递增 version:
update t_order
set status = ?, version = version + 1
where order_no = ? and version = ?;多主节点各自 version + 1 可能生成相同版本,无法识别并发。
8.2 墙上时钟与 LWW
Last-Write-Wins 常按时间戳取较大值。机器时钟会漂移,客户端时间也可伪造,实际较新的写可能因时间较小而永久丢失。LWW 只适合允许覆盖、冲突代价低且有可靠时间策略的字段。
8.3 Lamport Clock
节点每次本地事件递增计数,收到远程时间 t 后设置为 max(local,t)+1。若 A 因果先于 B,则 L(A) < L(B);反过来不成立,仅凭两个 Lamport 值不能证明因果关系。
8.4 Vector Clock
版本A = {east: 3, west: 1}
版本B = {east: 2, west: 2}A 的 east 分量更大,B 的 west 分量更大,双方都不逐分量包含对方,因此是并发版本。若 A 每个分量都不小于 B,且至少一个更大,则 A 支配 B。
8.5 Hybrid Logical Clock
HLC 组合物理时间和逻辑计数,在大致保留时间可读性的同时处理同一时间片和回拨。它仍不能决定两个业务修改怎样合并,只提供顺序或因果元数据。
九、JDK 8 Demo:判断向量时钟关系
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public final class VectorClockDemo {
enum Relation { BEFORE, AFTER, EQUAL, CONCURRENT }
static Relation compare(Map<String, Long> left, Map<String, Long> right) {
Set<String> nodes = new HashSet<String>();
nodes.addAll(left.keySet());
nodes.addAll(right.keySet());
boolean less = false;
boolean greater = false;
for (String node : nodes) {
long l = left.containsKey(node) ? left.get(node) : 0L;
long r = right.containsKey(node) ? right.get(node) : 0L;
if (l < r) less = true;
if (l > r) greater = true;
}
if (less && greater) return Relation.CONCURRENT;
if (less) return Relation.BEFORE;
if (greater) return Relation.AFTER;
return Relation.EQUAL;
}
public static void main(String[] args) {
Map<String, Long> east = new HashMap<String, Long>();
east.put("east", 3L);
east.put("west", 1L);
Map<String, Long> west = new HashMap<String, Long>();
west.put("east", 2L);
west.put("west", 2L);
System.out.println(compare(east, west));
}
}Demo 只判断因果关系,不负责业务合并。购物车可按商品集合合并,账户余额不能通过“取较大值”解决;资金数据通常需要单写入点、账本或严格事务约束。
十、落后副本怎样收敛
10.1 Read Repair
协调者读多个副本,按版本选出最新值,并修复旧副本。它依赖读流量,长期不读的冷 key 可能一直不修复。
10.2 Anti-Entropy
节点后台比较数据摘要并同步差异。Merkle Tree 可按树分支定位差异,避免逐条传输全量 key。频率过低会延长不一致窗口,过高会争抢磁盘和网络。
10.3 Hinted Handoff
目标副本临时不可用时,协调节点把写暂存为 Hint,恢复后转交。它提高故障期间写可用性,但不是永久副本,可能扩大磁盘占用和恢复洪峰。
10.4 CDC/WAL 重放
从事实源提交日志重放到下游。必须记录 Offset/LSN、幂等写入、Schema 演进和失败重试;消费到最新位置也不证明数据完全正确,仍需校验和对账。
flowchart TD
A["读或后台校验发现副本差异"] --> B["比较版本和因果关系"]
B --> C{"是否存在唯一新版本"}
C -- "是" --> D["把新版本修复到落后副本"]
C -- "并发" --> E["按业务规则、CRDT或人工合并"]
D --> F["记录修复进度和失败指标"]
E --> F十一、冲突不能只写“后写覆盖前写”
| 数据类型 | 可选策略 | 不能忽略的风险 |
|---|---|---|
| 用户昵称 | LWW 或用户确认 | 覆盖通常可接受,但时钟需可信 |
| 购物车商品 | 集合合并、OR-Set 思路 | 删除与新增的并发语义要定义 |
| 点赞计数 | G-Counter/PN-Counter 思路 | 去重和节点生命周期 |
| 库存 | 单写入点、预占流水、状态机 | 不能简单取最大值或相加 |
| 余额 | 不可变账本和复式记账 | LWW 会造成资金丢失 |
| 文档协作 | OT/CRDT 或人工冲突 | 删除、权限和历史保留 |
CRDT 通过满足交换律、结合律和幂等性的合并规则,让副本在接收相同更新后收敛;它不是让所有业务自动无冲突,元数据增长和删除墓碑也需要治理。
十二、商业场景:支付后的跨地域读取
用户在华东完成支付,订单版本从 18 变为 19;随后查询被路由到仍停在 18 的华南副本。错误做法是返回 WAIT_PAY。
正确链路:支付响应返回版本 19;后续查询携带最低版本 19;华南达到 19 就返回,否则短暂等待;超过阈值转事实源或 Leader;监控版本等待时间和回源比例。
这比固定“支付后 3 秒读主库”更可验证:复制快时无需多等,复制慢时也不会在固定窗口结束后读旧值。
十三、读到旧数据的生产 Runbook
- 明确事实源、业务 key、期望版本和实际版本。
- 查写请求是否真正提交,保存事务流水或日志位置。
- 查复制 Offset/LSN、队列 Lag 和最后成功时间。
- 查读取实际命中的地域、集群、节点和缓存层。
- 确认读 API 的一致性级别及是否允许陈旧读。
- 检查会话是否携带最低版本,网关是否丢失上下文。
- 检查冲突规则是否用机器时间错误覆盖新版本。
- 检查 Read Repair、Hint、Anti-Entropy 或 CDC 是否积压。
- 先阻止旧值继续扩散,再按事实源版本幂等修复。
- 修复后对比副本校验和,并补监控和演练。
十四、常见错误
| 错误说法 | 正确理解 |
|---|---|
| 有副本就是高可用 | 还取决于故障域、选主、Quorum、路由和恢复 |
R + W > N 就一定强一致 | 还需可靠版本、固定集合、并发处理和读写协议 |
| 时间戳大的一定更新 | 时钟漂移可能让实际新写被覆盖 |
| 最终一致就是等一会 | 必须定义收敛机制、上限、告警和修复出口 |
| 读 Follower 只是稍慢 | 可能违反支付确认、权限变更等业务语义 |
| Read Repair 能修全部数据 | 冷 key 仍需 Anti-Entropy |
| CRDT 能解决所有冲突 | 只有符合其代数和业务语义的数据才适合 |
