特别放送 分布式下的一致性杂谈

你好,我是聂鹏程。

我们常说:“众人齐心,其利断金。”其实说的就是团结一致的重要性。一致性对一个团队如此重要,对于一个分布式系统又何尝不是呢?人心散了,团队会不好带。分布式系统中出现不一致了,也会带来各种各样的问题,甚至导致业务不可用。

我在第23讲“CAP理论:这顶帽子我不想要”时,就解释了分布式系统中一致性和可用性,就像是鱼与熊掌,不可兼得。因此,多年来,在不同场景下,保证一致性的同时尽可能提高可用性,或者保证可用性的同时尽可能提高一致性,成为了众多学术界、工业界仁人志士们研究的课题以及努力的方向。正可谓,分布式技术如此多娇,分布式一致性引无数英雄竞折腰。

今天,我特地邀请到我的朋友王启军,来与你分享他对分布式一致性的解读、思考和实践。

王启军,华为云PaaS团队资深架构师,负责 Java和Go微服务框架。他曾任当当网架构师,主导电商平台架构设计;曾就职于搜狐,负责手机微博的研发;著有《持续演进的Cloud Native》。

话不多说,我们来看看王启军的分享吧。

你好,我是王启军。今天,我来和你聊聊分布式下的一致性。

以前面试别人的时候,我经常会用一些开放性的问题来考察对方的能力。比如我最爱的一个问题是,“如果给你一份数据,要求支撑大规模的并发读写,同时具备横向扩展能力,你该如何拆分、如何同步数据呢?”

此时,候选人想到的通常是复制数据到多台服务器,以提升读的性能;然后对数据进行分区,分布到不同的服务器,以解决写的瓶颈。

如果到了这里,接下来我会问他怎么同步数据,一次性写多条数据,怎么保证多台服务器的一致性呢?如果数据同步存在延迟,怎么保证写后一定能读到呢?

这次追问之后,大部分候选人就会卡住。不知道你在面试的时候有没有遇到过类似的问题,今天我就想和你聊聊这个话题。

一致性的分类

业界对一致性的定义有很多种,比如CAP理论中的一致性和ACID中的一致性,描述的就不太一样,不能一概而论。所以,在讨论之前,你最好弄清楚一致性的分类

为了便于理解,业界通常会把一致性笼统地分为如下三类:

但实际上,这种分类并不能描述清楚,弱一致性在生产环境中基本没什么应用场景,最终一致性范围太宽泛,可能会存在多种不同强度的一致性。

相对来说,我更喜欢另外一种对一致性的分类方法,一致性模型主要从以下两个角度去分类

这两种一致性模型,又可以细分为很多类。接下来,我们就一起看看吧。

以数据为中心的一致性模型,又可以分为:严格一致性(Strict Consistency)、顺序一致性(Sequential Consistency)、因果一致性(Causal Consistency)、FIFO一致性(FIFO Consistency)、弱一致性(Weak Consistency)、释放一致性(Release Consistency)和入口一致性(Entry Consistency)。

限于篇幅,我不会详细介绍每一种一致性的定义,只和你说明几个常用的。

严格一致性,要求任何写操作都能立刻同步到其他所有进程,任何读操作都能读取到最新的修改。要实现这一点,要求存在一个全局时钟,也就是说每台服务器的时间都完全一致,但在分布式场景下很难做到。所以,严格一致性在实际生产环境中目前无法实现。

既然全局时钟导致严格一致性很难实现,顺序一致性放弃了全局时钟的约束,改为分布式逻辑时钟实现。分布式逻辑时钟可以理解为一个分布式ID。

顺序一致性是指,所有的进程以相同的顺序看到所有的修改。读操作未必能及时得到此前其他进程对同一数据的写更新,但是每个进程读到的该数据的不同值的顺序是一致的。举个例子,你在手机和PC上看到的聊天室的顺序是一致的吗?如果是一致的,那就代表满足顺序一致性,即使理论上某些先发的消息排在后发的消息后面了,也是满足的。

因果一致性是一种弱化的顺序一致性,所有进程必须以相同的顺序看到具有潜在因果关系的写操作,不同进程可以以不同的顺序看到并发的写操作。举个例子,在聊天室你看到有人问你:“你吃饭了吗?”,你回答:“我吃过了。”,你是因为看到了问题所以才触发了回答,所以这两条消息之间就存在因果关系,原因必须排在前面。

以用户为中心的一致性模型,包括4类:

单调读一致性是指,如果一个进程读取数据项a的值,那么该进程对a执行的任何后续读操作,总是得到第一次读取的那个值或更新的值。这个比较容易理解,说白了,就是不能读到新数据后,再读到比这个数据还旧的数据;如果没读到新数据,一直读的还是旧数据,单调读一致性并不关心这个问题。

单调写一致性是指,一个进程对数据项a执行的写操作,必须在该进程对a执行任何后续写操作前完成。这个很容易满足,注意这里是一个进程,所有的写操作都是顺序的。

写后读一致性是指,一个进程对数据项a执行一次写操作的结果,总是会被该进程对a执行的后续读操作看见。这个比较常见,比如数据库采用Master-Slave结构部署时,写完Master数据库,如果从Slave读取,有可能读不到,就不满足写后读一致性了。

读后写一致性是指,同一进程对数据项a执行的读操作之后的写操作,保证发生在与a读取值相同或比其更新的值上。这个问题经常出现的场景是,如果数据存储了多个副本,因为没有及时同步,在第一个副本上读了数据,去第二个副本上写,出现不一致的情况。

如何满足一致性需求?

当单个存储承受不住压力的时候,在读多写少的情况下,我们自然会想到使用主从的方式。为了保证写的一致性,通常只在主节点写,然后主从之间通过状态机(Replicated State Machine)的方式同步数据。

可以简单地理解为,主从都有相同的初始状态,在主上执行的所有命令,都同步一份相同顺序的命令到从,这样就可以保证最终数据也相同

比如MySQL,就可以采用一个Master,多个Slave的方式,所有的写都在Master上更新,所有的读都在Slave上进行,但这里存在一个问题,就是怎么保证写后读一致性?答案就是,写后读都在Master上进行。注意,并不是所有的读都作用在Master,而只对写后读一致性有要求的场景才在Master上读。

当然,这样Master的压力可能还是会很大。回到问题本身,之所以满足不了写后读一致性,原因是主从之间存在延迟。那么,我们完全可以设定一个大于延迟时间的阈值,小于阈值并且要求写后读一致性的操作作用在Master上,其他所有的读都作用在Slave上。

还有一个问题就是,当主挂掉的时候,为了保证可用性,通常要将其中一个从提升为主,这里面就会涉及很多问题,到底选择哪个从作为主节点?发生网络分区的时候,如何避免脑裂?如果旧的主节点又恢复了,如何协调?

关于这些问题如何解决,你可以参考Etcd和ZooKeeper的实现方案。

Quorum机制(NWR模型)

主从机制要求写必须在主上进行,那能不能让写在所有节点上都可以进行呢?当然可以,不过这已经不是主从模式了。

回到前面的问题,如果同时写三份数据,如何保证一定能够读取到最新的数据呢?简单来说就是利用版本号,假设一共三个节点,每次写数据的同时,我都会记录这条数据对应的版本号,读数据的时候读所有的节点,然后跟版本号进行对比,版本最新的就是最终数据。实际上这就是Quorum机制的原理,也可以叫作NWR模型。

简单来说,Quorum机制就是要满足公式W+R > N。其中,W表示必须至少写入成功的节点数,R表示至少读取成功的节点数,N表示总节点数。这个公式把选择权交给了业务用户,让用户来做出最终决策。

假设现在一共有三个节点,为了满足这个公式:

这里还存在另外一个问题,那就是,如果写的时候只写入一个节点就返回,当存在并发操作时,版本会存在冲突,也就是说,如果初始版本为1,两个进程分别对其中两个节点写数据,两个节点版本号都变成了1,数据却不一样,这个问题如何解决呢?

这,就要求我们写数据的时候最好是遵循W>N/2,也就是说,写的时候最好大于总节点数的一半,在写的过程中进行冲突检测,而不是在读的时候检测。

N阶段提交

还有一种比较经典的做法,常用在数据库中,那就是N阶段提交。这里的N有一阶段、两阶段、三阶段,其中两阶段用得最多。

这里我必须首先说明一下,两阶段提交不等于XA协议,两阶段提交是一种模式,ZooKeeper中提交事务的过程实际上也类似于两阶段提交,Google的分布式事务Percolator也是基于两阶段提交,而XA只是一种协议。它是由X/Open国际联盟提出的X/Open Distributed Transaction Processing(DTP)模型,简称XA协议。

顾名思义,两阶段提交的整个过程分为两个阶段,第一阶段询问是否可以提交,锁定数据,如果所有节点都返回可以提交,第二阶段提交,否则第二阶段回滚。基于XA的两阶段提交就是这种流程,这种方式常用于同时更新两个数据库的场景,一般常用的关系型数据库都支持这个协议。

由于第一阶段要给数据库加锁,否则会出现不一致的情况,这就会带来很多问题,XA被诟病的大部分原因都跟这个锁有关。例如,加锁后,协调者挂掉怎么办?加锁后,性能大幅下降如何处理?

为了解决死锁的问题,可以将加锁的时间缩短,降低死锁的概率,这就是三阶段提交。也就是说把第一阶段分为两个部分,询问是否可以提交,回复可以提交的时候,并不加锁,当所有节点都回复可以的时候,协调者再发一次加锁的请求。但这样的话,系统会变得更复杂。

当然,两阶段提交并不是只有XA协议,TCC也可以看成是一种两阶段提交协议,TCC是Try-Confirm-Cancel的缩写。相对于两阶段提交事务机制,它的特征在于不依赖于数据库的协议,数据库不需要锁定数据,事务过程由业务服务来实现,这样也就不会出现死锁的问题,性能也会高很多。还有一点容易被忽略,这种做法降低了数据库的压力。

当然,世界上没有免费的午餐,TCC事务机制也有不好的地方

而一阶段提交,就是没有询问是否可以提交的过程,直接提交,任意一个节点提交失败,就进行重试或者回滚。这种方案对业务的侵入性很大,需要对业务提供一个回滚操作。

实践案例

接下来,我就以在华为云的工作场景为例,来给你串下整体的思路吧。

华为云目前的分布式框架采用TCC的模式,将协调者从业务中抽离,独立为一个服务。主业务发起事务,获得全局事务ID,调用分支业务时将全局事务ID通过Header传递过去,分支业务根据全局事务ID申请分支事务ID,所有的状态都会存储到分布式事务服务端,服务端根据执行情况进行全局协调。

事务执行的大概步骤,如下所示。

什么时候开始考虑一致性?

在分布式领域,一致性是个永恒的话题,因为在大多数场景下,出于成本考虑,通常会选择性地忽略或者降低对一致性的要求。但是,当系统达到一定规模的时候,不一致的概率就会大大增加,比如原本不一致的概率可能是一万年一次,如果数据量提升一万倍,那可能是一年一次。但,无论发生的概率是多少,研发成本都是一样的,这时候实现更强的一致性就非常有必要了。

凡是涉及钱的系统,对一致性的要求必然很高,除此之外,大多数场景实际上对一致性的要求并没有那么高,比如用户登录时给用户加一个积分,加积分这个操作并没有那么重要,延迟几秒影响也不大,那完全可以做成异步的,保证最终一致性就可以了。

有没有什么学习材料?

如果要推荐学习资料的话,我建议你可以多去看看分布式领域的论文,特别是大神莱斯利 · 兰波特(Leslie Lamport)的,以及Google、Amazon等硅谷著名企业发布的论文。比如,下面这两篇,我就非常推荐你去读一读,因为很多数据库的分布式事务都是参考了它们 。

另外,你还可以看一些开源框架的实现,比如Cassandra、Kafka等,这些代码对于你理解分布式一致性会有非常大的帮助。

总结

我来简单和你总结一下今天的主要内容吧。

首先,要搞明白分布式一致性,我们就要从其分类开始,除了弱一致性、强一致性、最终一致性这种分类方式外,还有一种更好的分类方式,就是以数据为中心和以用户为中心的一致性分类。

其次,如果要满足一致性的需求,有很多种方案,这里我们介绍了两种最常用的方案,Quorum机制和N阶段提交,并通过华为云的一个实践案例来加深你对一致性的理解。

最后,在分布式领域,一致性是目前为止解决得最不好的一个领域,也是最复杂的一个领域,解决方案五花八门,我只是与你介绍了其中的一部分内容,如果你要继续学习,可以多阅读一些论文和开源框架的代码。