一致性模型与线性化:从强一致到最终一致

一致性模型与线性化:系统梳理从线性一致、顺序一致、因果一致到会话保证与最终一致的完整强度谱系,给出线性一致性的判定方法与 Jepsen 故障注入验证思路,剖析 LWW、版本向量与 CRDT 的冲突解决取舍,并附工程选型决策树、常见踩坑与监控指标清单

分布式系统中同一份数据存在多个副本,客户端读写不同副本时会看到不同结果。「一致性模型」就是对「并发读写应该返回什么」的契约:它规定了读、写、比较交换(Compare-And-Swap)这些单操作在时间轴上的合法返回值集合。契约越强,写业务代码越轻松,代价是延迟越高、可用性越低。

很多团队嘴上说「我们保证强一致」,实际实现只是「写走主库、读也走主库」。这种说法在单副本时成立,在主从切换、跨地域部署、网络分区的瞬间就失效。本文按强度从高到低梳理这条谱系,给出可判定的定义、可验证的检查方法,以及一套能直接用的选型决策树。

一句话:一致性模型不是「数据对不对」,而是「并发时你能看到什么顺序」。

1. 一致性模型到底在定义什么

1.1 三个容易混淆的「一致性」

名称出处含义
数据一致性ACID 中的 C事务执行前后数据满足业务约束
副本一致性一致性模型并发读写在不同副本上看到的顺序语义
CAP 中的 C分布式理论特指线性一致性,即最强的那一档

本文讨论第二种。它也是分布式系统里最容易被含糊带过的一层——因为它不像索引、锁那样有明确的配置项,而是散落在「读走哪个副本」「写等几个确认」「会话怎么绑定」这些细节里。

1.2 副本延迟带来的三类问题

现象触发原因用户感知
读己之写失效写走主库、读走从库提交后刷新看不到自己的数据
单调读被破坏先后读到延迟不同的两个副本数据「倒退」回旧值
因果倒置评论先同步、正文后同步看到回复却看不到原帖

这三类问题分别对应会话保证中的读己之写、单调读与因果一致,是设计时最先要问清楚的三句话。

1.3 一致性模型不是「性能开关」

一个常见误解是「强一致就是慢,弱一致就是快」。更准确的模型是:一致性强度决定的是「为了给出某个答案,必须等待多少信息」。线性一致必须等到多数派确认;因果一致只需等到依赖的因果前驱;最终一致什么都不用等。等待的对象不同,延迟量级也完全不同——前者是跨洲 RTT,后者可能是本地内存。

2. 一致性谱系

2.1 强度是包含关系

把常见模型按强度从高到低排列:

模型保证典型实现
线性一致性全局实时全序,最强etcd、ZooKeeper 写路径、Spanner
顺序一致性全局全序,但不约束实时早期多核内存模型
因果一致性只保因果序,并发可乱序MongoDB 因果会话、COPS
读己之写会话内可见自己的写粘性路由、会话版本过滤
单调读会话内读不回退副本版本追踪
最终一致停止写入后最终收敛Cassandra、DynamoDB

关键点:强度是包含关系——线性一致蕴含因果一致,因果一致蕴含最终一致,反之都不成立。所以选型时正确的方向是从上往下走:先问「业务能不能容忍乱序」,再逐级下降,而不是一上来就选最强。

2.2 为什么不是「越强越好」

强度一次写的等待分区时的可用性代码复杂度
线性一致多数派确认(跨洲数十 ms)少数派分区不可写低(模型简单)
因果一致因果依赖到齐大部分分区可写中(需传因果)
最终一致本地写即返回全部分区可写高(业务处理冲突)

这张表解释了为什么真实系统总是混合:把强一致压缩到最小的状态集(锁、配置、唯一性约束),其余全部下沉到弱一致。

3. 线性一致性

3.1 定义

线性一致性(Linearizability)要求:若操作 A 在实时时间上先于操作 B 完成(A 的响应早于 B 的请求),则任何客户端都不能观察到 B 先于 A 生效。等价说法是每个操作看起来在请求与响应之间的某一瞬间原子生效,且所有操作共享同一个全序。

注意「实时」这个词:它是线性一致与顺序一致的唯一分界线。顺序一致只要求存在一个与各进程程序顺序相容的全序,允许这个全序与真实时间脱钩。

3.2 判定方法

判定一段历史是否线性一致,本质是搜索一个满足实时约束的全序:

历史:A: W(x,1)   区间 [0,10]
      B: R(x)->1  区间 [2,4]
      C: R(x)->0  区间 [3,5]

若 B 读到 1、C 读到 0:
  要把 C 排在 A 之前(C 读到旧值 0)—— 区间重叠,可行
  要把 B 排在 A 之后(B 读到新值 1)—— 区间重叠,也可行
  同时满足意味着 A 既早于 B 又晚于 C,实时序矛盾 -> 非线性一致
# 简化的线性一致性检查(暴力搜索,仅示意思路)
def is_linearizable(history, initial):
    def search(state, pending):
        if not pending:
            return True
        earliest = min(p.max_time for p in pending)
        for i, op in enumerate(pending):
            if op.min_time > earliest:
                continue                       # 违反实时序,跳过
            if applies(op, state):             # 该操作在当前状态上合法
                nxt = apply_op(op, state)
                if search(nxt, pending[:i] + pending[i + 1:]):
                    return True
        return False
    return search(initial, history)

这段搜索是指数级的,只适合教学。真实检查器(如 Jepsen 的 Knossos、Elle)会利用「只比较读到的值与写集合」把状态空间压到多项式级。

3.3 用故障注入验证

线性一致性最容易被「看起来对」的测试骗过:单线程顺序跑一万次都通过,一上并发就露馅。因此验证必须注入故障:

Jepsen 类测试的故障注入维度:
  1. 网络分区:随机切断节点间链路,模拟脑裂
  2. 时钟偏移:给节点加 -5s ~ +5s 的偏移
  3. 进程暂停:SIGSTOP 后恢复,模拟长 GC / 虚拟机挂起
  4. 主节点切换:在写压力下杀掉 leader
  5. 丢包与乱序:注入延迟与重排

只有在这五类故障下仍能通过历史检查,才有资格对外宣称线性一致。这套「注入故障 + 历史分析」的方法论同样适用于验证最终一致系统是否真的收敛,只是判据从「全序合法」换成「停写后有限时间内一致」。

3.4 代价

线性一致要求任何读都要经过多数派或 leader 确认,直接推高尾延迟。跨地域部署时,一次线性一致写通常要等跨洲 RTT(几十到上百毫秒)。因此它只适合协调类状态:锁、选主、配置、唯一性约束。这类状态的强一致由共识算法兜底,可参考 https://plumephp.com/consensus-algorithms/。

一个例外是「线性一致读」可以通过 ReadIndex 之类的手段避免落盘,只等多数派的确认位置,从而把写路径的成本降下来——这也是 etcd、Raft 实现里最常见的优化点。

4. 顺序一致性与因果一致性

4.1 顺序一致性

顺序一致性只要求存在某个全序与所有进程的本地程序顺序一致,但不要求该全序尊重实时时间。也就是说,它允许「我已经看到写入、你还没看到」,只要所有进程看到的顺序相同即可。对分布式存储而言它太弱(不约束实时),因此很少作为对外契约,更多出现在 CPU 内存模型里。

4.2 因果一致性

因果一致性只保 happens-before 关系:如果 A 因果先于 B,则所有节点必须先看到 A 再看到 B;并发事件任意顺序均可。实现方式是把因果信息随数据一起传播(向量时钟、依赖列表或混合逻辑时钟),接收方缓冲未满足依赖的更新。

依赖追踪(Dependency Tracking):
  节点收到更新 U,其依赖集 dep(U) = {v1, v2}
  本地已应用 v1、v2 -> 立即应用 U
  否则 -> 放入缓冲区,等依赖到齐再应用

缓冲是因果一致的代价:依赖迟迟不到,更新就会长期挂起。因此实现里必须加超时与兜底策略(如依赖超时后强制应用并记录异常),否则一次丢包就能让某个副本永久落后。

4.3 三种模型对照

维度线性一致顺序一致因果一致
实时约束强无仅因果部分
需要共识是是(单点定序)否
跨地域延迟高中低
并发冲突不可能不可能可能
适用场景锁、配置、唯一约束内部定序社交、协作编辑

因果一致的好处是跨地域延迟低(无需全球多数派),又避免了「评论先于正文出现」这类反直觉现象。MongoDB 的 causal session、Riak 的 causal context 都属此类。事件在副本间流动时如何携带因果信息,可延伸阅读事件驱动架构 。

5. 会话保证

5.1 四种保证

多数系统无法承受全局线性一致,于是退一步:只在单个客户端的会话内提供有序保证。这是性价比最高的一档,因为用户只关心自己看到的顺序。

保证含义实现要点
读己之写会话内能读到自己刚写的值粘性路由到同副本,或记录会话版本
单调读同一会话的读不回退记录已读版本,过滤更旧副本
单调写会话内写按顺序生效单连接串行化
写后读写后必能读到(含他会话的写)需要全局版本或因果依赖

5.2 版本过滤实现

// 会话版本过滤:读请求带上"至少看到这个版本"的约束
type Session struct {
    lastVersion uint64 // 本会话已观察到的最大版本
}

func (s *Session) Read(key string, replicas []Replica) (Value, error) {
    for _, r := range replicas {
        v := r.Get(key)
        if v.Version >= s.lastVersion { // 只接受不比已读更旧的副本
            s.lastVersion = v.Version
            return v, nil
        }
    }
    return Value{}, ErrStale // 全部副本都太旧,重试或回退读主
}

5.3 粘性路由与兜底

落地时通常配合读主兜底:一旦会话要求「读己之写」而所有从库都落后,就回退到主库读。这个兜底路径必须被监控,否则它会悄悄把大部分读流量吸到主库上,从库形同虚设。

# 粘性路由:同一会话固定到同一副本,减少跨副本乱序
upstream read_pool {
    hash $cookie_session_id consistent;   # 会话级一致性哈希
    server 10.0.1.11:6379;
    server 10.0.1.12:6379;
}

6. 最终一致与冲突解决

6.1 最终一致的定义边界

最终一致只承诺:如果停止写入,副本会在有限时间内收敛到相同值。它没说多久,也没说中间能读到什么。因此工程上必须补两条:收敛时间上界(例如 P99 500ms)与中间态的可见性规则(能否读到中间态、能否乱序)。

没有这两条约束的「最终一致」在事故复盘时几乎无法定性——因为你无法判断「收敛慢」是 bug 还是设计预期。

6.2 冲突解决策略

并发写同一键时必然产生冲突,处理方式:

策略机制风险
LWW(Last-Write-Wins)比时间戳取大者时钟偏移导致丢更新
版本向量 + 应用合并检测并发后交给业务实现复杂,需业务语义
CRDT数学上保证可交换合并只适用特定数据结构
读时修复 / 反熵对账后台比对差异并修复收敛慢,需要巡检

LWW 的隐患最大:NTP 漂移会让「后写的」被判为「先写」,静默丢失更新。若数据不可丢,应改用版本向量或 CRDT。副本间差异的检测与修复流程,可参考 https://plumephp.com/distributed-data-consistency-reconciliation/。

6.3 CRDT 的两种流派

CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)用数学结构把合并变成幂等、可交换、可结合的操作:

流派代表结构合并方式适用
状态型(CvRDT)G-Counter、OR-Set逐元素取 max / 并集状态小、带宽充裕
操作型(CmRDT)操作日志 + 因果投递按因果序重放操作状态大、操作小
# G-Counter:只增不减的分布式计数器
class GCounter:
    def __init__(self, node_id, nodes):
        self.id = node_id
        self.counts = {n: 0 for n in nodes}

    def increment(self):
        self.counts[self.id] += 1          # 只动自己的分量

    def value(self):
        return sum(self.counts.values())   # 读时求和

    def merge(self, other):
        for n in self.counts:
            self.counts[n] = max(self.counts[n], other.counts[n])  # 取 max

CRDT 的边界很清楚:它解决「合并」,不解决「业务语义」。像「余额不能为负」这种不变量,CRDT 给不出保证,必须退回强一致。

6.4 用事务换一致性

当业务真的需要跨行、跨表的强一致,可以在存储层之上引入分布式事务,把「多副本协调」降维成「事务协调」问题:

强一致需求的三种手段
  1. 收敛到单点:把强一致状态放进共识组件(etcd / ZooKeeper)
  2. 事务协议:2PC / TCC / Saga 保证跨资源原子性
  3. 避免共享:数据分区,让每个强一致单元只属于一个节点

三条路的取舍与实现细节,见 https://plumephp.com/distributed-transactions/。多数系统的现实做法是混合:核心账务走共识,周边状态走最终一致。

7. 选型与实践

7.1 决策树

这个状态能不能容忍读到旧值?
|- 不能 -> 需要线性一致 -> 共识组件(etcd / ZooKeeper / Spanner)
|- 能
   |- 只需"自己看到自己" -> 会话保证(粘性路由 + 版本过滤)
   |- 需要"评论不早于正文" -> 因果一致(向量时钟 + 依赖缓冲)
   |- 都能忍 -> 最终一致(LWW / CRDT + 对账兜底)

7.2 常见踩坑

坑现象修法
把「读主」当线性一致主切换瞬间仍可能读到旧值主库需先达成多数派再对外服务
LWW 依赖物理时钟静默丢更新,事后无法追溯换逻辑时钟或改用 CRDT
会话保证未绑定连接客户端重连后保证失效会话版本随请求头传递
认为最终一致「很快」跨地域收敛达分钟级明确收敛 SLA 并持续监控
用读超时掩盖不一致重试后读到更旧数据重试前先固定会话版本

7.3 监控指标

  • 副本落后量(replication lag)的 P50 与 P99
  • 冲突发生率与冲突解决耗时
  • 会话回退读主(fallback to leader)的比例
  • 线性一致操作(锁、配置读取)的 P99 延迟

总结

主题关键内容
一致性本质定义并发读写的合法返回顺序,而非数据正确性
线性一致实时全序、需要共识、延迟最高,适合锁与配置
顺序与因果顺序一致不约束实时;因果一致只保 happens-before
会话保证读己之写、单调读、写后读,性价比最高的一档
最终一致收敛无天然上界、需明确 SLA,冲突用 LWW/CRDT/对账
选型按「能否容忍旧值」逐级下降,混合使用是常态

一致性模型的价值在于把「多久能看到最新数据」从口头约定变成可测试的契约。落地时的正确姿势是:对每一类状态单独问「它能容忍什么顺序」,然后把强一致压在尽可能小的状态集上,其余交给会话保证与最终一致。理解了这层分层,再回头看 CAP 与 PACELC,就不会把它们当成非此即彼的口号。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「distributed-systems」更多文章

  1. 边缘计算架构与就近接入
  2. 分布式系统成本优化与容量治理
  3. 单体到分布式:遗留系统迁移与绞杀者模式