关于一致性,你该知道的事儿(上)
- 前言
- 一、缓存一致性
- 二、内存模型一致性
- 三、事务一致性
- 四、分布式事务一致性
- 4.1 分布式系统的一些挑战
- 4.2 关于副本的一些概念
- 4.3 分布式事务之共识问题
- 4. 3.1 PC(two-phase commit, 2PC)
- 4.3.2 Raft
- 三、后记
- 参考
前言
程序员最重要的一个工作就是把现实的需求场景进行建模,然后翻译成符合逻辑的、具有一致性规则的机器语言(让机器能认识,然后执行)。 那一个个存储在各种介质(各种缓存、内存、磁盘等)中的对象要在各种触发、输入条件下(比如说用户请求)符合一致性原则(当然根据不同的需求场景有不同的一致性要求)。
比如说,从A账户往B账户转账,那么转账之前AB账户的总额要和转账之后AB账户的总额一样,这就是逻辑,这就是一致性。
学了这么多年计算机理论,也写了好几年代码,经常会遇到一些关于一致性方面的问题,什么atomic、volatile、lock、屏障、顺序一致性、事务、Raft、2PC等等概念,有的时候分不清他们属于哪个层次,哪个部分的内容。这篇文章理理一致性的相关内容,把这些概念归归类。人言有云,摆正位置才能看得清楚,理的透彻。
注意:本篇是简要讨论归纳,因为每一部分内容单独抽出来都可以写篇大作,这里就点到为止。
一、缓存一致性
我们知道现代的cpu架构中,cpu和内存Memory一般不是直接相连的,为了缓解cpu和内存将近百倍的速度差距,在他们之间还会有多级缓存Cache。cpu在读写数据时首先会操作cache(实际上中间可能还会有Store Buffers等结构来进一步提升性能),然后才是Memory。 如下图所示:
简要架构如下图所示。
既然数据被保存在了多个地方(多个cpu专属的cache和共用的memory)中,形成了多个副本,那么就会在读写的时候就会出现一致性的问题了。
以哪个为准? 哪个是有效的数据?如果cpu都要写某个地址,然后冲突了怎么办?
听起来很复杂,很难搞,相信我,缓存和内存之间的数据一致性还不是最难的,一般来说,我们至少可以保证Cache和memory不会突然宕机不工作,cpu和cache、memory之间不会因为通信不畅而超时(分布式系统就不一定了)。
为了维护cache数据的一致性,需要一定的标准和协议来规范各个cpu的读写,比如说MESI协议。
简要来说,MESI协议是通过在Cahce中(针对具体的某个地址,也叫做cacheLine)加一些特殊标记,来标识当前cache中数据的状态(是有效的还是无效的?是已经被修改了,还是未被修改的?是独享的还是共用的?)。然后各个cpu在读写的时候需要和其他的cpu进行消息通信,然后来共同维护缓存一致的状态。
几个主要的状态如下所述:
- M(modified): 表示该地址刚被修改过,而且没有出现在其他的cpu cache中,当前cpu是唯一拥有者。
- E(exclusive): 表示该地址未被修改,也就是说和内存是一致的。如果对其修改后就转变为M状态。
- S(shared): 表示该地址中的内容被其他cpu cache所共享,也就是说某些另外的cpu cache中也含有此地址的数据。因此该cpu不能直接修改,需要和其他cpu进行通信。
- I(invalid): 表示该cache 地址是无效的。
CPU通信的消息主要有以下几种:
- Read: CPU发起读取数据请求,请求中包含需要读取的数据地址。
- Read Response: 作为Read消息的响应,该消息可能是内存响应的,也可能是某CPU响应的(比如该地址在某CPU Cache 中为Modified状态,该CPU必须返回该地址的最新数据)。
- Invalidate: 该消息包含需要失效的地址,所有的其它CPU需要将对应Cache置为Invalid状态
- Invalidate Ack: 收到Invalidate消息的CPU在将对应Cache置为Invalid后,返回Invalid Ack
- Read Invalidate: 相当于Read消息+Invalidate消息,即取得数据并且独占它,将收到一个Read Response和所有其它CPU的Invalid Ack
- Writeback: 写回消息,即将状态为Modified的cache 地址写回到内存,通常在该地址将被替换时使用。
看到上面的状态和cpu通信的消息,协议运行的机制也大概猜到七七八八了,总的来说就是cpu要想修改某个地址,得获取到独占标志位;然后读取的时候要通过和其他cpu交流的方式保证读取到的是最新的。具体的转换过程这里就不赘述了。
仔细品品这种加标志位和cpu之间消息通信的方式,或许还能品出点加锁的味道。
二、内存模型一致性
上面的缓存一致性说的是针对单个变量(地址),cpu 缓存和内存的一致性关系。没有考虑多个变量地址之间的联系,没有考虑cpu在读写一系列变量情况下的规范和顺序。
听起来可能有点懵?
cpu读写变量地址,不就是按照代码编写的顺序,一个一个执行的吗?难道下面的代码不是先操作变量a,再操作变量b?
void test()
{
a = 1;
b = 1;
}
其实还真不一定。从普通开发人员的 视角,我们认为a和b是按照顺序来依次赋值执行的。但是为了提高执行能指令性能,我们的cpu和编译器是会优化执行顺序的(通过指令重排、指令移除、流水线等)。 一般来说,编译器和cpu在优化的时候都只保证了优化后的代码在单线程执行时和原有代码的逻辑保持一致。 所以从全局的角度(或者并发程序的角度)来看,a和b的执行顺序是不能保证的。
cpu和编译器在做指令优化的时候是按照较为底层的读写顺序来进行优化的(比如说上面的例子,在其他地方先读取了b,那么有可能cpu下一次就会先操作b)。它们无法再业务层来理解a和b之间的关系,有可能我们由于并发考虑就是需要a先处理,b再处理的逻辑, 那这怎么办呢?
内存一致性就是来说明这个的,它规定了程序员和系统之间的一种契约(或者一种规范),如果程序员遵循内存操作规则,内存将是一致的,读取、写入或更新内存的结果将是可预测的。
内存模型还分为CPU处理器层面和编程语言层面,前者主要指的是多核处理器对并发的内存操作进行排序和执行的规范,其主要目标是保持指令性能优化的同时不会影响到正确性;而编程语言层面的一致性在语言层面定义了一些并发的同步语义和内存可见性规则。
处理器层面和编程语言层面的内存一致性都比较复杂(处理器层面层面的更复杂),篇幅和能力所限,这里就不详述了,感兴趣的可以参见【5】【6】【7】【8】。但是总的原则是一致性越高的系统需要更严格的限制,性能优化程度也就越低。 对于上层应用的程序开发者来说,在编写并发程序时如果要使用底层的同步机制时(如atomic、volatile等),还是要小心对待。
三、事务一致性
我们知道对于传统的支持事务的数据库系统来说,ACID是标配的几个特性(当然,确切的来说很少有数据库系统能够提供完整的ACID的功能,大部因为于性能和复杂性考虑提供了不同的阉割版本,但是对于很多应用场景是足够的)。
细究的话,A(原子性)、I(隔离性)、D(持久性)都是为C(一致性)服务的,也就是说数据库提供AID几个特性是为了更好在数据库层面提供一致性保证。 比如说,原子性是为了一系列有关联的操作(事务中的操作)要么全做,要么全不做,数据库呈现出来的状态不能是做了一半的结果,做了一半的结果肯定是不符合一致性的。
举个例子,A向B转账:A=A-100, B=B+100。 如果只完成了第一步然后数据库宕机导致第二部没完成,估计用于就要骂娘了。
再比如说隔离性,它是为了保证在并发条件下,数据库要提供一定程度上的原则(读-已提交、可重复读、可串行化)。举个例子说,A要读取数据D1和D2,B要写入数据D1和D2,如果A和B并发发起请求。如果没有数据库提供的隔离原则,A可能会读到D1版本的旧数据和D2版本的新数据。这在某些应用场景可能就不符合一致性原则。
具体有关ACID的内容,以前有写过相关的内容,这里就不赘述了,想了解的可以参见【2】【3】
四、分布式事务一致性
4.1 分布式系统的一些挑战
谈有关分布式一致性的问题之前,首先我们先来谈谈分布式系统中会遇到哪些棘手的问题呢?
分布式系统一般是由分布在不同位置的独立节点组成,他们之间是相互独立的个体,所有的信息交流基本都是通过网络之间的通信来实现的。对于整个系统来说,除了单个节点运行时可能需要的各种问题(内存硬件故障、机器宕机、程序崩溃等)外,节点之间还会遇到各种更复杂的问题:
- 与合作节点失联。 如果偶然间某个时刻与合作的节点失联了,你也不清楚到底是因为网络的问题(网络中断)还是节点终端的问题(节点挂了或者节点负载太高导致无法响应请求)。
- 在由多个节点组成的系统之中,每个节点自身只能以它获取到的信息来进行判断。但是这在整个系统的维度下可能并不是准确的。整个系统需要对某个信息达成共识,不能A认为信息(比如说系统的状态、数据等)是什么样就是什么样。共识,是一个问题。
- 时钟不同步。在绝大多数互联网系统中,节点的时间都不可能做到完完全全的同步,因此想用时间戳来进行事件先后的判断可以,但是存在不靠谱的可能。
4.2 关于副本的一些概念
所谓副本就是“分身”、“拷贝”,有的时候一个副本“忙不过来”,就会复制多个副本同时“忙”,同时干活工作。 但是复制是需要时间的,也是需要成本的,因此副本之间很多时候也不是完全同步的,这很多时候就带来了一致性问题。
一般来说,系统会采用两种形式的副本方式:
(1)一个是缓存形式的副本,利用不同介质的不同访问速度(或者不同访问形式)来提高程序的读写性能,典型的如cpu内的多级缓存-> 内存->硬盘(如上文中所述的缓存一致性)。 再如对于不同的访问形式,本地内存-> redis缓存->数据库系统等多级数据架构(这一般在高性能的应用层用的较多), 这算是一种异构类型的副本。
(2)还有一种是数据库系统多采用的数据的多个拷贝,在以前【分布式技术之复制】章节有描述。这种主要是为了提供系统的可用性,一个副本不可用,其他副本可以顶上去。这算是一种同构类型的副本。
4.3 分布式事务之共识问题
这里主要讨论上述的第二种副本形式注意,下面讨论的基本假设是各个节点之间都可以接受读写请求,并不一定是主从复制那种(虽然最后的其中一个目的是达到主从复制的效果)
如我们在【4】中讨论的那样,副本的复制滞后对一致性有重大的影响。对于很多应用来说,有点延时带来的影响不大,不影响主体功能和业务逻辑。但是对于一些要求高的场景来说,需要提供强一致性的数据库系统,否则会给系统带来重大影响(比如说对于很多协调服务来说,需要每个协调服务集群对系统的元数据保持一致的共识,常见的比如说Zookeeper)。
所谓多个副本的强一致性,通俗的理解就是系统看起来就像一个副本一样,所有的操作都是原子性的(这里原子性更多的侧重于多个副本对于某项写请求要么都做,要么都不做这种表现)。 那么如何实现多个副本间的强一致性呢?
你想,每个副本都可以接受客户端的请求,“你说一句,我说一句”, 不冲突还好,一旦冲突了怎么办呢?
大家说话都算数,那就乱了套了。如果要有一个leader,让leader来决定整个系统的写入顺序,然后通知给大家。看起来不错,这就是我们前面说的主从复制。
不错,但是这里有两个问题,一个是前面说的复制延迟的问题;另一个是 “ 谁是leader”也是个问题,选举谁当leader呢?
千言万语汇成一句话,“多个副本之间如何达成一个共识”
所谓共识,简单来说是多个节点就某件事情达成一致。这里的一致也就从某种程度上隐含了一致性的意思。
一个基本的实现思路是: 提议(proposal)。 每个接受客户请求的副本节点在准备处理时向整个系统发出“提议”请求(一般是发送请求给其他所有的副本节点)。 当其他节点返回同意(或者大部分同意)之后(说明其他节点随后也会执行相同的操作),即可执行实际的请求。
这里有一个问题,如果其他副本节点一开始同意,但是后面由于某种原因没执行或者反悔了(比如说其他节点执行的之后宕机了,或者需要回滚) 那对于原先提议的副本节点来说,该怎么处理呢?(说好的一起走,半路却偷偷掉头?)
enen, 确实有点难搞了。所以,协议相对复杂了起来。
目前有一些常用的共识算法,如2PC、Paxos、Raft、ZAB等协议可以解决上述问题。下面简单介绍其中的几种。
4. 3.1 PC(two-phase commit, 2PC)
2PC协议,也叫两阶段提交协议算法,是一种在多个节点之间实现事务原子提交的算法,它用来确保所有的节点要么全部提交,要么全部中止。 基本流程如下图所示:
上述的协调者,一般运行于请求事务的进程中,也可以独立出来作为一个单独的组件。主要作用是用来协调所有的参与节点(上图中的数据库节点)。
基本的流程原理如下所示【摘自DDIA】:
1. 当应用程序启动一个分布式事务时 ,它首先向协调者请求事务 ID 。该ID全局唯一
2. 应用程序在每个参与节点上执行单节点事务,并将全局唯一事务 ID附加到事务上。此时,读写都是在单节点内完成。如果在这个阶段出现问题(例如节点崩溃或请求超时),则协调者和其他参与者都可以安全中止。
3. 当应用程序准备提交时 ,协调者向所有参与者发送准备请求,并附带全局事务ID 。如果准备请求有任何一个发生失败或者超时,则协调者会通知所有参与者放弃事务。
4. 参与者在收到准备请求之后 ,确保在任何情况下都可以提交事务 ,包括安全地将事务数据写入磁盘(不能以任何借口稍后拒绝提交,包括系统崩愤,电源故障或磁盘空间不足等),并检查是否存在冲突或约束违规。 一且向协调者回答 “是”,节点就承诺会提交事务。换句话说,尽管还没有真正提交,但参与者已表态此后不会行使放弃事务的权利。
5. 当协调者收到所有准备请求的答复肘,就是否提交(或放弃) 事务要做出明确的决定(即只有所有参与者都投赞成票时才会提交)。协调者把最后的决定写入到磁盘的事务日志中,防止稍后系统崩愤,并可以恢复之前的决定。这个时刻称为提交点。
6. 协调者的决定写入磁盘之后 ,接下来向所有参与者发送提交(或放弃)请求。 如果此请求出现失败或超时,则协调者必须一直重试,直到成功为止。此时,所有节点不允许有任何反悔:开弓没有回头箭, 一旦做了决定,就必须贯彻执行,即使需要很多次重试。而如果有参与者在此期间出现故障,在其恢复之后,也必须继续执行。这是因为之前参与者都投票选择了“是”,对于做出的承诺同样没有反悔的余地。
有点复杂?
其实,总共也就2个过程,第一个过程由协调者发起“proposal”提议,然后各个参与者根据自身的情况做出回应(同意请求或者否定请求);当协调者收到回复之后,根据提议的结果(只有所有的节点都同意整个提议才是可以执行的)做出具体的提交(正式执行请求命令)还是回滚。
上述协议看起来好像能跑的通,可以达到多个副本节点共识的目的。但一个最大的问题是其不支持容错,比如说在参与者回复“Yes”给协调者之后,协调者跪了,参与者就变成了无头苍蝇,它得等待协调者恢复给它下一步的动作(提交或者终止)。这种算法强依赖于协调者和所有的副本节点。
也正是因为相同的原因2PC算法的性能实在是比较差(当然分布式事务的整体性能都不是太好),一般在数据库层面用的不多。
4.3.2 Raft
Raft是在Paxos的基础上改良的容错式的分布式共识协议。具体的协议内容网上有一大堆,这里就不赘述了,主要讨论一点具体细节。
和2PC不同的是Raft协议有一个明确的Leader,leader由所有的副本节点共同投票选举,之后的操作都是由leader来进行提议,然后分发给其他的副本节点。
这里面涉及到两个环节的投票,一个是对leader的选举(当副本节点在系统中找不到leader时,就会发起选举, 每个节点都可以选举自己作为leader,但是只有经过了系统大部分节点的同意之后才可以成为真正的leader);一个是正常执行请求的投票(所有的请求都会先经过leader,然后由leader来发起);
其中后半部分有点类似于我们前面说的主从复制。你可能有疑问,既然leader都确定了,为什么不直接采用leader的结果,还需要由大家投票呢? 在我看来有两个原因:
- leader并不是固定的,当leader发生网络分区或者宕机的时候,会有其他的副本节点顶上来,因此leader接受请求后把提议发给其他副本节点,还需要判断当前自己还是不是leader(从某种程度上算是又接受了一次投票)?
- 在主从复制一节中,我们看到过异步复制带来的滞后问题。这里leader向其他副本节点发送执行请求的提议,必须要经过系统大多数节点之后才可以通过;保证了leader在向客户端回复的时候系统中大部分都节点已经执行了结果。
两个环节有一个切合点,就是选举的Leader节点具有整个系统最新的数据(因为每次执行请求的提议和选举leader的提议都需要大多数的投票赞成,这样两个过程一定会至少有一个重复的副本节点)。
之所以说Raft是一个容错式的共识协议,是因为Raft的每次投票过程只需要半数的节点即可进行,5个节点最多可以允许2个节点不在状态。
虽然是容错了,虽然可以达到强一致性了,但是毕竟还是有每次请求还是有同步的过程(同步到一半节点也是同步呀),因此性能比起异步还是要降了不少的。也正是因为这样,一般只有需要强一致性的系统才会考虑使用这种多副本的强一致性的协议,比如说保存整个系统的元数据和运行状态的一些协调服务。
三、后记
一致性问题是个大问题,不过还好,对于应用层来说,计算机的底层系统(CPU、编译器、编程语言、数据库系统)已经为我们做了很多保持一致性的功能。 但是了解上述的这些一致性对于理解计算机系统和编写并发程序来说还是颇有用处的。
一口吃不成大胖子,内容有点多,下一篇将讨论讨论应用层面关于一致性的相关内容,撤。
参考
【1】《DDIA》
【2】小窥数据库事务(上)
【3】小窥数据库事务(下)
【4】关于分布式复制,你该知道的事儿
【5】Memory Ordering at Compile Time
【6】现代C++的内存模型
【7】硬件内存模型
【8】Go内存一致模型
【9】关于一致性,你该知道的事儿(下)