掘金 后端 ( ) • 2024-04-22 15:38

为了解决单节点存储瓶颈、访问耗时、单点故障等问题,通常将数据存储到多个节点,称之为分布式技术。分布式不仅可以将负载分散、还可以避免单点故障、降低延时。然而,分布式也引入了额外的问题:网络中的数据可能会丢失、重新排序、重新推送或任意延迟;不同节点的时钟漂移;节点的暂停或崩溃。

解决分布式问题的最好方式是找到一些通用的抽象,实现一次,然后依赖这些保证。就像事务一样,通过使用事务,应用程序不用去关心多个操作出现部分成功部分失败的问题「原子性」、数据写入约束问题「一致性」、多个客户端并发访问题「隔离性」、存入的数据丢失问题「持久性」。即使数据执行期间发生了崩溃、冲突和磁盘故障,事务隐藏了这些问题,应用程序不用关心。

分布式系统中最重要的抽象就是共识:就是让所有节点对某一件事达成一致。一旦达成共识,应用程序就可以忽略许多问题。例如:在单主复制的场景中,如果主库宕机,剩余的节点可以使用共识算法来选举新的领导者。从而避免同时有两个节点都认为自己是领导者,造成的脑裂问题。在分区数据库中,为了保证事务的原子性,则需要涉及一个事务的多个节点就事务的提交而达成共识。

一致性与共识的关系

在开始讨论共识算法之前,不得不讨论一下一致性的问题。上面说到,共识就是让所有节点对某一件事达成一致。而一致性是指多个节点,给定一系列操作,在约定协议的保障下,使它们对外界呈现的状态是一致的。一致性描述的是结果状态,即多个节点对外界呈现的状态;共识是一种机制/算法,描述的是过程,即如何实现多个节点的一致性。

一致性

一致性是指多个节点,给定一系列操作,在预定协议的保障下,对外界呈现的状态。在实际实现中,会对一致性进行分类。按照系统的容忍度,可以将一致性分为:最终一致性、因果一致性、顺序一致性、线性一致性「强一致性」。其实和事务的隔离级别一样,均是出于对性能的考虑,分为不同级别,从而提升性能。

最终一致性

最终一致性,也叫弱一致性。分布式中的各个节点,在停止操作的一段时间后,各个节点的数据是完全相同的。简单来说,就是一段时间后,节点的数据会达到最终一致的状态。

大多数异步复制的数据库只能保证最终一致性。如下图所示,用户更新头像之后,需要过一段时间,两个从库才能达到和主库一致的状状态。

最终一致性的实现方式比较简单。例如在 msyql 中,从库往往选择监听主库的 binlog,对数据回放,从而保证最终一致性。最终一致性带来的问题是,不同节点之间达成完全一致状态的时间是不能确定的,可能是几毫秒,也可能是分钟,这往往会导致写入的数据不能被及时查到。

单调读

单调读是一个比最终一致性更强的保证。即保证用户的多次读取,不会发生读取新值之后又读到旧值的情况

如下图所示,在满足最终一致性的前提下,仍然会有业务异常。即 user:2345 看到 user 1234 回复的消息。但是再次刷新却看不到了回复的消息。常见的解决方案就是保证同一个 user 读取的节点是固定的。

因果一致性

因果一致性是一个比单调读更强的保证。对于一些列按照某个顺序的写请求,那么读取这些内容时也会按照写入的顺序呈现。不会出现后写入的数据先被读到的现象。

如下图所示,在一个分区的场景中。由于不同节点之间的网络延时不固定,导致 observer 看到的消息是先有 Cake 的回复,再有 Poons 的问题。从而造成业务异常。常见的方案是将因果关系的写入路由到同一个分区,从而保证因果顺序。

顺序一致性

顺序一致性是比因果一致性更强的保证 ,即执行结果符合每个用户按顺序执行的结果。

为了方便介绍,采用用户视角,而不是数据库视角。每一个柱都是由数据库发出的请求,柱头是请求的发送时刻,柱尾是客户端收到响应的时刻。因为客户端和数据之间通信存在网络延时,并不知道数据库处理请求的具体时间。在顺序一致性的保证下, A 和 B 会收到什么响应:

  • A 的第一次读操作,完成于写操作之前,返回 0。
  • A 的最后一个读操作,完成于写之后,返回 1。因为写操作一定在收到响应之后处理完成,而读操作发生在响应之后,因此必须看到新写入的值。
  • 在写操作时间上重叠的任何操作「A 的第二次读, B 的两次读」,可能会返回 0 或 1。这些操作是并发的,并不知道数据库执行的顺序。即可能出现 A 第二次读到 1,B 第二次读到 0 的现象。

线性一致性

线性一致性,是最强的保证。它使多个节点看起来好像只有一个节点,而且所有操作都是原子的。

在上面顺序一致性的例子中,由于在写操作时间上重叠,A 的第二次读可能返回 1, B 的第二次读可能返回 0。它违背了线性一致性的约束,即永远读到数据的最新值「由于 A 已经读到了 1,意味后续系统应该返回最新值 1」。如下图所示,A 读到 1 之后的后续所有读取都应该为 1。从而保证数据永远读取到的是最新值。

CAP 定理

线性一致性看起来是那么的完美,应用程序可以像使用单体数据库一样使用分布式数据库。但带来的问题是严重的性能开销。

在分布式系统中,通常认为系统无法同时满足 CAP 特性,也叫 CAP 定理:

  • Consistency「一致性」:这里指的是强一致性。即分布式系统对外表现好像只有一个副本一样,写入的数据可以立即读到。
  • Availability「可用性」:对于每次请求,系统都能在一定时间内给予响应。响应的不一定是最新的数据。
  • Partition tolerance「分区容错性」: 系统在网络分区(即网络通信的中断)发生时,仍然能够保持系统的正常运行。

在 CAP 定理中,分布式系统无法满足这三种特性。意味着要么牺牲一致性,搭建一个 AP 系统;要么牺牲可用性,搭建 CP 系统。

如下图所示,是一个具有两个数据中心的架构。考虑一种情况,每个数据中心可以正常提供服务,只是数据中心之间的网络发生中断。

  • 为了保证一致性,请求到从库的客户端将不可用。需要等待网络恢复时,数据中心才能提供服务。即牺牲了可用性。
  • 为了保证请求到从库的客户端能够正常响应「可用性」,则需要牺牲强一致性,即写入主库的数据不能被立马读到。网络恢复后,两个数据中心会达到最终一致。

遗憾的是,在分布式场景下,网络故障肯定是存在的,这是受到物理规律的限制。也就意味着,在分布式系统中,一定要满足分区容错性。在此前提下,可以考虑满足一致性还是可用性。CAP 定理的要求似乎太过于严格。它只考虑了强一致性和网络分区,对于最终一致性和网络延迟、节点故障并没有进行讨论。CAP 定理似乎对于设计并实现一个分布式系统并没有太大的参考意义。

出于性能的考虑,分布式系统在设计时通常满足最终一致性,从而满足一定程度的可用性。或者牺牲一定程度的可用性「可能在一段时间内不用,部分写入请求失败」,从而满足强一致性。

分布式事务与共识

一致性是分布式系统对外界呈现的状态。按照 CAP 定义,网络故障是不可避免的,意味着只能在一致性和可用性之间作出取舍。共识是分布式系统中就某一事件达成一致的过程。例如:对于下面的两种场景均需要分布式中各个节点达成共识,从而保证一致性。

  • 原子提交

在线性一致性的保证下,分布式系统表现的好像只有一个副本。这就意味着,在分区场景下,如果一个事务包含多个分区,仍需要保证原子性。分布式系统的各个节点需要对事务的提交达成一致,要么全部提交,要么全部回滚,从而保证事务操作的原子性。

  • 领导者选举

在单主复制的场景下,领导者发生故障。为了保证系统的可用性,需要从从库中选择一个节点作为领导者。如果从库不能就谁成为领导者而达成共识,就会导致脑裂。即存在多个主库,造成数据异常。

共识的不可能性

Fischer,Lynch 和 Paterson 证明,如果存在节点可能崩溃的风险,则不存在总是能够达成共识的算法。在分布式系统中,我们必须假设节点可能会崩溃,所以可靠的共识是不可能的。然而这里我们正在讨论达成共识的算法,到底是怎么回事?

答案是 FLP 是一种限制性很强的模型,它假定确定性算法不能使用任何时钟或超时。如果允许算法使用超时或其他方法来识别可疑的崩溃节点(即使怀疑有时是错误的),则共识变为一个可解的问题。即使仅仅允许算法使用随机数,也足以绕过这个不可能的结果。

因此,虽然 FLP 是关于共识不可能性的重要理论结果,但现实中的分布式系统通常是可以达成共识的。

两阶段提交

两阶段提交是保证分布式中各个节点原子事务提交的一个算法,即保证所有节点提交或中止。下图说明是两阶段提交的流程,整体分为两个阶段。所以叫两阶段提交。

在两阶段提交中,需要一个协调者用于协调各个节点进行事务提交/中止。这个协调者可以是应用程序的一个进程,也可以单独运行在服务器上。其详细流程如下:

  1. 提交请求阶段

    1. 协调者向所有的参与者发送事务内容,并询问是否可以提交请求,等待响应
    2. 参与者执行事务的内容,记录 undo 日志「用于回滚」、redo 日志「用于回放」,但不会真正的提交
    3. 参与者向协调者返回事务是否可以提交。可以提交:yes,不可以提交:no。可以提交意味着无论发生什么情况「磁盘容量不足、不满足约束等」参与者均能保证事务的正常提交。
  2. 提交执行阶段

    1. 当协调者收到所有的参与者发来的信息后,会根据结果来确定事务是提交还是终止。并将结果写入磁盘,即使协调者发送崩溃,故障恢复后仍知道决策结果。这被称为提交点
    2. 协调者向各个节点发送提交/中止请求,各个节点执行对应的操作。
    3. 协调者收到各个节点的 ack 信息,事务执行完毕。

上面整个流程是如何保证事务的原子性呢?当各个节点确认事务可以提交之后,意味着稍后它肯定可以提交。协调者一旦作出决定后,这一决定是不可撤销的。这些承诺保证了分布式事务的原子性「在单机数据库中,其实是把写入日志和事务提交合为一体」。

在两阶段提交中,事务的原子性完全依赖协调者。事务提交请求阶段如果有节点超时或失败,协调者会让各个节点中止事务。在事务提交执行,如果有请求失败,协调者会无条件重试。

如下图所示,如果协调者在给 db2 发送 commit 之后发生故障。此时,db1 并不知道需要提交和中止。为了保证一致性,则需要 db1 阻塞,等待协调者恢复,确认提交还是终止。

三阶段提交

两阶段提交的最大问题在于事务的原子执行其实是依赖协调者完成。如果协调者发生故障,其他阶段会被阻塞「该事务中的数据会被加锁阻塞」,直到协调者恢复。

为了解决阻塞问题,有人提出了三阶段提交,非阻塞提交。三阶段其实是对两阶段的补充。与两阶段相比,三阶段加入了一个 canCommit 阶段,判断自己是否可以执行事务,这时并不会写入 redo log 和 undo log。

三阶段如何解决协调者故障而导致的阻塞问题呢?在三阶段协议中,引入超时机制。即进入第三阶段后,如果等待不到协调者的响应则直接 commit。为什么两阶段提交不能使用超时机制?在两阶段提交中,参与者不知道协调者的决策结果,则只能阻塞等待。而在三阶段中,参与者进入了第二阶段,意味着所有的节点均可以提交。超时之后可以直接提交。

三阶段协议并不是完美的,在分布式场景下,并没有绝对的故障检测机制。常见超时判断其实是不准确的,网络拥堵,时钟漂移,协调者 gc 均会导致误判为故障。出于这个原因,两阶段提交仍然是一个常用的协议,尽管大家知道协调者的故障会导致其阻塞。

XA 事务

XA「eXtended Architecture 的缩写」是跨异构技术的两阶段提交标准。所谓异构,可以简单理解为两种不同的数据库。例如:一个事务涉及 Mysql、PG 等多个数据库。为了保证数据事务的一致性则需要参与事务的数据库均实现 XA 的标准。

XA 事务本质上是一个两阶段提交标准的实现。应用程序通过调用实现 XA 的数据库驱动与数据库进行通信,从而实现分布式事务。通常情况下会有一个 sdk 实现 XA 定义的接口,应用程序通过 sdk 启动一个线程「协调者」进行分布式事务。它会将决策结果写入日志,并向参与者发送提交或终止消息。

由于协调者是寄生在应用程序中,如果应用程序发生崩溃,协调者将会死亡。任何等待消息的参与者将会阻塞,直到协调者重启加载日志信息,并下发消息到参与者。

共识算法

使用分布式事务可以保证多个节点对事务的一致性保证。然而在具体实现的时候强依赖协调者,如果协调者发生故障,则无法保证事务的一致性。在单主复制的架构中,所有的事务都是在一个节点上,可以保证事务的原子性。然后,问题在于主节点发生故障时,如何选择一个节点作为新的主库。

最著名的容错共识算法是视图戳复制,Paxos,Raft 以及 Zab。这些算法之间有不少相似之处,但它们并不相同。这里并不作过多讨论,除非你准备自己实现一个共识系统

总结

本文主要介绍了分布式场景下的一致性和共识。按照一致性的强弱,可以分为:线性一致性、顺序一致性、因果一致性、单调读、最终一致性。

一致性是分布式系统对外展示的一致的状态。而实现这种状态的方法我们称之为共识。在分布式场景下,常见的算法有两阶段提交、三阶段提交、XA 事务;还有一类算法是采用的主从方式,核心就是故障情况下如何选主,这里算法包括 Paxos、Raft、Zab 等。