| 等级 | 具体细分等级 |
|---|---|
| 强一致性 | 线性一致性(Linearizability consistency) |
| 强一致性 | 顺序一致性(Sequential consistency) |
| 弱一致性 | 因果一致性 (Causal consistency) |
| 弱一致性 | 最终一致性 (Eventual consistency) |
线性一致性(Linearizable Consistency)
线性一致性(Linearizability),又称原子一致性(Atomic Consistency)、严格一致性(Strict Consistency)或强一致性(Strong Consistency),是并发系统和分布式系统中关于单个对象操作正确性的核心模型。
其核心目标是让一个多副本的分布式系统,在外部观察起来,表现得就像只有一个数据副本一样,且每个操作都是操作都必须是 “瞬时” 生效且 “原子” 的(存在可线性化点),系统中所有进程看到的全局操作顺序和真实世界的发生时间顺序一致。
可线性化点(Linearization Point):指的是一个操作在其调用开始和响应返回之间的某个时间点“瞬间生效”。在这个点之前,操作的效果对系统其他部分不可见;在这个点之后,操作的效果对所有后续操作立即可见。可以将其想象为操作被提交到全局时间线上的一个不可分割的点
实现路径
- 单主复制:从主节点或者同步更新的从节点上读取,可能满足
- 共识算法 :如 Raft, Paxos。在介绍完顺序一致性后,我们会同时详细介绍共识
- 基于时钟的协议:Google Spanner 利用具有有界误差的全局时钟(TrueTime API)为事务分配时间戳,并通过等待误差时间(Commit Wait)来保证操作的全局顺序
顺序一致性(Sequential Consistency)
顺序一致性是 Lamport(1979)在解决多处理器系统共享存储器时首次提出来的
- 任何一次读写操作都是按照某种特定的顺序
- 所有进程看到的读写操作顺序都保持一致
那么线性一致性和顺序一致性的区别在哪里呢?顺序一致性虽然通过逻辑时钟保证所有进程保持一致的读写操作顺序,但这些读写操作的顺序跟实际上发生的顺序并不一定一致。而线性一致性是严格保证跟实际发生的顺序一致的。
偏序和全序
简单的说,偏序的意思是说集合中的元素是部分有序的,而全序的意思是集合中任意一对元素都是可以相互比较的,可以完全排序。下面是一些例子 :
- 自然数的集合是全序
- 整数的集合是全序
- 复数的集合是偏序,1和100i是无法比较的,没有意义
Lamport 逻辑时钟
既然物理时钟不可靠,那就人为构造一个递增的序列来为事件排序,这就是Lamport逻辑时钟的基本思想。
Happens-Before 关系:
Lamport逻辑时钟的基石是 “happens-before”关系(记为 ),它定义了事件的偏序关系,其规则如下 :
- 同一进程内部:如果事件a和b在同一个进程内发生,且a在b之前执行,则
- 跨进程消息传递:如果事件a是进程发送一条消息的事件,而事件b是另一个进程接收这条消息的事件,则
- 传递性:如果 且 ,那么
如果两个事件之间无法根据以上规则建立 happens-before 关系,则称这两个事件是并发的(concurrent)
逻辑时钟的更新规则:
分布式系统中每个进程保存一个本地逻辑时钟值, 表示进程发生事件a时的逻辑时钟值,的更新算法如下:
- 进程每发生一次事件,加1。
- 进程给进程发送消息,本地执行一个事件(将 加 1),将这个新的时间戳包含在消息中一起发送出去
- 进程接收消息,更新为 。
从以上算法可以很容易地得出下面两个结论:
- 同一个进程内的两个事件a和b,如果 ,那么
- a是进程的消息发送事件,b是进程该消息的接收事件,那么
所以:对于任意两个事件a和b,如果 ,那么
但反过来如果,由于并发的存在,并不能说明,反向的推论并不成立。也就是说是 的必要不充分条件
Lamport逻辑时钟
整个事件集合中只有因果关系(蓝色部分)可以比较大小、并发关系(红色部分)的大小无意义,所以我们说Lamport逻辑时钟构造的是偏序关系
对于来说,也就是蓝色部分全部都是。但可能是因果关系(蓝色部分)也有可能是并发关系(红色部分),所以我们说是的必要条件
全序事件集
Lamport逻辑时钟算法构造的整个事件集合 (happened before →) 是一种偏序关系,我们加入另外一个条件也就是判断两个进程号的大小,把Lamport逻辑时钟中不能比较大小的事件变成可以比较大小,从而使整个事件集合变成一种全序关系
定义全序关系 如下:进程的事件a和进程的事件b如果满足下面两个关系中的任何一个,则称 。
- (事件a的Lamport时间戳小于事件b的)
- 并且
全序关系 把偏序关系 变成了任何两个元素都可比较的全序关系,而且有 ,则 。
这个全序关系的关键在于,它为系统中任何两个事件都定义了一个明确的先后顺序,即使它们是并发的
- 与偏序关系兼容:这个全序关系“扩展”了原有的偏序关系。如果事件a在偏序上“happened before”事件b(),那么在全序中a也一定在b之前()。它不会破坏已有的因果关系
- 顺序的任意性:物理时间上真正先发生的事件,在全序中可能会被排在后发生的事件之后。这可能导致不公平但正确的结果
再次强调:注意全序关系只是明确了先后顺序,是不能描述事件的因果关系的,不能推导出(只能推导出),即使知道了两个逻辑时钟值,也无法区分因果事件和并发事件,因为全序关系可能将本无因果的并发事件强制排序。
全序关系广播
抛开Lamport时钟来聊一个更高级别的抽象:全序关系广播
全序关系广播,也被称为原子广播(Atomic Broadcast),在分布式系统中,如果多个节点需要达成一致,仅仅保证消息发到了是不够的,最难的是保证所有节点接收消息的顺序一模一样
全序广播必须满足两个核心性质:
- 可靠传递: 如果消息被发给了一个节点,它最终必须被所有正常的节点接收
- 全序属性: 如果节点 A 先处理消息 再处理 ,那么系统中任何其他节点也必须先处理 再处理
全序广播保证了所有节点以相同的顺序接收消息,这直接导致了顺序一致性。
在全序广播的基础上,再加上「读操作必须能看到最新的写」这一约束(例如读也走一次共识,或者通过 Leader 租约确认),升级到了线性一致性
如何实现全序广播?
实现全序广播主要有以下几种常见策略:
A. 令牌传递 (Token Passing)。 谁拿到令牌,谁就有权决定下一个共识值。节点收到带序号的消息后,直接按序投递。工程挑战:令牌丢失, 如果持有令牌的节点突然宕机,令牌就消失了。节点加入/退出, 逻辑环的维护非常复杂。早期有一些基于令牌的容错协议(如 Totem),通过极其复杂的重组机制来找回丢失的令牌,但在现代大规模集群中几乎见不到了
B. 基于通信历史/逻辑时钟 (Communication History / Logical Clocks)。不需要 Leader,每个节点根据自己的逻辑时钟给提案打分,通过两轮交互取最大值作为最终序号
C. 基于定序器 (Sequencer-based)。简单来说就是由Leader 给每个消息分配一个全局递增的序列号。优点: 简单,延迟低。工程挑战: Leader是单点故障,所以如果 Leader 挂了,需要复杂的选举机制来选新 Leader 并恢复数据。这也是Raft/Paxos的实现策略
虽然在分布式理论课上,Lamport 时钟经常被用来演示如何实现全序,但在高性能的工业级分布式协调系统中,它们被认为太弱或太重了。工程上更偏爱基于定序器的共识。ZooKeeper和etcd使用了更强力、更直接的机制:基于领导者的定序器(Leader-based Sequencer),配合逻辑序列号来实现全序,在定序器的基础上增加了选举逻辑和日志恢复逻辑
共识
在分布式理论中,全序广播和共识(Consensus) 是等价的问题。任何能实现全序广播的机制,在数学上都能推导出共识。
- 共识是让节点就“某一个值”达成一致
- 全序关系广播可以看作是一系列的共识实例:节点们需要对“第1条消息是什么”、“第2条消息是什么”……达成一致
如果你解决了一个问题,你就自动解决了另一个问题。现代的许多共识算法(如 Paxos, Raft, ZAB)本质上就是在实现全序广播(通常通过复制日志 Log Replication 的形式)
ZooKeeper和etcd通过各自的共识协议的实现了线性一致性的写操作。共识协议保证所有节点最终都会按相同的顺序达到相同的状态,但由于网络延迟,Follower可能还没收到最新的日志,客户端读取到旧数据,所以系统只保证了顺序一致性读。为了达到线性一致性读,etcd 和 ZooKeeper 必须采取特殊手段:
- Read Index / Lease Read (etcd/Raft):读请求必须询问 Leader。Leader 会确认自己是否还是合法的领导者,并确保读到的数据不早于当前的 Commit Index
- Sync + Read (ZooKeeper):ZooKeeper 默认提供的是“单调写”和“顺序读”。但如果你在读之前调用
sync()接口,它会强制同步进度,从而逼近线性一致性
因果一致性(Causal Consistency)
因果一致性是一种弱化的顺序一致性模型,它旨在保证操作之间因果关系的正确顺序,同时允许无因果关系的并发操作以不同顺序被观察到,从而在强一致性的严格性和最终一致性的宽松性之间取得了良好的平衡
因果一致性的条件包括:
- 所有进程必须以相同的顺序看到具有因果关系的读写操作
- 不同进程可以以不同的顺序看到并发的读写操作
顺序一致性虽然不保证事件发生的顺序跟实际发生的保持一致,但是它能够保证所有进程看到的读写操作顺序是一样的。而因果一致性更进一步弱化了顺序一致性中对读写操作顺序的约束,仅保证有因果关系的读写操作有序,没有因果关系的读写操作(并发事件)则不做保证。也就是说如果是无因果关系的数据操作不同进程看到的值是有可能是不一样,而有因果关系的数据操作不同进程看到的值保证是一样的。
向量时钟
实现因果一致性的核心技术是向量时钟 (Vector Clock)。向量时钟是1988年由Colin Fidge和Friedemann Mattern提出的,比Lamport提出逻辑时钟晚了刚好十年。
Lamport逻辑时钟存在的问题是不能描述事件的因果关系,不能推导出,这样导致即使知道了两个逻辑时钟值,但却不能确定这两个事件的因果关系。
向量时钟可以解决这两个问题,它的思想是进程间通信的时候,不光同步本进程的时钟值,还同步自己知道的其他进程的时钟值。
| 特性 | 向量时钟 | Lamport 逻辑时钟 |
|---|---|---|
| 核心思想 | 每个节点维护一个向量,记录所有节点已知的事件计数 | 每个节点维护一个单一的、全局递增的计数器 |
| 时序信息 | 多维向量,记录部分时序信息 | 单一时钟值,建立全序关系 |
| 因果关系判断 | 充分必要条件: | 必要条件: |
| 并发事件识别 | 可以精确识别。如果两个向量时钟不可比较,则事件并发 | 无法准确识别。时钟值大小不能确定事件是因果还是并发 |
向量时钟算法
分布式系统中每个进程保存一个本地逻辑时钟向量值,向量的长度是分布式系统中进程的总个数。 表示进程知道的进程的本地逻辑时钟值,的更新算法如下:
-
初始化:初始化的值全为0:。例如,对于节点 A、B、C:,,
-
本地事件发生:进程每发生一次事件,加1。例如,A发生事件,
-
发送消息:进程给进程发送消息,它首先会执行上一步(递增自己的分量),然后将自己的整个向量时钟附加到消息中一并发送出去。例如,A发送消息给B,先加自己的向量[2,0,0],然后发送出去
-
进程接收消息,需要做两步操作
4.1 对于向量中的每个值,更新为 ,这一步是为了获取进程目前所知的所有进程的最新事件计数。所以
4.2 将中自己对应的时钟值加1,即加1,表示处理了一次接收事件。此时
定义向量时钟之间的大小关系
- 如果所有分量都相等定义为
- 如果 中每一个分量都小于或等于中对应的分量,并且至少有一个分量是严格小于的,则认为
- 如果 中每一个分量都大于或等于 中对应的分量,并且至少有一个分量是严格大于的,则认为
- 如果两个向量中存在一些分量 大,另一些分量 大(即无法用上述大小关系比较)。这表示这两个事件是并发的,记作
我们可以得出这样的结论:
-
向量时钟之间的大小关系是一种偏序关系
-
向量有序,则事件有序(充要):
-
向量平行,则事件并发(充要):
以下是向量有序是事件有序得充分必要条件证明过程
证明1:
- 同一个进程内的两个事件a和b,如果 ,那么
- a是Pi进程的消息发送事件,b是进程的消息接收事件,那么
所以对于任意两个事件a和b,如果 ,那么
证明2:
如果 a和b两个事件处在同一个进程中,显而易见
如果 a和b两个事件处在两个进程中,一定只能是以下几种情况,全部都是
向量时钟
整个事件集合中只有因果关系(蓝色部分)可以比较大小、并发关系(红色部分)的大小无法比较,所以我们说向量时钟构造的是偏序关系
对于来说, 一定,即只位于蓝色部分,红色部分的大小无法比较。所以我们说是的充分必要条件
向量时钟的应用
微信朋友圈不同地域的用户可能在不同的服务器上,微信朋友圈的跨地域数据同步就借鉴了因果一致性的思想,以确保评论和回复能以正确的顺序呈现在全球所有用户面前
上海小王发了一个风景图片到朋友圈,香港Mary看到后评论了问“这是哪”,上海小王回复评论“梅里雪山”。由于网络原因,对评论的回复先同步到了加拿大的服务器,评论后到,导致加拿大Kate先看到回答“梅里雪山”,后看到提问“这是哪”。
使用向量时钟,,因此 ,加拿大的服务器知道c发生在e前面,可以对评论和回复正确排序后显示。
向量时钟的不足
-
只考虑了固定数量的节点,没有考虑节点的动态添加和销毁。
现代解决方案: 区间树时钟 - Interval Tree Clocks
-
假设节点数量是N ,那么每个节点需要维护的空间复杂度是 𝑂(𝑁)。 通信的信息量的复杂度也是 𝑂(𝑁)
2019年新鲜出炉的一个寻求优化时钟空间的算法,布隆时钟 - Bloom Clocks
混合逻辑时钟
混合逻辑时钟(Hybrid Logical Clock, HLC)是一种巧妙融合物理时钟和逻辑时钟优势的算法,旨在为分布式系统中的事件提供既能反映因果关系、又与物理时间保持接近的时间戳。它由 Sandeep Kulkarni 等人在2014年的论文《Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases》中提出
HLC的算法
在 HLC 中,每一个节点(或进程) 维护一个时间戳状态,这个状态由两部分组成:
- :逻辑物理时间。代表当前节点已知的最大物理时钟值(不一定来自本地)
- :逻辑计数器。当多个事件的 部分相同时,用于在逻辑上区分其先后顺序
此外,我们还有一个本地的物理时钟源:
- :节点 当前的系统物理时间
其核心算法在于如何在不同操作下更新这个 对
发送事件或本地事件
当发生一个本地事件或准备发送消息时,它需要生成一个新的时间戳。算法的核心思想是:尽可能使用当前的物理时间,除非物理时间发生了回退或落后于之前的逻辑时间
- 获取当前物理时间
- 更新 。这一步确保 部分总能跟踪到最新的物理时间。
- 更新
- 如果新的 等于之前的 (即物理时间没有超越),则 。这表示在同一个物理时间片内发生了新事件
- 如果新的 大于之前的 (即物理时间发生了跃升),则 。这表示物理时间已经推进,逻辑计数器可以重置
接收事件(处理消息 )
当节点 收到一条消息 ,该消息携带的时间戳为 。节点 需要更新自己的时钟,以满足因果关系(即自己的时间必须晚于消息的时间)
- 获取当前当前 ,消息的 、当前物理时间
- 更新 。这一步确保本地的 部分能感知到来自其他节点的最新物理时间。
- 根据 是由谁决定的,来更新计数器
- 如果三者中相等:增加逻辑时钟部分
- 如果是 赢了(本地HLC时间最大):延续本地计数,
- 如果是 赢了(消息时间最大):跟随消息计数,
- 如果是 赢了(本地物理时间最大):重置计数,
HLC的应用
1、因果一致性
这是 HLC 最本质的数学属性。HLC 旨在捕捉分布式系统中事件的“发生先于”(Happens-Before, )关系。如果事件 发生在事件 之前(即 ),那么 一定成立
2、快照隔离/一致性快照
这是 HLC 在工程实践中(如 CockRoachDB、HBase)最主要的应用目标
最终一致性(Eventual Consistency)
最终一致性是更加弱化的一致性模型,因果一致性起码还保证了有因果关系的数据不同进程读取到的值保证是一样的,而最终一致性只保证所有副本的数据最终在某个时刻会保持一致,但不保证中间状态的顺序。理论上,可能出现非常混乱的中间状态
由于最终一致性对数据一致性的要求比较低,在对性能要求高的场景中是经常使用的一致性模型
为什么说MySQL的异步复制 (默认)是最终一致性?
| 方案 | 机制 | 一致性类型 | 优点 | 缺点 |
|---|---|---|---|---|
| 异步复制 (默认) | Master 写完 Binlog 即返回,不管 Slave | 最终一致性 | 性能最高,主库不被拖累。 | 读从库可能有延迟;Master 宕机可能丢数据。 |
| 半同步复制 (Semi-Sync) | Master 至少等待一个 Slave 收到 Binlog (写入 Relay Log) 才返回 | 增强的最终一致性 | 数据安全性更高,不易丢数据。 | 性能有损耗;Slave 依然可能存在回放延迟(读仍可能不一致)。 |
| 全同步/组复制 (MGR) | 所有节点协商一致才提交 | 强一致性 (接近) | 数据一致性极高。 | 性能最差,受限于最慢的节点,架构复杂。 |
首先对比ZooKeeper,Zookeeper中针对同一个Follower A提交的写请求R1,R2,某些Follower可能读取到旧数据。既然数据同步需要时间,Follower 上的数据会滞后(Stale),那不就是「最终」才一致吗?但ZooKeeper文档中写明它是顺序一致性读
之所以会产生最终一致性的错觉,是因为ZooKeeper的一致性模型中存在实时性(Timeliness)的缺失
- 线性一致性: 写入成功后,所有节得都读到最新值
- ZK 的读: ZK 默认读不满足线性一致性,因为允许部分客户端读 Follower 读到旧数据。官方称之为“Sync-Linearizable”,即如果你需要读到最新数据,需要先发一个
sync()命令
因为允许读到旧数据(滞后),所以给人的感觉是“最终才会一致”。但在分布式理论中,滞后(Lag) 和 乱序(Out of order) 是两个完全不同的性质
- 允许滞后 + 严格有序 = 顺序一致性 (Sequential)
- 允许滞后 + 允许乱序 = 最终一致性 (Eventual)
顺序一致性的定义非常严格,它要求系统满足两个条件:
- 内部有序: 单个处理器的操作顺序符合程序顺序
- 全局单一视图: 所有客户端看到的操作执行顺序必须是一致的
ZooKeeper 的顺序一致性保证了什么: ZooKeeper 保证了“客户端视角的全局有序”,注意是客户端视角
- 内部有序: 即使 Follower 滞后,它也必须严格按照 Leader 的 zxid(事务ID)顺序来应用日志。如果 Leader 发出 R1然后 R2,Follower 决不可能先应用 R2。它要么停在 R0(旧数据),要么更新到 R1,要么更新到 R2。滞后是可以的,但乱序是不行的
- 全局单一视图: ZK 保证单一客户端的单调读(Monotonic Reads)。如果你在 Follower A 读到了版本R1的数据,当你切到 Follower B 时,ZK的机制确保你不会读到版本R1的数据(如果 Follower B 还没同步到R2,客户端的请求会被阻塞或需要处理,具体取决于实现细节,但逻辑视图上不会回退)
ZK 保证了: 只要你看见过新世界,我就绝不让你再退回旧世界
ZooKeeper 如何做到的?ZooKeeper 在客户端会话(Session)层面做了手脚:
- ZXID 检查机制: 当 Client 连上 ZK 的 Follower A 时,Follower A 会告诉 Client:“我现在的数据版本是 ZXID=100”。Client 会在本地记下“我看过 ZXID 100 了”。
- 拒绝“时光倒流”: 如果 Client 断开,重连到滞后的 Follower B(ZXID=80)。Follower B 会检查 Client 的握手包,发现 Client 见过 100,而自己只有 80。
- 处理结果: Follower B 要么拒绝服务,要么阻塞直到自己追上 100,要么 Client 被路由到其他节点。
MySQL无法满足全局单一视图。MySQL的异步复制确实保证了内部操作顺序,但它缺乏防止“客户端视角的全局有序”的机制,因此只能算最终一致性
假设架构是:1 Master + 2 Slaves (Slave A 快, Slave B 慢)。 初始值 x = 0。
- Master 写入
x = 1。 - Slave A 同步完成,变更为
x = 1。 - Slave B 延迟了,还是
x = 0。
如果是顺序一致性(如 ZK),系统必须保证: 一旦客户端(比如 Client 1)读到了 x = 1,那么客户端Client 1以后即使切换了Follower都不会再读到 x = 0
但在 MySQL 异步复制中发生了什么?
- 时刻 T1: Client 1 连上 Slave A,读到
x = 1(用户觉得:哦,数据更新了) - 时刻 T2: Client 1 刷新页面,负载均衡将请求切到了 Slave B
- 时刻 T3: Client 1 读到
x = 0
在多节点切换访问时,对于 Client 1 来说,他先看到了新数据,后看到了旧数据。数据的版本发生了回退。 这种情况破坏了单调读(Monotonic Reads),也破坏了顺序一致性要求的全局单一视图。在客户端看来,这个系统的行为是混乱的,而不是顺序的,因此只能算最终一致性
✨ 微信公众号【凉凉的知识库】同步更新,欢迎关注获取最新最有用的知识 ✨