数据库基础体系 · 第 130/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
分布式数据库一致性模型:线性一致、顺序一致、因果和最终一致
在单机数据库中,客户端通常只面对一个状态源:事务提交后,后续查询从同一个数据库实例读取数据。分布式数据库或主从复制系统则不同:
- 数据可能存在多个副本;
- 写入确认和副本应用可能不是同一个时刻;
- 不同客户端可能被路由到不同节点;
- 网络分区、节点故障和故障转移会改变“谁是当前数据源”;
- 一个事务可能跨越多个分片或副本。
因此,“写入成功后能否立即读到”“不同客户端是否看到相同顺序”“两个相关写入能否被拆开观察”,都需要用一致性模型准确描述。
本文讨论四种常见模型:
- 线性一致性(linearizability)
- 顺序一致性(sequential consistency)
- 因果一致性(causal consistency)
- 最终一致性(eventual consistency)
还需要特别区分:复制一致性模型不是事务隔离级别。一个数据库可以在单个节点上提供 Serializable 事务,但异步副本仍然可能读到旧数据;也可以在复制层提供较强的读写顺序,但事务内部仍然不是 Serializable。
一、先建立分析对象:操作、历史和可观察结果
一致性模型不是针对某个“最终数据库状态”简单下定义,而是针对一组并发操作的执行历史定义。
一次操作通常包含:
- 调用(invocation)
- 返回(response)
- 操作参数
- 操作结果
- 发起操作的客户端或事务
例如,对键 x 的寄存器执行:
初始状态:x = 0
客户端 A:写入 x = 1
客户端 B:读取 x
如果客户端 A 的写入已经返回成功,随后客户端 B 才开始读取,那么观察结果可能是:
A: write(x, 1) ──返回成功──
B: read(x) ──返回 1
也可能是:
A: write(x, 1) ──返回成功──
B: read(x) ──返回 0
第二种结果是否允许,取决于一致性模型以及读请求实际访问的副本。
1. 实时先后关系
如果操作 op1 已经返回,op2 才开始调用,那么它们之间存在实时先后关系:
如果两个操作的时间区间重叠,则不一定存在这种关系。
例如:
A: write(x, 1) ───────────── 返回
B: read(x) ───── 返回
两者重叠时,读取操作在逻辑上可以被放在写入之前,也可以放在写入之后,具体取决于读到了什么结果。
2. 程序顺序
同一个客户端发出的操作通常有程序顺序:
客户端 A:
1. write(x, 1)
2. read(y)
即使系统内部并发执行,这两个操作也通常要求按客户端看到的顺序处理:
顺序一致性和因果一致性都会用到程序顺序,但线性一致性还会额外要求所有操作尊重实时先后。
3. 操作的合法顺序
一致性模型通常还依赖底层对象的语义。例如,针对一个普通读写寄存器:
write(x, 1)
read(x) -> 1
是合法的;而在没有其他写入的情况下:
write(x, 1)
read(x) -> 0
通常不符合该寄存器的单一副本语义。
对数据库而言,操作可能不是简单的 read 和 write,而是:
- 事务提交;
- 范围查询;
- 条件更新;
- 多行写入;
- 读取多个键;
- 跨分片事务。
因此,实际判断时必须先明确对象语义:讨论的是单个键、单个分片、一个事务,还是整个数据库。
二、线性一致性:像只有一个副本,并且操作立即生效
1. 形式化定义
一个操作历史是线性一致的,需要满足:
- 可以把所有已完成操作排列成一个全序;
- 这个全序符合对象的合法顺序;
- 如果操作
op1在实时上先于op2,那么全序中也必须满足:
第三条是线性一致性的关键:全局单一顺序必须尊重实时先后。
可以把它直观理解为:
每个操作都像在某个瞬间原子地生效;所有客户端看到的是同一条时间线。
这个“某个瞬间”称为操作的线性化点(linearization point)。它可能是:
- 共识日志中的提交位置;
- 主节点确定事务提交的时刻;
- 读请求执行的一次有效状态检查;
- 某种带租约或读屏障的逻辑位置。
线性化点不一定等于客户端发起请求或收到响应的时刻,但必须位于操作调用和返回之间。
2. 一个满足线性一致性的例子
初始状态:
x = 0
执行历史:
时间 →
客户端 A:write(x, 1) ───── 返回成功
客户端 B: read(x) ───── 返回 1
可以构造如下全序:
write(x, 1)
read(x) -> 1
这个顺序符合实时关系,因此历史是线性一致的。
如果两个操作发生重叠:
时间 →
客户端 A:write(x, 1) ───────────── 返回成功
客户端 B: read(x) ───── 返回 0
只要读取操作在写入的线性化点之前,就仍然可能合法。线性一致性并不要求“请求先到达的操作一定先生效”,而是要求存在一个符合调用、返回边界的全序。
3. 反例:写入已返回,后续读取仍返回旧值
初始状态:
x = 0
执行历史:
时间 →
客户端 A:write(x, 1) ── 返回成功
客户端 B: read(x) ── 返回 0
由于 A 的写入已经返回,B 的读取才开始,因此必须满足:
如果读取返回 0,就不存在既尊重实时关系、又符合寄存器语义的全序。这个历史不是线性一致的。
这正是异步主从复制中最常见的“写后读不一致”:
客户端
│
├── 写主库,主库提交并返回成功
│
└── 读从库,从库尚未应用该日志,返回旧值
主库和从库最终可能会收敛,但在该时刻系统不满足线性一致性。
4. 线性一致性提供的直觉
线性一致性通常能满足以下直觉:
- 写入成功后,从系统整体观察不到更旧的状态;
- 一个客户端读到某个新版本后,不应在后续读中退回旧版本;
- 全局只有一个可解释的操作顺序;
- 依赖实时顺序的业务逻辑更容易推理。
例如库存扣减、唯一名分配、主节点选举状态、账户余额变更等场景,往往需要至少对相关对象或事务提供线性化语义。
5. 线性一致性不等于“所有事务都 Serializable”
对单个对象来说,线性一致性很强;但数据库事务还涉及:
- 多行读写;
- 谓词范围;
- 并发事务之间的冲突;
- 快照;
- 可重复读;
- 幻读;
- 原子提交。
数据库领域常用的相关术语是严格可串行化(strict serializability):
- 可串行化:结果等价于某个串行事务顺序;
- 严格可串行化:这个串行顺序还要尊重事务之间的实时先后。
可以把严格可串行化视为事务级别的线性化思想,但两者的精确定义仍取决于操作对象和事务模型。一个系统对单键读写线性一致,并不自动意味着跨表、跨分片事务严格可串行化。
三、顺序一致性:每个客户端的顺序都保留,但不保证实时顺序
1. 形式化定义
一个历史满足顺序一致性,当且仅当存在一个全局串行顺序,使得:
- 所有操作都能按照这个顺序排列;
- 该顺序符合对象的合法语义;
- 每个客户端自己的程序顺序在全局顺序中保持不变。
与线性一致性的差别在于:
顺序一致性要求保留每个客户端的程序顺序,但不要求保留不同客户端之间的实时先后。
2. 线性一致但顺序不一致的关键区别
考虑初始状态:
x = 0
执行:
时间 →
客户端 A:write(x, 1) ── 返回成功
客户端 B: read(x) ── 返回 0
这个历史不是线性一致的,因为 A 的写入已返回,B 才开始读取。
但它可以满足顺序一致性。构造一个全局顺序:
read(x) -> 0
write(x, 1)
对于客户端 A,它只有一个操作;对于客户端 B,它也只有一个操作,因此没有任何客户端内部顺序被破坏。顺序一致性不关心 A 的写入在物理时间上先完成,只要求存在一个全局串行解释。
这说明:
但通常不能反过来推导。
3. 顺序一致性为什么仍然比“各副本随便读”强
顺序一致性不是简单的“每个副本都自洽”。它要求所有操作能够放入同一个全局顺序。
例如某个客户端执行:
1. write(x, 1)
2. read(x) -> 0
如果中间没有其他写入,这通常无法解释为一个合法的单副本顺序:
write(x, 1)
read(x) -> 0
因此,该客户端自己的程序顺序已经被破坏。这种结果连顺序一致性通常也不满足。
但是,不同客户端对并发操作的观察可以产生问题。
4. 顺序一致性允许违反实时顺序
假设两个操作没有重叠:
A:write(x, 1) 完成
B:read(x) -> 0 开始并完成
顺序一致性仍然可以将读取排在写入之前:
read(x) -> 0
write(x, 1)
所以,对于需要“提交后任何后续读取都必须看到”的业务,顺序一致性可能仍然不够。
5. 顺序一致性与多副本实现
实现顺序一致性通常需要系统维护一个可解释的全局操作序列,或者让所有客户端观察到的操作顺序能够映射到同一个序列。
只在每个副本本地按顺序应用日志还不够。例如:
副本 R1 看到:write A,然后 write B
副本 R2 看到:write B,然后 write A
如果客户端分别在两个副本上读取,并且能观察到相互矛盾的顺序,那么系统可能无法为所有操作构造一个共同的全局序列。
这里要区分两件事:
- 副本内部日志是否有序;
- 所有客户端观察到的历史是否存在共同的全局顺序。
前者不自动推出后者。
四、因果一致性:所有因果相关操作必须按因果顺序可见
顺序一致性强制所有操作共享一条全局顺序。因果一致性放宽了这一点:
只有存在因果关系的操作必须保持顺序;彼此并发、互不依赖的操作可以在不同副本上以不同顺序出现。
1. 因果关系的来源
常见的因果关系包括:
同一客户端的程序顺序
客户端 A:
write(x, 1)
read(x)
第二个操作依赖第一个操作先发生。
读到某个写入后再进行写入
客户端 A:write(x, 1)
客户端 B:read(x) -> 1
客户端 B:write(y, 1)
因为 B 先读到了 A 的写入,所以 B 对 y 的写入依赖于 A 对 x 的写入:
这里的箭头表示因果先行关系。
应用层显式传递依赖
例如客户端 A 创建订单,客户端 B 读取订单事件后创建支付记录。即使这两个操作由不同客户端完成,事件传递也会建立因果关系。
通常可以把因果关系写成传递闭包:
其中上标 + 表示传递闭包。例如:
A 写 x
↓
B 读到 x
↓
B 写 y
↓
C 读到 y
那么 A 写 x 必须先于 C 读到 y,即使 C 没有直接读取 x。
2. 因果一致性的基本要求
如果操作 op1 因果先于 op2:
那么所有能够观察到这两个操作的副本,都不能把 op2 呈现为已经发生,而把 op1 呈现为尚未发生。
对于上面的订单和支付例子,不能出现:
客户端看到:支付记录已经存在
客户端却看不到:对应的订单创建
否则就是因果倒置。
3. 反例:观察到结果,却看不到前因
初始状态:
x = 0
y = 0
执行:
客户端 A:write(x, 1)
客户端 B:read(x) -> 1
write(y, 1)
客户端 C:read(y) -> 1
read(x) -> 0
因果关系是:
A 写 x
→ B 读到 x
→ B 写 y
→ C 读到 y
因此 C 读取到 y=1 时,因果上已经必须包含 x=1。如果 C 随后仍读取到 x=0,就违反了因果一致性。
在异步复制系统中,这种问题可能表现为:
- 下游服务已经看到“支付成功”事件;
- 但查询订单服务时仍看不到订单;
- 一个评论回复已经可见,但原评论暂时不可见;
- 一个派生索引已经包含记录,但主数据查询不到对应对象。
4. 因果一致性允许并发写入采用不同顺序
考虑两个没有依赖关系的并发写入:
客户端 A:write(x, "A")
客户端 B:write(x, "B")
A 和 B 之间没有因果关系。因果一致性允许:
副本 R1 观察到:A,然后 B
副本 R2 观察到:B,然后 A
只要系统能够处理并发版本,且不会破坏已有的因果关系。
这与顺序一致性不同。顺序一致性要求所有操作能够放入同一个全局顺序,因此不能让两个客户端永久观察到互相冲突的并发写入顺序,除非对象语义允许这种结果并能构造同一个全序。
实际系统可能使用:
- 版本向量;
- 逻辑时钟;
- 因果元数据;
- 带上下文的写入;
- 事件依赖标记;
- 按依赖追踪的复制协议。
但仅有时间戳并不自动提供因果一致性。物理时钟可能漂移,且“时间更大”不一定表示某个操作读取过另一个操作的结果。
5. 因果一致性不等于所有副本立即相同
因果一致性允许不同副本暂时拥有不同状态:
R1:已经看到 write A 和 write B
R2:只看到 write A
这并不违反因果一致性,只要 R2 没有看到依赖于 B 的操作,却看不到 B。
因此,因果一致性比线性一致性更容易通过异步复制实现,但它要求复制系统携带和检查依赖关系,不能简单地把“最终会同步”当作因果保证。
五、最终一致性:停止更新并完成传播后,副本最终收敛
1. 基本定义
最终一致性通常表达为:
如果系统停止对某个数据继续写入,并且所有副本之间的更新消息最终都能送达,那么这些副本最终会收敛到相同的值或等价状态。
可以抽象为:
- 副本集合:
- 某个时刻之后没有新的更新;
- 所有已产生的更新最终送达;
- 存在某个时刻 ,使得对任意 :
这里的“相同”必须结合冲突解决语义理解。例如:
- 最后写入获胜;
- 多值保留;
- 集合合并;
- 删除标记优先;
- 应用层合并。
2. 最终一致性不保证什么
最终一致性本身不保证:
- 写入返回后立即可读;
- 读到的新值不会再次变旧;
- 不同客户端看到相同顺序;
- 因果依赖不会倒置;
- 更新不会暂时丢失;
- 副本一定在某个固定时间内收敛;
- 冲突解决结果符合业务期望。
“最终”是一个收敛性质,不是延迟 SLA。若系统没有规定消息最终一定送达,或者节点持续故障,最终一致性甚至可能无法兑现。
3. 一个典型反例:读到新值后又读到旧值
初始状态:
x = 0
执行:
客户端 A 写入主副本:x = 1,返回成功
客户端 B 第一次读取副本 R1:返回 1
客户端 B 第二次读取副本 R2:返回 0
如果 R2 尚未追上 R1,这种结果在最终一致系统中可能被允许。只要之后 R2 最终收到 x=1,系统仍可能满足最终收敛。
但这违反了常见的“单调读”直觉。单调读不是最终一致性的必然组成部分,若业务需要,必须额外提供:
- 会话粘性;
- 版本或时间戳路由;
- 读修复;
- 最低可见版本;
- 因果令牌。
4. 一个典型反例:相关状态暂时分离
A:创建订单 order-1
B:读取订单后发布 order-created 事件
C:只从异步副本读取
如果事件副本已经同步,而订单查询副本尚未同步,C 可能看到:
事件:order-created(order-1)
订单:不存在
最终一致性不自动阻止这一结果。若业务要求“看到事件就一定能看到订单”,需要因果一致性、事务消息、单调版本读取,或者把相关数据放入同一具有更强语义的存储边界。
六、四种模型的关系
在常见的单对象、相同对象语义下,可以把它们粗略排列为:
这个关系表达的是“保证集合通常逐步变弱”,并不是说所有论文和产品都会用完全相同的定义。事务、批量操作、多对象读写和冲突解决策略可能改变模型之间的精确关系。
1. 线性一致性蕴含顺序一致性
线性一致性已经提供了一个尊重实时关系的全序。去掉“必须尊重跨客户端实时关系”这一更强要求后,自然仍然满足顺序一致性。
2. 顺序一致性通常强于因果一致性
顺序一致性要求所有客户端共享一个全局顺序。因果一致性只要求因果相关操作有序,并允许并发操作在不同副本上采用不同顺序。
因此,因果一致性可以容纳更多并发行为。
3. 因果一致性通常强于最终一致性
因果一致性要求传播过程中不发生因果倒置;最终一致性只要求在稳定后收敛。最终一致的系统可以暂时返回任意旧版本,甚至让用户观察到依赖关系反转,只要之后能够收敛。
4. 不要把层级当作所有产品的默认能力
一个数据库产品的宣传用语如“强一致”“最终一致”“多副本一致”,必须结合具体范围判断:
- 是单个键,还是整个事务?
- 是主库写入,还是副本读取?
- 是正常运行,还是故障转移期间?
- 是提交确认,还是应用确认?
- 是单分片,还是跨分片?
- 是普通读,还是带版本条件的读?
如果不明确这些边界,模型名称很容易被误用。
七、一致性模型与事务隔离级别不是一回事
这是数据库工程中最常见的混淆之一。
1. 一致性模型回答什么问题
一致性模型主要回答:
多个客户端、多个副本和多个操作之间,允许观察到怎样的全局行为?
例如:
- 写入返回后,另一个客户端是否必须立即读到?
- 两个副本是否必须以同一顺序展示并发写入?
- 读到派生结果后,是否必然能看到产生它的前置写入?
2. 事务隔离级别回答什么问题
事务隔离级别主要回答:
在一个数据库实例或事务系统中,并发事务可以互相观察到什么?
常见隔离级别包括:
- Read Uncommitted;
- Read Committed;
- Repeatable Read;
- Serializable。
它们讨论脏读、不可重复读、幻读、写偏差等问题。
3. 示例:主库上事务很强,副本读取仍然过时
假设主库上的事务执行:
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
COMMIT;
即使这个事务在主库上以 Serializable 方式提交,下面的请求仍可能读到旧值:
请求 1:连接主库,事务提交成功
请求 2:连接异步只读副本,查询余额,返回修改前的值
原因不是事务隔离失败,而是:
- 事务已经在主库完成;
- 复制日志尚未在副本应用;
- 请求 2 读取了另一个时间点的副本状态。
反过来,一个系统也可能通过同步复制让提交结果快速传播,但主库本地仍使用较弱的事务隔离级别。
4. PostgreSQL 和 MySQL 的默认语义边界
以常见稳定版本的公开语义为例:
- PostgreSQL 的默认事务隔离级别是 Read Committed;
- InnoDB 的默认事务隔离级别通常是 Repeatable Read;
- 这些是事务内的可见性规则,不等价于跨副本线性一致性;
- PostgreSQL 流复制和 MySQL Replication 的具体同步方式、提交等待方式、读路由和故障转移配置,决定了副本读取的实际一致性。
因此,不能因为数据库支持某个隔离级别,就直接声称整个分布式部署提供相同级别的一致性。
八、复制系统如何影响一致性模型
分布式数据库的可观察一致性,通常由以下路径共同决定:
客户端写入
↓
写入节点接收请求
↓
生成事务日志或复制日志
↓
日志发送到其他副本
↓
副本持久化日志
↓
副本应用日志、更新可读状态
↓
客户端读取被路由到某个副本
这条路径中的每个“确认点”都可能不同。
1. 只等待主节点确认
流程:
客户端 → 主节点写入
主节点本地提交
主节点返回成功
主节点 → 异步发送复制日志
这种方式通常具有较低写延迟,但客户端收到成功时,从节点可能完全不知道该更新。
如果后续读请求被路由到从节点,就可能出现:
写后读旧值
这通常只能支持某种最终一致或弱会话一致语义,不能仅凭主节点成功返回就宣称线性一致。
2. 等待日志复制,但不等待读取状态应用
流程:
主节点提交日志
↓
等待一个或多个副本持久化日志
↓
返回客户端成功
↓
副本稍后应用日志
这比只等待主节点更强,但仍需注意:
- 副本是否已经把日志应用到查询可见状态?
- 读请求是否一定访问这些已确认副本?
- 故障转移时是否只提升包含已确认日志的节点?
- 是否存在旧主节点继续接受写入的风险?
如果只保证日志持久化,不保证副本可读状态已经更新,那么读副本仍可能暂时读旧值。
3. 等待副本应用,并约束读路径
要接近线性一致性,至少需要同时考虑:
- 写入确认覆盖哪些副本;
- 副本是否已应用到可读状态;
- 读取是否访问包含该版本的节点;
- 主节点故障后,新的主节点是否包含已确认写入;
- 旧主节点是否被隔离,不能继续服务写请求。
最后一点是脑裂问题。即使复制延迟很小,如果旧主节点在网络分区期间继续接受写入,系统仍可能出现两个互相冲突的“主”。
4. 共识日志不自动等于线性化读
Raft、Paxos 等共识算法可以帮助多个节点就日志顺序达成一致,但数据库还需要正确实现:
- 日志提交条件;
- 状态机应用;
- 只读请求的安全处理;
- Leader 身份有效性;
- 成员变更;
- 故障转移后的纪元或任期;
- 旧 Leader 的隔离。
例如,Raft Leader 直接读取本地状态时,必须确认自己仍是当前有效 Leader,且本地状态已经包含所需提交日志。常见方法包括读屏障、ReadIndex 或可靠租约等。“日志使用 Raft 复制”不等于“所有读请求天然线性化”。
九、数据库中的完整场景:写主库、读副本
下面用一个与具体产品无关的复制拓扑说明问题:
主库 P
├── 异步复制 → 从库 R1
└── 异步复制 → 从库 R2
初始状态:
orders(100) = status='pending'
客户端执行:
请求 1:向 P 更新订单为 paid
请求 2:向 R1 查询订单状态
可能的时间线:
t0 P 执行 UPDATE
t1 P 提交事务
t2 P 返回成功
t3 客户端向 R1 发起 SELECT
t4 R1 尚未应用复制日志,返回 pending
t5 R1 应用日志,之后返回 paid
请求 2 在 t3 开始时,写入已经在 t2 返回成功。因此:
UPDATE 已完成
SELECT 后开始
SELECT 却返回旧值
该历史违反线性一致性。
1. SQL 形态
主库上的更新可能是:
UPDATE orders
SET status = 'paid'
WHERE order_id = 100;
副本上的查询可能是:
SELECT status
FROM orders
WHERE order_id = 100;
两条 SQL 都是普通合法操作;问题出在它们访问了不同复制进度的数据库状态,而不是 SQL 本身错误。
2. 业务层可选的修复方式
如果只需要解决某个会话的写后读,可以采用:
写请求成功后:
- 后续一段时间固定路由到主库;或
- 携带提交版本,读取时选择至少达到该版本的副本
如果要求所有客户端都满足线性一致性,则需要更强的系统保证:
写入确认、复制提交、读取路由、故障转移和脑裂隔离
必须作为一个整体设计。单独把“读主库”写进应用代码,可能解决正常运行时的写后读,却不能自动解决故障转移期间的安全性。
十、四种模型的对比算例
设初始状态为:
x = 0
场景 A:写入完成后读取旧值
A:write(x, 1) 完成
B:read(x) -> 0
可能的判断:
| 模型 | 是否允许 |
|---|---|
| 线性一致性 | 不允许 |
| 顺序一致性 | 可以允许 |
| 因果一致性 | 通常可以允许,A 与 B 没有建立因果依赖 |
| 最终一致性 | 可以允许 |
注意:如果 B 先读取到 A 的写入,之后又主动依赖这个结果发起操作,那么两者之间就可能建立因果关系,不能再简单视为独立操作。
场景 B:同一客户端写后读旧值
A:
1. write(x, 1) 完成
2. read(x) -> 0
如果没有其他写入,这通常违反:
- 线性一致性;
- 顺序一致性;
- 因果一致性中的程序顺序和读己之写(read-your-writes)直觉。
最终一致性仍可能暂时允许。
场景 C:因果链被拆开
A:write(x, 1)
B:read(x) -> 1
write(y, 1)
C:read(y) -> 1
read(x) -> 0
判断:
- 线性一致性:不允许;
- 顺序一致性:通常也无法构造合法全序;
- 因果一致性:不允许;
- 最终一致性:在收敛前可能允许。
场景 D:并发写入在不同副本顺序不同
A:write(x, "A")
B:write(x, "B")
A、B 互不依赖。
R1:先看到 A,再看到 B
R2:先看到 B,再看到 A
因果一致性可以允许,因为 A 和 B 并发;顺序一致性通常要求存在所有客户端共同认可的单一全局顺序,因此更严格。最终一致性也可能允许,前提是冲突最终能够按定义收敛。
十一、失败路径:一致性通常在哪里被破坏
1. 复制延迟
主库已经确认写入,副本的日志或状态尚未追上。
表现:
- 写后读旧值;
- 不同只读节点返回不同结果;
- 查询结果随路由变化;
- 延迟突然升高后旧值窗口变长。
2. 日志已到达,但尚未应用
副本可能已经接收到并持久化复制日志,但查询线程读取的状态仍未更新。
因此,“副本收到了日志”和“副本已经能够读到该版本”不是同一件事。
3. 读写路由不一致
写请求固定访问主库,读请求通过负载均衡随机访问副本:
写 → P
读 1 → R1
读 2 → R2
读 3 → P
同一个客户端可能观察到:
paid → pending → paid
这可能不是数据回滚,而是读取了不同复制进度的节点。
4. 故障转移提升了旧副本
主库 P 已向客户端返回成功,但该写入尚未复制到 R1。P 故障后,R1 被提升为新主库:
P:已经确认 x=1
R1:仍只有 x=0
P 故障
R1 成为新主库
客户端读取到 x=0
这会造成已确认写入丢失,从外部观察看,线性一致性和持久性都可能被破坏。
安全的故障转移通常需要额外保证:
- 新主库包含所有已确认写入;
- 旧主库不能重新接受写入;
- 采用任期、纪元或 fencing 机制隔离旧主;
- 复制状态和提交状态的定义一致。
5. 脑裂
网络分区导致两个节点都认为自己可以接受写入:
客户端 A → 节点 P:x=1
客户端 B → 节点 R:x=2
如果双方都返回成功,之后即使进行合并,也可能产生:
- 最后写入覆盖;
- 多值冲突;
- 应用层无法决定正确结果;
- 一个客户端看到
1,另一个看到2。
共识协议、仲裁机制和旧主隔离,解决的正是这类“多个节点同时宣称自己有效”的问题。
十二、如何验证一个系统到底提供什么一致性
不能只看产品名称或“同步复制”四个字。应围绕具体数据路径构造可观察测试。
1. 先明确测试对象
例如:
对象:orders 表中的同一订单
写入边界:主库事务提交
读取边界:任意被路由的查询节点
故障边界:主库故障后发生故障转移
目标:写入返回后,任何后续读取都不能返回旧状态
这个目标已经接近“该对象的线性一致性”。
2. 记录调用和返回时间
至少记录:
请求 ID
客户端 ID
事务 ID
节点 ID
调用时间
返回时间
返回值
复制位点或版本
然后检查是否存在:
op1 已返回
op2 才开始
op2 却返回了无法放在 op1 之后的结果
仅比较数据库最终状态不够,因为最终一致系统也可能最终得到正确结果,但中间历史仍违反线性一致性。
3. PostgreSQL 中观察复制滞后
在主库上,可观察当前 WAL 位置:
SELECT pg_current_wal_lsn();
在备用库上,可观察已经回放到的 WAL 位置:
SELECT pg_last_wal_replay_lsn();
如果备用库回放位置落后于主库产生的位置,说明它可能尚未包含某些更新。
需要注意:
- WAL 位置差异可以帮助诊断复制滞后;
- 它不能单独证明整个系统满足或不满足线性一致性;
- 还要确认读取是否访问该备用库;
- 还要确认日志已经回放为查询可见状态;
- 故障转移时还要检查新主库是否包含已确认事务。
4. MySQL 中观察复制状态
在 MySQL 副本上,可以使用:
SHOW REPLICA STATUS\G
需要关注复制线程状态、接收和应用进度以及错误信息。不同部署形态可能使用不同复制拓扑,不能只依赖某一个延迟字段得出“强一致”结论。
同样,这类命令主要用于诊断复制进度。它们不能替代端到端一致性测试,因为业务真正观察的是:
客户端提交成功
→ 路由
→ 副本状态
→ 查询结果
5. 用不变量测试业务要求
比测试“某次查询返回了什么”更有价值的是测试业务不变量,例如:
看到 payment_succeeded,就必须能看到对应 order
读取到版本 10 后,不允许再次读取版本 9
扣减成功后,库存不能在后续读取中增加回旧值
已确认的唯一 ID 不能在故障转移后消失
然后把每条不变量映射到模型:
| 业务不变量 | 可能需要的保证 |
|---|---|
| 写后读 | 线性一致,或会话级读己之写 |
| 结果不回退 | 单调读、会话一致性或线性一致 |
| 事件依赖主数据 | 因果一致性或原子消息事务 |
| 所有副本最终相同 | 最终收敛保证 |
| 全局唯一分配 | 通常需要线性化协调或等价的共识机制 |
十三、生产取舍:强一致性和弱一致性分别付出什么
1. 线性一致性的代价
线性一致性通常需要:
- 更严格的副本确认;
- 跨节点通信;
- 对多数派或权威节点的依赖;
- 读屏障或有效 Leader 检查;
- 故障时拒绝部分请求;
- 网络分区期间牺牲可用性或增加延迟。
在网络分区时,如果系统无法确认哪个节点拥有最新状态,却仍然允许两个分区都成功写入,就很难维持单一线性化历史。
CAP 定理中的“C”通常指接近线性一致性的强一致语义,而不是泛指所有数据库一致性模型。最终一致性或因果一致性不能被简单替换成 CAP 里的同一个 “C”。
2. 因果一致性的代价
因果一致性通常比线性一致性更有并发性和可用性,但需要:
- 传播因果依赖;
- 保存版本元数据;
- 读请求携带或获取依赖上下文;
- 确保依赖版本已经在目标副本可见;
- 处理并发冲突。
如果因果元数据规模随依赖关系增长,系统还需要压缩、截断或使用更粗粒度的版本表示,而这可能改变保证范围。
3. 最终一致性的代价和适用边界
最终一致性可以让副本异步传播,减少写入等待,但应用必须接受:
- 暂时读旧值;
- 多次读取结果不单调;
- 冲突需要合并;
- 删除和恢复存在传播窗口;
- 故障时可能出现较长的不一致期。
它适合对暂时旧数据可容忍、且能够处理冲突的场景,例如:
- 搜索索引;
- 缓存;
- 推荐结果;
- 分析报表;
- 非关键的多地域副本读取。
但“最终一致”不代表可以忽略业务约束。库存、余额、权限、唯一性和状态机迁移等场景通常需要更强的协调,或者把关键决策集中到提供强保证的边界内。
十四、常见误解
误解一:主库返回成功,就代表所有副本都一致
不一定。主库成功确认可能只表示:
- 本地日志写入;
- 本地事务提交;
- 某些副本持久化;
- 某些副本已经应用。
必须明确确认点。
误解二:同步复制就一定是线性一致
不一定。还要看:
- 同步的是日志还是可读状态;
- 读请求是否访问已确认副本;
- 读是否经过有效 Leader 检查;
- 故障转移是否会提升落后节点;
- 旧主是否会继续提供服务。
误解三:有全局递增 ID,就有全局顺序一致性
ID 只能表示分配顺序或编号顺序,不能自动证明:
- 写入已经在所有副本可见;
- 读取遵循该 ID 顺序;
- 事务提交顺序和 ID 顺序一致;
- 故障转移不会丢失或重排数据。
误解四:最终状态相同,就说明执行过程一致
最终一致性关注稳定后的收敛,但很多业务错误发生在收敛前:
先扣款,后查不到订单
先看到删除,后又看到旧数据
先看到下游事件,后看不到上游对象
这些中间观察结果必须单独评估。
误解五:Serializable 就解决了分布式一致性
Serializable 主要约束事务并发执行结果。若查询访问的是异步副本,或者故障转移丢失了已确认日志,Serializable 本身无法修复复制和路由问题。
结语:从“系统最终长什么样”转向“客户端能观察到什么历史”
理解分布式数据库一致性,核心不是背诵“强一致”“弱一致”这些标签,而是分析一段实际历史:
- 哪个客户端在什么时候调用了什么操作;
- 哪个节点接收并确认了操作;
- 哪些副本何时持久化、何时应用;
- 后续读取访问了哪个节点;
- 节点故障或网络分区时谁仍然可以接受请求;
- 最终是否存在一个满足对象语义的全局顺序,或者至少满足因果关系与收敛要求。
线性一致性提供单一、尊重实时关系的全局时间线;顺序一致性保留每个客户端的操作顺序,但允许违反跨客户端实时顺序;因果一致性只约束有依赖关系的操作;最终一致性则主要保证停止更新并完成传播后副本最终收敛。
工程上真正需要选择的不是抽象名称,而是业务不变量所要求的最小保证:关键协调使用更强的一致性,允许暂时旧读和可合并冲突的场景使用更弱模型,并把提交确认、复制、读路由和故障转移作为同一个端到端系统来验证。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:Elasticsearch 快照、恢复与升级:兼容、重建索引、灰度和回滚
- 下一篇:数据库共识基础:Raft、Paxos、Leader、日志复制和成员变更
- 延伸:数据库复制、分片与高可用:一致性、路由、故障转移和扩容
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论