分布式系统中同一份数据存在多个副本,客户端读写不同副本时会看到不同结果。「一致性模型」就是对「并发读写应该返回什么」的契约:它规定了读、写、比较交换(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,就不会把它们当成非此即彼的口号。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。