Skip to content

17 可靠性:分布式集群的数据一致性都有哪些实现方案?

你好,我是文强。

前两节我们讲完了消息队列集群的设计要点和思路,也讲到了在集群中引入副本的概念来实现数据的分布式可靠存储。这节课我们就来讲一下集群中数据的一致性,看看它是如何保证这些分布在多个节点上的副本上的数据是一致的。

分区、副本和数据倾斜

首先,我们来讲一下分区、副本和数据倾斜,这个是学习后面内容的一个基础。

前面我们讲过,副本之间一般都有主从的概念。为了达到容灾效果,主从副本需要分布在不同的物理节点上,来看一张图。

如上图所示,这是一个三副本的分片,Leader 和 Follower 会分布在三个节点上。控制副本分布的工作,就是由上节课讲到的控制器来完成的。控制器会根据当前的节点、Topic、分区、副本的分布信息,计算出新分区的分布情况,然后调用不同的 Broker 完成副本的创建(不同消息队列的具体流程可能不一样,但是运行原理是一致的)。

从功能上来看,在这种主从架构中,为了保证数据写入的顺序性,写入一般都是由 Leader 负责。因为组件功能特性和实现方式的不同, Follower 在功能上一般会分为这样两种情况。

  1. 只负责备份。 即写入和读取都是在 Leader 完成的,平时 Follower 只负责数据备份。当 Leader 出现异常时,Follower 会提升为 Leader,继续负责读写。
  2. 负责备份也负责读取,不负责写入。 即正常情况下,Leader 负责写入,Follower 负责读取和数据备份。当发生异常时,Follower 会提升为 Leader。

第一种方案的缺点在于,读取和写入都是在 Leader 完成,可能会导致 Leader 压力较高。第二种方案的问题是,如果 Follower 支持读取,那么就需要保证集群数据的强一致性,即所有副本的数据在同一时刻都需要保证是最新的。

这两个缺点中,第一个方案的缺点是比较好解决的,可以通过在 Topic 维度增加分片,并控制分片的 Leader 分布在不同的节点,来降低单分片和单节点的负载。而第二个是比较难解决的,当分片的数据量很大,要保证数据在集群中的强一致,又要保证可用性,基于CAP理论,技术上很难解决。这个难点我们后面会用CAP一致性协议来解释。所以在目前消息队列的实现中,一般都是用的第一种方案,即Master-Slave的架构。

在这种主从架构中,如果分区分布不合理或者分区数设置过少时,那就有可能会发生数据倾斜。如何解决呢?

如上图所示,在具体实现中可以通过增加分区,然后将不同的分区的 Leader 分布在不同的节点上。此时,我们 只要保证每个分区的写入是均匀的,那么就可以避免倾斜问题。

不过这里你可能会想,分区写入一定会是均匀的吗?当然不一定。那为什么会不均匀呢?你可以回顾一下 第09讲。 那如何解决倾斜呢?我们会在架构升级篇讲到,你可以先思考下。不过解决这个问题不是我们这节课的重点,这里你只要了解什么是数据倾斜就可以了。

讲完了分区、副本、数据倾斜,接下来我们来看一下副本(Leader 和 Follower)之间的数据同步方式。

副本间数据同步方式

从机制上来看,副本之间的同步方式有同步复制和异步复制两种。

  1. 同步复制 是指主节点接收到数据后,通过同步多写的方式将数据发送到从节点。
  2. 异步复制 是指主节点接收到数据后,通过主节点异步发送或者从节点异步拉取的方式将数据同步到从节点。

在目前主流的消息队列中,大部分只会实现其中一种方式,比如Kakfa是异步复制,Pulsar、RabbitMQ是同步复制。RocketMQ 是比较特殊的那个,既支持同步复制也支持异步复制。

从数据复制的具体实现上看,一般有通过 Leader 推送 和 Follower 拉取两种方式。

  1. Leader 推送 是指当 Leader 接收到数据后,将数据发送给其他 Follower 节点,Follower 保存成功后,返回成功的信息给 Leader。
  2. Follower 拉取 是指 Follower 根据一定的策略从 Leader 拉取数据,保存成功后,通知 Leader 数据保存成功。

这两种形式在业界都有在用,其中Leader推送是用得比较多的策略,Follower 拉取用得比较少。它们优缺点如下:

目前主流消息队列RabbitMQ、RocketMQ都是用的第一种方案,Kafka用的是第二种方案。Pulsar是计算存储分离的架构,从某种意义上来说,用的也是第一种方案

接下来我们看看CAP和一致性模型。

CAP 和一致性模型

在分布式系统领域,CAP 理论对于分布式系统的设计影响非常大,几乎所有的分布式课程都会讲解CAP,所以我们就不展开分析了,简单复习下就好。

CAP 是指一致性、可用性 分区容忍性。

  • 一致性(Consistency)是指每次读取要么是最新的数据,要么是一个错误。
  • 可用性(Availability)指 Client 在任何时刻的读写操作都能在限定的延迟内完成,即每次请求都能获得一个正确的响应,但不保证是最新的数据。
  • 分区容忍性(Partition Tolerance)是指在分布式系统中,不同机器无法进行网络通信的情况是必然会发生的,在这种情况下,系统应保证可以正常工作。

因为在大部分情况下,分布式系统分区容忍性是必须满足的条件。所以,从理论上看每个分布式系统只能满足其中两个,比如AP、CP。这也是我们经常说的分布式系统是满足AP还是满足CP的原因。

所以在消息队列的实现中,因为面对的功能业务场景不一样(比如消息和流),此时对一致性和可用性的要求不一样,所以对AP和CP的支持程度和方式也会不一样。但是为了支持更多的场景, 大部分消息队列都会支持更灵活的AP和CP策略。

简单来说,就是因为消息队列在业务中是用来当缓冲的,起削峰填谷的作用,所以可用性是必须要满足的。消息队列从某种意义上是一个AP的系统,但是作为一个存储系统,它又必须保证数据可靠性,所以就会在一致性上想办法。

在分布式系统中,一致性模型分为强一致、弱一致和最终一致三种。

  • 强一致 是指数据写入 Leader 后,所有 Follower 都写入成功才算成功。
  • 弱一致 是指数据写入 Leader 后,不保证 Follower 一定能拉到这条数据。
  • 最终一致 是指数据写入 Leader 后,在一段时间内不保证所有的副本都能拉到这条数据,但是最终状态是所有的副本都会拉到数据。

接下来,我们来看消息队列在一致性和可靠性上是怎么实现的。

集群数据一致性和可靠性实现

从技术上看,消息队列作为存储系统,弱一致一般是不考虑的,所以一般是在强一致和最终一致上做选择。但是如果有场景(比如日志)需要弱一致呢?要怎么满足?

从技术上来看,消息队列都会支持通过配置生效的、灵活的一致性策略。即允许通过修改配置来调整一致性策略,比如在一些需要强一致的场景中,可以通过修改配置来支持强一致。同样的需要最终一致或者弱一致时,也可以通过修改配置来生效。

从实现的角度看,内核对灵活的一致性策略的支持一般有集群维度固定配置和用户/资源维度灵活配置两个实现思路。

  1. 集群维度固定配置 是指在集群部署的时候,就配置好集群的一致性策略,比如RocketMQ 、Pulsar、ZooKeeper。
  2. 用户/资源维度灵活配置 是指在客户端写入数据的时候或者在Topic/Queue维度,可以配置不同的一致性策略,比如Kafka和RabbitMQ。

第一种方案的好处是用户不用关心一致性的配置,理解成本也较低,缺点是不够灵活。第二种方案,用户需要知道并设置一致性策略,很灵活。第二种虽然增加了理解和配置的成本,但是在我看来使用成本其实也不高,所以 我会推荐你使用第二种方案

接下来我们来看一下ZooKeeper、Kafka、Pulsar这三个组件是如何实现分布式数据可靠性的,之所以挑选它们,是因为其具有一定的代表性。

ZooKeeer 作为分布式协调服务,对数据可靠性要求是最高的,数据量也比较小。Kafka 是经典的流领域的消息队列,数据量大,需要保证高性能、高吞吐,对可靠性的要求较低。Pulsar 是计算存储分离架构,它的写入是通过BookKeeper完成的,模型上很特殊。

下面我们先来看看ZooKeeper是如何实现分布式数据的一致性和可靠性的。

ZooKeeper 数据一致性和可靠性

ZooKeeper 没有副本的概念,只有主从节点的概念,即所有节点上的数据都是一样的。主从节点之间通过 Zab 协议来保证集群中数据的最终一致。

基于该协议,ZooKeeper 通过主备复制、Epoch 等概念来保证集群中各个副本之间数据的一致性。在 ZooKeeper 中,写入只能在 Leader 完成,Leader 收到数据后会再将数据同步到其他Follower 节点中,Follower 可以负责读取数据。

Zab 协议本质上是最终一致的协议。它遵循多数原则,即当多数副本保存数据成功后,就认为这条数据保存成功了。多数原则的本质是在一致性和可用性之间做一个权衡,即如果需要全部副本都成功,当底层出现问题时,系统就不可用,而最终一致的可靠性又太弱。所以, 多数原则是一个平衡且合理的方案,在业界也是用得最多的。

ZooKeeper 的多数原则是固定的,即数据写入成功的节点数要超过集群总节点的一半,数据才算成功。如下图所示,这是一个三节点的ZooKeeper集群,根据半数原则,只要有两个节点成功写入数据,就完成了数据写入。

另外,ZooKeeper 需要保证数据的高可靠,不允许丢失。而在多数原则理论中,如果数据只写入到Leader和Follower中,此时这两台节点同时损坏或者集群发生异常时导致 Leader 频繁切换,数据就可能会损坏或丢失。为了解决这些复杂场景,Zab协议定义了 Zxid、崩溃恢复等细节来保证数据不会丢失。

这里因为Zab协议是一个非常经典的一致性协议,我们就不展开讲了,如果有兴趣,你可以去研究一下它的细节。接下来我们看看Kafka是如何保证数据的一致性和可靠性的。

Kafka 数据一致性和可靠性

Kakfa 在分区维度有副本的概念,副本之间通过自定义的 ISR 协议来保证数据一致性。

在实现中,Kafka 同时支持强一致、弱一致、最终一致。和 ZooKeeper 默认的多数原则不同,Kafka 的一致性策略是在客户端指定的。客户端会指定 ACK 参数,参数值-1、0、1,分别表示强一致、弱一致、最终一致。

另外,和 ZooKeeper 不同的是,Kafka的副本同步是通过 Follower 主动拉取的形式实现的。如下图所示,每台 Follower 节点会维护和 Leader 通信的线程,Follower 会根据一定的策略不停地从Leader拉取数据,当数据写入到 Leader 后,Follower 就会拉到对应的数据进行保存。

这里解释一下。如果客户端设置 ACK=-1 表示数据要强一致,数据写入到Leader,就需要所有Follower 都拉取到数据后,数据才算保存成功。设置 ACK=0 表示弱一致,客户端写入数据后就不管了,不管Leader有没有写入成功,Follower有没有同步。当 ACK=1 表示最终一致,只要Leader 写入成功后,就认为成功,不管Follower有没有同步。

因为 Follower 是可以批量拉取数据的,所以 Kafka 在副本拉取数据的性能上会高许多。在我看来,这个模型的设计是蛮优秀的,通过批量拉取Leader数据来提高一致性的性能。但是这个协议存在的缺点是,实现上比较复杂,需要维护副本线程、ACK 超时时间等机制,并且在一些边界场景,比如Leader频繁切换的时候,可能会导致分区的数据发生截断,从而导致数据丢失。

在我看来,Kafka 这样设计的原因和它主打性能和吞吐的定位有关,ISR 一致性协议的考虑主要也是围绕这两点展开的。为了解决Leader切换、数据截断等问题, Kafka 引入了副本水位、Leader Epoch、数据截断等概念,来保证数据的可用性和可靠性。 从结果上看,ISR 协议可能存在数据丢失的情况。我们在后面讲 Kafka 的时候会再详细讲一下 ISR 协议。

在 Kafka 去 ZooKeeper 的版本中,Kakfa的元数据模块使用基于Raft协议实现的KRaft来替代ZooKeeper,从而实现元数据的分布式可靠存储。此时 Kakfa 元数据的一致性和可靠性的实现,其理论基础可以参考Raft协议,理论模型都是一致的,只是实现的细节有些不同。

KRaft 的详细细节你可以参考 官方的KIP文档。另外 Kafka 去 ZooKeeper 的版本,其消息数据的数据一致性模型依旧是ISR模型,这块和我们前面讲到的是一样的。

接下来,我们看看 Pulsar 是如何保证数据的一致性和可靠性的。

Pulsar 数据一致性和可靠性

我们知道,Pulsar 是计算存储分离的架构,我们常说的 Pulsar 都是指 Pulsar 的 Broker。Broker 本身是不保存数据的,数据是保存在BookKeeper中。所以 Pulsar 的数据一致性协议是配合 BookKeeper 一起完成的。

如上图所示,数据发送到Pulsar Broker中后,Broker会调用 BookKeeper 的客户端,通过Ledger和Entry将数据写入到BookKeeper中。所以从 Pulsar Broker的角度来看, 数据的一致性就是通过控制 Ledger 数量和 Ledger 在 BookKeeper 上的分布来实现的。

在Pulsar Broker,通过配置 Write Quorum Size(Qw)和 Ack Quorum Size(Qa)两个参数可以控制数据的一致性。Qw 指的是 Ledger 的总副本数,Qa 指数据写入几个副本后算写入成功。比如Qw=3、Qa=2,就表示Ledger有三副本,只要写入两个副本,数据就算写入成功。当前,Qw 和 Qa 这两个参数是在Broker端固定配置的,不能单独指定。

Pulsar 副本间的数据同步方式是 Leader 收到数据后,主动写入到多个 Ledger 的。Leader 会等到配置的 Qa 数量的副本写入成功,才告诉客户端写入成功。Broker 和 BookKeeper 之间是以流的方式写入数据的,即会先创建一个Ledger,然后将消息包装为一个一个的Entry,然后通过流的方式写入到Ledger中。 流方式的写入可以提高写入的性能。

从设计思路上对比Kafka和BookKeeper的一致性实现,Kakfa 的一致性放到了服务端实现,让客户端的使用更加轻松,无需感知底层的实现;而 BookKeeper 的实现方式,在客户端实现了更多细节,减轻了内核的工作量。

总结

一般我们讲分布式系统一致性和可靠性时,都会用大量的篇幅去讲 CAP、Raft、Paxos 等一致性协议。其实是没问题的,不管是 ZooKeeper 的 Zab、Kafka 的 ISR 和 KRaft,还是 Pulsar 基于 BookKeeper 实现的一致性协议,都是 Paxos 和 Raft 的实现或简化。但是这几个协议不是一节课能讲完的,并且我认为讲解的意义不大,因为网上资料非常多,也有原始的论文可以参考。如果你真的对分布式系统的设计感兴趣,建议你课后去深入研究一下CAP、Raft和Paxos。

在消息队列中,都会通过主从架构和控制分区、副本的分布来提升集群性能和数据可靠性。在分区副本模型中,需要注意的是数据倾斜对集群的影响。

多个副本之间则是通过一致性协议来维护副本间数据的一致性。副本之间的数据同步机制主要分为同步复制和异步复制两种。从实现上来,数据的同步主要有Leader推送和Follower拉取两种策略。

在CAP协议中,大多数消息队列都是AP的系统,同时会在强、弱、最终一致性上做权衡。多数原则是用得比较多的策略,比如ZooKeeper、Pulsar 都是用的这个策略。从具体实现的角度,有的消息队列会提供灵活的AP、CP、CA切换机制,以满足不同场景对一致性的追求。

从技术的视角来看,强一致、最终一致、弱一致并没有好坏之分,只有合适之分。需要根据自己的业务形态来进行合适的选择,比如在追求性能、不追求可靠性、且允许数据丢失的场景中,弱一致就是合适的,并不需要无限追求强一致。

思考题

RabbitMQ 的 Queue 在镜像模式下的一致性模型和可靠性是怎样的?

期待你的思考,如果觉得有收获,也欢迎你把这节课分享给身边的朋友。我们下节课再见!

上节课思考闭环

请你简单描述一下 Kafka 集群中修改配置/权限操作的流程?

Kafka 修改配置/权限的实现,是每个 Broker 直接去监听Broker中的节点。Broker 会直接监听 ZooKeeper 上的节点,然后根据 Hook 到的信息,做对应的操作。比如修改集群和Topic配置,就是 Broker 通过直接监听ZooKeeper 的不同子节点来实现的。这种方式的好处是,Broker直接监听ZooKeeper,避免Controller转发一道,从而避免让Controller成为瓶颈,整体链路更短,出问题的概率也更低。