Skip to content

多副本一致性:读写路径、Quorum、版本时钟与冲突收敛

数据复制到多个节点可以提高可用性、读取能力和容灾能力,但副本越多,越需要回答:写成功以哪个确认点为准、读哪个副本、怎样判断新旧、并发更新怎样合并、落后副本怎样追平。

一、学习目标

学完后应能画出 Leader/Follower 与无主复制的读写路径,正确理解 NWR,区分常见一致性模型,并能使用版本和收敛机制定位陈旧读问题。

二、为什么需要副本

mermaid
flowchart TD
    A["业务写入一份逻辑数据"] --> B["复制协议传播到多个节点"]
    B --> C["节点故障时仍有副本"]
    B --> D["读取可分散到多个节点"]
    B --> E["跨地域保留恢复副本"]
    B --> F["代价:延迟、冲突和收敛机制"]

“有三份副本”只说明有三份物理拷贝,不说明三份此刻相同,也不说明任意节点都能安全接受写入。

三、Leader/Follower 写入路径

mermaid
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 风格 NWR

  • N:每个 key 的副本数。
  • W:写成功需要多少副本确认。
  • R:读返回前需要读取多少副本。

固定副本集合中若满足 W + R > N,任意写集合和读集合至少有一个交集。例如 N=3,W=2,R=2,读两个副本理论上至少会碰到一个已确认写的副本。

mermaid
flowchart TD
    A["写入副本A和B后确认"] --> B["读取副本B和C"]
    B --> C["交集副本B携带已确认版本"]
    C --> D["按版本规则选择并返回"]
    D --> E["异步修复落后的副本C"]

5.1 为什么公式不自动等于线性一致

交集还需要以下条件才能形成可靠语义:

  1. 读协调者等待足够响应,并按可靠版本规则选值。
  2. 写确认版本不能被更旧写覆盖。
  3. 并发写必须能识别,不能只看机器时间。
  4. 读写使用同一副本集合;Sloppy Quorum 可能临时写到替代节点。
  5. 故障恢复和成员变更不能破坏集合假设。
  6. 客户端超时后的未知写必须正确处理。

W=1,R=1 延迟低但容易陈旧;W=N 会让任一副本故障阻塞写。参数是在延迟、可用性和一致性之间做业务决策。

六、一致性模型必须按可观察行为区分

模型对客户端承诺什么商业例子
线性一致操作像在真实时间上的单点瞬时生效锁归属、配置主版本
顺序一致所有进程看到同一操作顺序,不要求符合墙上时间消息或协作场景
因果一致有因果关系的操作保持顺序评论后回复、协同编辑
最终一致停止新写后副本最终收敛搜索索引、缓存副本
读己之写当前会话写入后自己能看到修改资料后立即回显
单调读读到版本 V 后不再读小于 V 的版本已支付不能刷新回待支付
单调写同一会话写入按发出顺序生效先创建再更新资料

线性一致与可串行化不是同一维度

线性一致通常描述单个对象操作是否符合真实时间顺序;可串行化描述多个事务并发执行的结果是否等价于某个串行顺序。数据库可以提供可串行化隔离,却不保证跨副本陈旧读符合真实时间;严格可串行化才同时满足事务串行化和真实时间约束。

七、读己之写和单调读怎样落地

写接口返回业务版本:

json
{"orderNo":"O20260718001","status":"PAID","version":19}

后续读取携带最低版本:

text
GET /orders/O20260718001
X-Min-Version: 19

服务端处理:本地版本至少为 19 就返回;否则在预算内等待复制,等待不足时转读 Leader;仍无法满足则返回明确未同步语义,不能悄悄返回旧值。

仅使用会话粘滞可以降低倒退概率,但副本故障切换后仍可能读旧值;最低可接受版本把会话要求变成了可验证条件。

八、怎样判断版本先后与并发

8.1 单调版本号

单 Leader 或单数据库行可用递增 version

sql
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

text
版本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:判断向量时钟关系

java
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 演进和失败重试;消费到最新位置也不证明数据完全正确,仍需校验和对账。

mermaid
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

  1. 明确事实源、业务 key、期望版本和实际版本。
  2. 查写请求是否真正提交,保存事务流水或日志位置。
  3. 查复制 Offset/LSN、队列 Lag 和最后成功时间。
  4. 查读取实际命中的地域、集群、节点和缓存层。
  5. 确认读 API 的一致性级别及是否允许陈旧读。
  6. 检查会话是否携带最低版本,网关是否丢失上下文。
  7. 检查冲突规则是否用机器时间错误覆盖新版本。
  8. 检查 Read Repair、Hint、Anti-Entropy 或 CDC 是否积压。
  9. 先阻止旧值继续扩散,再按事实源版本幂等修复。
  10. 修复后对比副本校验和,并补监控和演练。

十四、常见错误

错误说法正确理解
有副本就是高可用还取决于故障域、选主、Quorum、路由和恢复
R + W > N 就一定强一致还需可靠版本、固定集合、并发处理和读写协议
时间戳大的一定更新时钟漂移可能让实际新写被覆盖
最终一致就是等一会必须定义收敛机制、上限、告警和修复出口
读 Follower 只是稍慢可能违反支付确认、权限变更等业务语义
Read Repair 能修全部数据冷 key 仍需 Anti-Entropy
CRDT 能解决所有冲突只有符合其代数和业务语义的数据才适合

十五、关联知识点