《深入拆解消息队列47讲》进阶篇——小记随笔

集群:哪些环节会存在性能瓶颈和数据可靠性风险?

进阶篇将从集群构建、性能、可靠性、数据安全、可观测性几个方面展开。总结来说,我们将把单机的消息队列架构扩展成为分布式的高可靠、高性能的完整集群。

img

从技术上看,消息队列的性能和可靠性由生产者、Broker 集群、消费者三方共同保障,而不只是服务端的工作。通常衡量集群性能的一个重要指标是全链路耗时,即客户端发出一条消息到消费者消费到这条消息的时间差。

img

生产者的性能和可靠性

img

主要分为客户端 SDK 和网络两大部分。

网络层面

在网络层面,对性能和可靠性的影响主要包括连接协议、传输加密、网路稳定性、网络延时、网络带宽五个方面。

  • 连接协议:
    生产者客户端会先和 Broker 建立并保持 TCP 长连接,而不是在每次发送数据时都重新连接,以确保通信的性能。
  • 传输加密:
    在数据传输过程中,为了避免数据包被篡改、窃取,就需要进行传输加密。另外当启用加密传输后,数据的传输性能会下降,这也是我们启用加密传输时一个需要考虑的点。
  • 网路稳定性:
    因为网络质量不稳定,传输过程中可能也存在丢包的情况,此时就需要依赖 TCP 的重传机制来解决问题。但当出现大量网络重传时,就会极大地影响性能,导致集群的吞吐下降和耗时上升。这也是我们在系统运营过程需要监控网络包重传率的原因。
  • 网络延迟:
    在性能部分,客户端和服务端的网络耗时是绕不过去的。特别在流量大、高吞吐的场景下,网络耗时对数据传输性能的影响更大
  • 网络带宽:
    在网络部分,我们还需要关注网络带宽。如下图所示,一般我们会关注客户端节点网卡、中间网络链路、Broker 节点的网卡三个部分的带宽容量。客户端和 Broker 的网卡使用情况比较好发现和分析,经常忽略且不容易分析的是中间网络带宽的使用情况,比如在跨地域传输、跨云传输的场景下,中间网络带宽很容易成为瓶颈。

SDK 层面

在 SDK 层面,对性能和可靠性的影响主要包括发送模式、批量语义、异常处理、生产者数量四个方面。

  • 发送模式:
    在生产端,一般支持发送即忘、同步发送、异步发送三种发送模式,发送模式的设计思想是希望在性能和可靠性之间寻找平衡。
    • 发送即忘是指调用 send() 函数后,不用等待服务端的返回结果,因此可以不断地发送数据。这种模式的性能是最高的,可靠性是最低的,因为数据发送失败后没有任何后续的容错处理。
    • 同步发送是指调用 send() 函数后,业务代码同步等待服务端的返回,优点是能保证发送消息的顺序性,这种模式的性能是最低的。其性能高度依赖 Broker 和服务端之间的网络延时,以及 Broker 的处理耗时。
    • 异步发送是指调用 send() 函数后,使用异步线程回调的方式发送数据。即在不阻碍主线程的情况下发送数据,此时业务可以一直不停地发送数据。但是如果 send() 速度大于底层发送给 Broker 的速度,当 SDK 底层的线程池用完后,发送数据也会阻塞。
      总结一下,从性能上来看,发送即忘 > 异步发送 > 同步发送。从可靠性来看,异步发送 = 同步发送 > 发送即忘。同步发送可以保证顺序,异步发送因为重传机制的存在,会无法保证顺序。
  • 批量语义:
    批量发送是指生产端是否支持 Batch 语义。批量比非批量的吞吐性能高。从全链路延时来看,因为批量发送需要在生产者客户端本地等待聚合数据,所以非批量发送的全链路耗时会比批量发送的全链路延时低。
  • 异常处理:
    当客户端接收到服务端的报错时,如果没有正确地捕获异常,进行重试和记录,就有可能出现数据发送失败我们却不知道的情况,最终导致数据丢失。做好异常捕获、重试的逻辑,并对发送结果进行记录。比如发送失败的异常信息,发送时候记录消息的 ID 或者能唯一标识消息的信息,从而做到发送数据的可追溯。
  • 生产者数量:
    因为单个生产者和单个 TCP 连接是有性能瓶颈的,在业务中我建议你建立多个生产端实例同时来写入数据,这样可以提高生产者的性能。

Broker 的性能和可靠性

单机维度

在单机维度,Broker 的性能和可靠性提升可以拆成应用程序、操作系统、物理硬件三个层面。

img

应用程序:

应用程序的优化主要分为网络层、逻辑层、存储层三个模块。其中网络层、存储层的性能和可靠性是最重要的,至于如何优化我们在基础篇已经讲过,这里就不再重复了。
img

除了网络层和存储层,逻辑层的处理速度也很重要。比如在生产流程中,需要进行数据解析、数据校验、数据重组、协议转换等操作,这些操作都需要通过编码实现,编码质量的好坏会严重影响该流程的性能。此时就需要用到相关语言的编程技巧,来提升逻辑层的处理速度和稳定性。

另一方面,应用程序的性能还受限于编程语言虚拟机,比如 Java 虚拟机。合理的对语言虚拟机调优可以极大地提高程序的处理性能。比如 Java 虚拟机调优一般会关注 JDK 版本、垃圾回收策略、堆大小等等几个方面。

操作系统:

操作系统是指通过调整操作系统参数来提高性能。在消息队列中,我一般会建议你调整系统最大 FD、PageCache 刷盘策略、Socket 最大缓冲区大小、进程可映射的内存区域数量等等配置来提高性能。从实际运营来看,大部分情况下默认的系统参数都够用,对这些参数的调优一般在需要深度优化挖掘单节点性能的场景中会用到。如果你想对 Kafka 集群的操作系统进行调优,还可以参考 Kafka 操作系统调优(https://kafka.apache.org/documentation/#hwandos)。

物理硬件:

物理硬件是指节点的 CPU、内存、网卡、硬盘等物理资源。我们都知道用更大规格的物理资源,性能肯定会更高。从实际运营的角度,通过升级物理硬件来提高性能,也是最简单、最直接的方式。

  • CPU:因为消息队列并没有很复杂的计算逻辑,核心流程是接收返回数据,所以大部分情况下消息队列对 CPU 的需求并不高。
  • 内存:内存是 Broker 很依赖的资源,因为数据的写入、消费的热读等等都需要依赖内存。
  • 网卡:网卡我们只需要关注是否被打满就行,打满了系统就会异常。大部分业务中网卡容量都是够的
  • 硬盘:消息队列是一个非常依赖硬盘性能的产品。要满足低延时、高吞吐的需求,硬盘的吞吐就非常重要。硬盘的三个衡量指标是 IOPS、吞吐、延时。用高性能的硬盘会给集群带来很大的性能提升。

集群维度

如下图所示,我们都知道集群的核心思想就是水平扩容,即通过水平扩容添加节点,让集群拥有更强的处理能力。在消息队列集群中,性能和可靠性是通过创建更多分区、多个副本,并将分区和副本分配到多个节点上来实现的。

img

消费者的性能和可靠性

在消费性能方面,我们主要关注延时和堆积两个指标。

  • 延时是指 Broker 保存一条消息后,这条消息被客户端消费到的时间差。
  • 堆积是指 Broker 堆积很多消息没有被及时消费。

消费者的性能和可靠性主要跟消费模型、消费重平衡、消费模式、位点提交四个方面有关。

img

  • 消费模型
    • 最好是选择 Push 模型,即服务端有消息后主动 Push 给多个客户端,此时的消费的延时是最低的。
    • 从提高吞吐来看,为了避免服务端堆积,主流消息队列都是通过客户端主动批量 Pull 数据来提高吞吐、避免堆积。一般情况下,Pull 模型都是默认的消费模型。
  • 重平衡
    消息队列一般是通过消费分组(或订阅)消费数据,以便能自动分配消费关系和保存消费进度。此时当消费重平衡时,为了重新分配消费关系,所有的消费都会暂停,从而会影响到消费性能。
    • 像 Flink 等流式计算引擎,都会绕过消费分组,指定分区进行消费,以避免重平衡带来的性能下降。
    • 而 RocketMQ 为了解决重平衡问题,就将重平衡移动到了 Broker 端,尽量降低消费重平衡带来的性能影响。
  • 消费模式
    • 在分配消费关系的时候,如果以分区粒度将分区分配给一个消费者,此时当消费者性能有差别时,就会出现消费倾斜,导致分区堆积,从而影响性能。
    • 如果是以消息粒度投递数据,即一个分区的数据能够投递给不同的消费者,此时就不会出现性能问题,性能是更高的,但是消息数据的顺序性无法保证。
  • 位点提交
    客户端如果存在错误提交消费位点(Offset)的情况,比如应该提交 Offset 却没有提交,就会导致重复消费;或者不应该提交 Offset 却提交了 Offset,就会导致消费者没有消费到应该消费的数据,从而导致下游认为数据丢失。
    当消费完成,需要提交位点后才能消费下一份数据,此时如果提交位点的请求过慢,也会影响消费的性能。有的消息队列会同时支持单条 ACK 和批量 ACK 的特性,正常来讲批量 ACK 的性能更高。
    在消费端,对性能影响最大的是网络链路的耗时。网络耗时会极大影响消费的性能,特别是在跨区、跨地域消费的情况下,问题会更加明显。所以我依旧建议你就近部署和消费。

总结

img

集群:如何构建分布式的消息队列集群?

消息队列的集群设计思路

当前业界主流的分布式集群,一般都是基于主从(Master/Slave)思想来设计的。即通过一个组件来管理整个集群的相关工作,比如创建和删除主题、节点上下线等等。这个组件一般叫做 Master(主节点)或 Controller(控制器)。

然后还需要有一个组件来完成集群元数据(比如节点信息、Topic 信息等等)的存储,这个组件一般叫做元数据服务。当然还有一批数据流节点来完成数据的读写和存储工作,这个组件一般叫做 Broker 或者节点。

元数据存储

消息队列集群元数据是指集群中 Topic、分区、配置、节点、权限等信息。元数据必须保证可靠、高效地存储,不允许丢失。
从技术上看,业界主要有第三方存储引擎和集群内部自实现存储两种方案。

  • 第三方存储:
    依赖第三方存储引擎是指直接使用第三方组件来完成元数据信息的存储,比如 ZooKeeper、etcd、单机或者分布式数据库等等。
    • 这种方案的优点是拿来即用,无需额外的开发成本,产品成型快,稳定性较高
    • 缺点是需要依赖第三方组件,会增加额外的部署维护成本,并且受限于第三方组件的瓶颈和稳定性,也可能会有数据一致性问题。
  • 集群内部自实现存储:
    是指在消息队列应用内部自定义实现元数据存储服务,相当于在消息队列集群中实现一个小型的 ZooKeeper。
    • 这种方案的优点是集群内部集成了这部分能力,部署架构就很简单轻量,应用自我把控性高,不会有第三方依赖问题。
    • 缺点是开发成本高,从头开始自研,相对于成熟组件而言,稳定性上短期会比较弱,需要投入时间打磨。

节点发现

我们知道集群是由多个节点组成的,此时组成集群的最基本要求就是:所有节点知道对方的存在或者有一个组件知道所有节点的存在,这样才能完成后续的集群管理和调度。这个过程就是节点发现的过程。
从技术上看,当前业界主要有配置文件、类广播机制、集中式组件三种手段来完成节点发现。

  • 配置文件是指通过配置文件配置所有节点 IP,然后节点启动后根据配置文件去找到所有的节点,从而完成节点发现。
  • 类广播机制是指通过广播、DNS 解析等机制,自动去发现集群中所有节点。比如通过解析 DNS 域名,得到域名绑定的所有 IP,从而发现集群中所有节点。
  • 集中式组件是指所有节点都向集中式组件去注册和删除自身的节点信息,此时这个组件就会包含所有节点的信息,从而完成节点发现。

节点探活

从实现角度来看,一般需要有一个角色来对集群内所有节点进行探活或者保活,这个角色一般是主节点(Master/Leader/Controller)或第三方组件。
技术上一般分为主动上报和定时探测两种,这两种方式的主要区别在于心跳探活发起方的不同。从技术和实现上看,差别都不大,从稳定性来看,一般推荐主动上报。因为由中心组件主动发起探测,当节点较多时,中心组件可能会有性能瓶颈,所以目前业界主要的探活实现方式也是主动上报。

img

主节点选举

主节点的选择一般有相互选举和依赖第三方组件争抢注册两种方式。

  • 相互选举是指所有节点之间相互投票,选出一个得票最多的节点成为 Leader。投票的具体实现可以参考 Raft 算法,这里就不展开。
  • 依赖第三方组件争抢注册是指通过引入一个集中式组件来辅助完成节点选举。比如可以在 ZooKeeper、etcd 上的某个位置写入数据,哪个节点先写入成功它就是 Leader 节点。当节点异常时,会触发其他节点争抢写入数据。以此类推,从而完成主节点的选举。

消息队列的集群架构

所以抽象来看,一般情况下消息队列的集群架构如下所示:

img

其中,Metadata Service 负责元数据的存储,Controller 负责读取、管理元数据信息,并通知集群中的 Broker 执行各种操作。
当完成元数据存储、节点发现、节点探活、主节点选举后,消息队列的集群就创建完成了。

集群运行机制举例

集群构建流程拆解

集群启动其实就是节点启动的过程

img

节点启动大致分为以下四步:

  • 节点启动时在某个组件(如图中的 Controller 或 Metadata Service)上注册节点数据,该组件会保存该节点的元数据信息。
  • 节点注册完成后,会触发选举流程选举出一个主节点(Controller)。
  • 节点会定期向主节点(或 Metadata Service)上报心跳用来确保异常节点能快速被剔除。
  • 当节点异常下线或有新节点上线时,同步更新集群中的元数据信息。

创建 Topic

img

创建 Topic 大致分为以下四步:

  • 客户端指定分区和副本数量,调用 Controller 创建 Topic。
  • Controller 根据当前集群中的节点、节点上的 Topic 和分区等元数据信息,再根据一定的规则,计算出新的 Topic 的分区、副本的分布,同时选出分区的 Leader(主分片)。
  • Controller 调用 Metadata Service 保存元数据信息。
  • Controller 调用各个 Broker 节点创建 Topic、分区、副本。

Leader 切换

因为集群会监听所有 Broker 的服务状态。当 Broker 挂掉时,Controller 就会感知从而触发 Leader 切换操作。下面是一张 Leader 切换的流程图。

img

Leader 切换的流程可以分为以下四步:

  • Controller 会持续监听节点的存活状态,持续监控 Broker 节点是否可用。
  • 根据一定的机制,判断节点挂掉后,开始触发执行 Leader 切换操作。
  • Controller 通过 RPC 调用通知存活的 Broker2 和 Broker3,将对应分区的 Follower 提升为 Leader。
  • 变更保存所有元数据。

从客户端的视角来看,服务端是没有机制通知客户端 Leader 发生切换的。此时需要依靠客户端主动更新元数据信息来感知已经发生 Leader 切换。客户端一般会在接收到某些错误码或者定期更新元数据来感知到 Leader 的切换。

元数据存储服务设计选型

基于第三方存储引擎

一般只要具备可靠存储能力的组件都可以当作第三方引擎。简单的可以是单机维度的内存、文件,或者单机维度的数据库、KV 存储,进一步可以是分布式的协调服务 ZooKeeper、etcd 等等。

从分布式的角度来看,单机维度的存储能满足的场景有限,也会有单机风险。所以业界主流的分布式消息队列都是选用分布式的协调服务,如 ZooKeeper、etcd 等来当集群的元数据存储服务。所以基于第三方存储引擎的集群架构图一般是下面这样子。

img

这是一个由单独的元数据存储集群和多台 Broker 节点组成的消息队列集群。Broker 连接上 Metadata Service 完成节点发现、探活、主节点(Controller)选举等功能。其中 Controller 的角色是由某一台 Broker 兼任的。

集群内部自实现元数据存储

img

从技术实现来看,主要有三个思路:

  • 直接在 Broker 内部构建一个小型的元数据存储集群来提供服务。
  • 通过某些可以内嵌到进程的小型的分布式存储服务来完成元数据的存储。
  • 通过某些可以内置的单机持久化的服务,配合节点间的元数据同步机制来完成元数据的存储。

从业界实现来看,目前第一种和第二种方案都有在使用。第三种方案主要用在单机模式下,问题是要维护多个节点的存储服务之间的数据一致性,有一定的开发工作量,并且保持数据强一致比较难。

ZooKeeper 的集群构建

ZooKeeper 是一个分布式的数据协调服务,本质上是一个简单的、分布式、可靠的数据存储服务。核心操作就是数据的写入、分发、读取和 Hook。从客户端看,主要操作就是写入和读取。从服务端看,主要操作就是集群构建、数据接收、存储、分发和 Hook。

在集群构建上,它会事先在配置中定义好集群中所有节点的 IP 列表。然后集群启动时,会在这些节点之间进行选举,经过多数投票机制,选举出一个 Leader 节点,从而构建成为集群
在节点启动的时候,节点之间就会两两进行通信,触发投票。然后根据票数的多少,基于多数原则,选择出一个 Leader 出来。当 Leader 节点发生宕机或者增加节点时,就会重新触发选举。

img

多数投票是一个经常用到的投票机制,即某个节点获得票数超过可投票的节点的一半后,就可以当选为 Leader。从实现角度,一般是通过集群中节点之间的通信和间隔随机投票的机制来完成投票,以保证能够在短时间内完成选举。

因为 ZooKeeper 只是一个数据存储服务,并没有很多管控操作,Leader 节点就负责数据的写入和分发,Follower 不负责写入,只负责数据的读取。当 Leader 收到操作请求时,比如创建节点、删除节点、修改内容、修改权限等等,会保存数据并分发到多个 Follower,当集群中有一半的 Follower 返回成功后,数据就保存成功了。当 Follower 收到写入请求时,就把写入请求转发给 Leader 节点进行处理。

Kafka 的集群构建

基于 ZooKeeper 的集群

Kafka 将 ZooKeeper 作为节点发现和元数据存储的组件,通过在 ZooKeeper 上创建临时节点来完成节点发现,并在不同的节点上保存各种元数据信息。

img

Kafka 的 Controller 选举机制非常简单,即在 ZooKeeper 上固定有一个节点 /controller。每台 Broker 启动的时候都会去 ZooKeeper 判断一下这个节点是否存在。如果存在就认为已经有 Controller 了,如果没有,就把自己的信息注册上去,自己来当 Controller。集群每个 Broker 都会监听 /Controller 节点,当监听到节点不存在时,都会主动去尝试创建节点,注册自己的信息。哪台节点注册成功,这个节点就是新的 Controller。

Controller 会监听 ZooKeeper 上多个不同目录,主要监听目录中子节点的增加、删除,节点内容变更等行为。比如会通过监听 /brokers/ids 中子节点的增删,来感知集群中 Broker 的变化。即当 Broker 增加或删除时,ZooKeeper 目录中就会创建或删除对应的节点。此时 Controller 通过 Hook 机制就会监听到节点发生了变化,就可以拿到变化节点的信息,根据这些信息,触发后续的业务逻辑流程。

Kafka 集群中每台 Broker 中都有集群全量的元数据信息,每台节点的元数据信息大部分是通过 Controller 来维护的,比如 Topic、分区、副本。当这些信息发生变化时,Controller 就会监听到变化。然后根据不同的 Hook(如创建、删除 Topic 等),将这些元数据通过 TCP 调用的形式通知给集群中其他的节点,以保持集群中所有节点元数据信息是最新的。

基于 KRaft 的集群

从架构的角度,基于 KRaft 实现的 Kafka 集群做的事情就是将集群的元数据存储服务从 ZooKeeper 替换成为内部实现的 Metadata 模块。这个模块会同时完成 Controller 和元数据存储的工作。

img

集群元数据需要分布式存储才能保证数据的高可靠。所以 Kafka KRaft 架构的 Metadata 模块是基于 Raft 协议实现的 KRaft,从而实现元数据可靠存储的。
它的设计思路和 ZooKeeper 是一样的,是主从架构。即通过在配置文件中配置节点列表,然后通过投票来选举出 Leader 节点。这个节点会承担集群管控、元数据存储和分发等功能。

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

分区、副本和数据倾斜

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

img

这是一个三副本的分片,Leader 和 Follower 会分布在三个节点上。控制副本分布的工作,就是由上节课讲到的控制器来完成的。控制器会根据当前的节点、Topic、分区、副本的分布信息,计算出新分区的分布情况,然后调用不同的 Broker 完成副本的创建

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

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

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

这两个缺点中,第一个方案的缺点是比较好解决的,可以通过在 Topic 维度增加分片,并控制分片的 Leader 分布在不同的节点,来降低单分片和单节点的负载。所以在目前消息队列的实现中,一般都是用的第一种方案,即 Master-Slave 的架构。

在这种主从架构中,如果分区分布不合理或者分区数设置过少时,那就有可能会发生数据倾斜。如何解决呢?
在具体实现中可以通过增加分区,然后将不同的分区的 Leader 分布在不同的节点上。此时,我们只要保证每个分区的写入是均匀的,那么就可以避免倾斜问题。

副本间数据同步方式

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

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

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

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

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

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

img

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

CAP 和一致性模型

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

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

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

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

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

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

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

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

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

ZooKeeper 数据一致性和可靠性

img

ZooKeeper 没有副本的概念,只有主从节点的概念,即所有节点上的数据都是一样的。主从节点之间通过 Zab 协议来保证集群中数据的最终一致。
基于该协议,ZooKeeper 通过主备复制、Epoch 等概念来保证集群中各个副本之间数据的一致性。在 ZooKeeper 中,写入只能在 Leader 完成,Leader 收到数据后会再将数据同步到其他 Follower 节点中,Follower 可以负责读取数据。
Zab 协议本质上是最终一致的协议。它遵循多数原则,即当多数副本保存数据成功后,就认为这条数据保存成功了。多数原则的本质是在一致性和可用性之间做一个权衡,即如果需要全部副本都成功,当底层出现问题时,系统就不可用,而最终一致的可靠性又太弱。所以,多数原则是一个平衡且合理的方案,在业界也是用得最多的。
另外,ZooKeeper 需要保证数据的高可靠,不允许丢失。而在多数原则理论中,如果数据只写入到 Leader 和 Follower 中,此时这两台节点同时损坏或者集群发生异常时导致 Leader 频繁切换,数据就可能会损坏或丢失。为了解决这些复杂场景,Zab 协议定义了 Zxid、崩溃恢复等细节来保证数据不会丢失。

Kafka 数据一致性和可靠性

Kakfa 在分区维度有副本的概念,副本之间通过自定义的 ISR 协议来保证数据一致性。在实现中,Kafka 同时支持强一致、弱一致、最终一致。和 ZooKeeper 默认的多数原则不同,Kafka 的一致性策略是在客户端指定的。客户端会指定 ACK 参数,参数值 -1、0、1,分别表示强一致、弱一致、最终一致。

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

img

因为 Follower 是可以批量拉取数据的,所以 Kafka 在副本拉取数据的性能上会高许多。在我看来,这个模型的设计是蛮优秀的,通过批量拉取 Leader 数据来提高一致性的性能。但是这个协议存在的缺点是,实现上比较复杂,需要维护副本线程、ACK 超时时间等机制,并且在一些边界场景,比如 Leader 频繁切换的时候,可能会导致分区的数据发生截断,从而导致数据丢失。在我看来,Kafka 这样设计的原因和它主打性能和吞吐的定位有关,ISR 一致性协议的考虑主要也是围绕这两点展开的。为了解决 Leader 切换、数据截断等问题,Kafka 引入了副本水位、Leader Epoch、数据截断等概念,来保证数据的可用性和可靠性。

Pulsar 数据一致性和可靠性

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

img

如上图所示,数据发送到 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 的实现方式,在客户端实现了更多细节,减轻了内核的工作量。

性能:Java开发分布式存储系统都有哪些常用的编码技巧?

PageCache 调优和 Direct IO

我们一直会听到 PageCache,简单理解它就是内存。写内存性能肯定是最高的。但是 PageCache 并不是万能的,在某些情况下会存在命中率低,导致读写性能不高的情况。遇到这种情况,就需要在业务上进行处理。

img

如上图所示,应用程序读取文件,会经过应用缓存、PageCache、DISK(硬盘)三层。即应用程序读取文件时,Linux 内核会把从硬盘中读取的文件页面缓存在内存一段时间,这个文件缓存被称为 PageCache。

缓存的核心逻辑是:比如应用层要读 1KB 文件,那么内核的预读算法则会以它认为更合适的大小进行预读 I/O,比如 16-128KB。当应用程序下次读数据的时候,会先尝试读 PageCache,如果数据在 PageCache 中,就会直接返回;如果数据不在 PageCache 中,就会触发从硬盘读取数据,效率就会变低。

这种预读机制,在顺序读的时候,性能会很高,因为已经预先加载了。但是有以下三种情况,PageCache 无法起作用。

  • 使用 FIleChannel 读写时,底层可能走 Direct IO,不走页缓存。
  • 在内存有限或者不够用的时候,频繁换页,导致缓存命中率低。
  • 大量随机读的场景,导致页缓存的数据无法命中。

为了解决上面这类 PageCache 无法起作用的场景,有一种解决思路是:通过使用 Direct IO 来模拟实现 PageCahce 的效果。可以绕过操作系统,直接使用通过自定义 Cache + Direct IO 来实现更细致、自定义的管理内存、命中和换页等操作,从而针对我们的业务场景来优化缓存策略,从而实现比 PageCache 更好的效果。

img

通过前面所学我们知道,NIO 中的 FileChannel 主要和 ByteBuffer 打交道,mmap 直接和缓存打交道,而 Direct IO 直接和硬盘打交道。即 Direct IO 是直接操作硬盘中的数据的,不经过应用缓存和页缓存。那么这个思路的核心实现就是:通过自定义 Cache 管理、缓存加载、换页等行为,让这些策略可以满足当前业务和场景的需求。比如在随机读或者内存不够的场景下,提高页缓存的命中率。

FileChannel 和 mmap

Java 原生的 IO 主要可以分为普通 IO、FileChannel(文件通道)、mmap(内存映射)三种。

FileChannel

FileChannel fileChannel = new RandomAccessFile(new File("test.data"), "rw").getChannel();

// 写数据
byte[] data = new byte[1024];
long position = 10L;
fileChannel.write(ByteBuffer.wrap(data)); //当前位置写入数据
fileChannel.write(ByteBuffer.wrap(data), position); //指定位置写入数据

// 读数据
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 10L;
fileChannel.read(buffer); // 当前位置读取1024byte的数据
fileChannel.read(buffer,position); // 指定位置读取 1024byte 的数据

从技术上看,FileChannel 大多数时候是和 ByteBuffer 打交道的,你可以将 ByteBuffer 理解为一个 byte[] 的封装类。ByteBuffer 是在应用内存中的,它和硬盘之间还隔着一层 PageCache。

img

从使用上看,我们通过 filechannel.write 写入数据时,会将数据从应用内存写入到 PageCache,此时便认为完成了落盘操作。但实际上,操作系统最终帮我们将 PageCache 的数据自动刷到了硬盘。这也是 FileChannel 提供了一个 force() 方法来通知操作系统进行及时刷盘的原因。

mmap

MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, filechannel.size();

byte[] data = new byte[1024];
int position = 10;

// 从当前位置写入1kb的数据
mappedByteBuffer.put(data); 

// 从指定位置写入1kb的数据
MappedByteBuffer subBuffer = mappedByteBuffer.slice(); 
subBuffer.position(position);
subBuffer.put(data);

mmap 是一个把文件映射到内存的操作,因此可以像读写内存一样读写文件。它省去了用户空间到内核空间的数据复制过程,从而提高了读写性能。如下图所示,mmap 的写入也是先把数据写入到 PageCache,不是直接把数据写到硬盘中。它的底层借助了内存来加速,即 MappedByteBuffer 的 put 实际是对内存进行操作。具体刷盘依赖操作系统定时刷盘或者手动调用 mappedByteBuffer.force() 刷盘。

img

从经验来看,mmap 在内存充足、数据文件较小且相对固定的场景下,性能比 FileChannel 高。但它有这样几个缺点:

  • 使用时必须先指定好内存映射的大小,并且一次 Map 的大小限制在 1.5G 左右。
  • 是由操作系统来刷盘的,手动刷盘时间不好掌握。
  • 回收非常复杂,需要手动释放,并且代码和实现很复杂。

预分配文件、预初始化、池化

在提高文件写入性能的时候,预分配文件是一个简单实用的优化技巧。比如前面讲过,消息队列的数据文件都是需要分段的,所以在创建分段文件的时候,可以预先写入空数据(比如 0)将文件预分配好。此时当我们真正写入业务数据的时候,速度就会快很多。

对一些需要重复用到的对象或者实例化成本较高的对象进行预初始化,可以降低核心流程的资源开销。

还一点就是对象池化,对象池化是指只要是需要反复 new 出来的东西都可以池化,以避免内存分配后再回收,造成额外的开销。Netty 中的 Recycler、RingBuffer 中预先分配的对象都是按照这个池化的思路来实现的。

直接内存(堆外)和堆内内存

堆内和堆外的堆是指 JVM 堆,堆内内存就是指 JVM 堆内部的内存空间,堆外就是指除了 JVM 堆以外的内存空间。堆内内存加上堆外内存等于总内存。

虽然概念熟悉,但你知道什么时候使用堆内内存,什么时候使用堆外内存吗?关于堆内内存和堆外内存的选择,我有下面五点建议:

  • 当需要申请大块的内存时,堆内内存会受到限制,可以尝试分配堆外内存。
  • 堆外内存适用于生命周期中等或较长的对象。
  • 堆内内存刷盘的过程中,还需要复制一份到堆外内存,多了一步,会降低性能。
  • 创建堆外内存的消耗要大于创建堆内内存的消耗,所以当分配了堆外内存之后,要尽可能复用它。
  • 可以使用池化 + 堆外内存的组合方式。比如代码中如果需要频繁 new byte[],就可以研究一下ThreadLocal<ByteBuffer> 和 ThreadLocal<byte[]>的使用机制。

同步刷盘

是把数据的可靠性选择交给用户,如果为了更高的性能,那就选择异步刷盘,如果选择可靠性,就同步刷盘,接受性能下降。

那有办法提高同步刷盘的性能吗?从应用程序的角度来看,可以通过批量同步刷盘的操作来提高性能。批量同步刷盘的核心思路是:每次刷盘尽量刷更多的数据到硬盘上。技术上是指通过收集多线程写过来的数据,汇总起来批量同步刷到硬盘中,从而提高数据同步刷盘的性能。

img

业务线程 T1 到 T6 的数据通过内存将数据发送给 IO 线程。然后业务线程进入 await 状态,当 IO Thread 收集到一定的数据后,再一起将数据同步刷到硬盘中。最后唤醒 T1 到 T6 线程,返回写入成功。

这个方案和 PageCache 写入的最大区别在于:fileChannel.write() 只要写 PageCache 成功就会直接返回,后续的刷盘动作交给操作系统去执行,在客户端看来数据已经写入成功了,但是底层却没有写入成功。而新方案中写入线程是可以感知到刷盘的结果的,当同步刷盘失败,则 IO 线程会通知业务线程写入失败,业务线程就会进行重试或给客户端返回失败。

另外,在主流的消息队列中,为了避免同步刷盘,常用的方案是通过多副本机制来实现数据的高可靠。如下图所示,数据会写入到多个副本中,每个副本只写入到本节点的 PageCache,然后就返回成功。因为多台节点同时挂掉的概率很低,所以消息丢失的概率也就很低了。

新的存储 AEP

随着存储技术的发展,Intel 推出了基于 3D Xpoint 技术的新型存储介质傲腾内存(AEP)。相比普通内存,AEP 拥有有大容量、非易失的特点,相当于大容量的持久化的内存。从使用上看,由于它的使用方式和普通内存不一样,并且成本较高,导致它没有被大规模应用。但是作为一个存储介质,它的优点就是快,速度比 SSD 快出 1~n 个数量级。所以在一些追求极限性能的场景中,比如某些金融级的消息队列,此时应用程序已经无法再提升性能了,就得依靠更好的硬件来提高性能,这时候我们就可以选择 AEP 作为存储介质。

线程绑核

负责各种功能的线程很多,比如处理请求、处理 IO、清理数据等等,此时就会有 CPU 争用和线程切换的情况。频繁的线程切换会导致性能下降,而 IO 线程占用的资源和时间较多,切换成本较高。

所以在一些追求性能和隔离性的场景中,我们可以通过线程绑核的操作来实现更好的性能。比如在一些重 IO 的操作中,留几个核心专门给 IO 线程使用,这样就可以完全避免 IO 线程的时间片争用。

在 Java 中实现绑核操作,可以通过这个项目来实现:https://github.com/OpenHFT/Java-Thread-Affinity。

SSD 的 4KB 对齐

SSD 4KB 对齐指的将 SSD 盘的物理扇区和逻辑扇区对齐,从而提高 SSD 盘读写性能的一种技巧,是 SSD 盘经典的优化技巧。

底层大致的原理是:如果读取一次数据,要跨多个物理扇区,那么性能就会下降;如果每次读取刚刚好是读一个物理扇区的数据,那么性能就会很高。

img

可以看到,如果读取的刚好是一个物理扇区,那么就不需要跨物理扇区读,读取性能就会更高。

基于这个理论,我们在使用 SSD 的时候,如果追求性能,并且场景合适,可以使用这个技巧来提高性能。但是在实际系统中,特别是在消息系统,因为用户的消息的大小是动态变化的,消息基本不可能是 4KB 的整数倍。所以如果强制要求 4KB 对齐,就需要进行空数据填充,当消息量多的时候,填充行为可能会导致额外的硬盘空间浪费。

安全:身份认证、资源鉴权和加密传输都是怎么实现的?

从消息队列架构全流程拆解的角度,消息队列的系统安全由六部分组成:网络隔离、传输安全、集群认证、资源授权、自我保护、数据加密。

img

网络隔离的安全性

不管是什么系统,从安全的角度来看,最完美的保护就是网络隔离。这很好理解,一个完全隔离封闭的网络,是不会存在网络安全问题的,因为别人根本无法访问它。

数据传输过程加密

从技术的角度来看,数据传输安全的核心是 SSL/TLS,你可以简单理解成,如果要保证传输过程中的数据安全,就要用 SSL/TLS。消息队列也是这个逻辑,几乎所有的消息队列产品,传输过程中的加密机制都是基于 SSL/TLS 实现的,

SSL 和 TLS 是同一个东西。SSL 3.0 及之前的版本叫 SSL,3.0 之后叫做 TLS,TLS 是 SSL 的升级版。

img

连接建立时的身份认证

加密传输,只能解决数据在网络传输过程中的安全性,此时消息队列集群资源还处于一个门户大开的状态,只要网络能通,集群就能被直接连接访问。为了解决这个问题,就需要开启集群认证。集群认证,通俗解释就是消息队列中的登录功能。

img

我们需要先来看一下身份认证的原始需求是什么,它其实包含两个方面:完成身份认证、支持多种认证方式。
为了支持多种认证方式,一般会包含认证框架和具体认证实现两部分。认证框架负责制定认证的规则和实现机制,多种具体的认证机制就基于框架制定的规则和机制来实现不同的认证方式。

框架和实现

最简单的认证框架,可以是一个 Java 的接口定义。具体代码也很简单,先定义一个接口 AuthenticationProvider,接口中包含了 authenticate 方法,只要实现了这个接口的类都是认证实现类,都可以执行认证操作。

public interface AuthenticationProvider extends Closeable {
  @Deprecated
  default String authenticate(AuthenticationDataSource authData) 
  throws AuthenticationException {
   throw new AuthenticationException("Not supported");
  }
}

身份认证框架

img

Kafka 的每种认证方式上都包含了 SASL,那什么是 SASL 呢?SASL 的全称是 Simple Authentication and Security Layer,翻译过来就是简单身份验证和安全层,你可以把它理解为一个框架,在这个框架上扩展各种身份验证提供程序就可以了。
所以,Kafka 在开发的时候引入了 SASL,然后基于 SASL 实现各种认证插件,比如 GSSAPI、PLAIN、SCRAM、OAUTH 等等,程序上就可以顺利集成各种认证机制了。

img

RabbitMQ 的实现机制和 Pulsar 类似。都是在内核提供了一个自定义实现的、可插入的身份验证框架,基于认证接口实现各种认证机制,并在配置文件中指定要启用的认证插件和参数,然后开启认证的。

img

Kafka 和 Pulsar/RabbitMQ 最大的区别在于认证框架的选择。SASL 框架的机制更完善,基于 SASL 需要遵循编码规范和机制,相对复杂,同时功能也相对较强,这是可预期的。

身份认证实现

用户名 + 密码的机制

Kafka 的 PLAIN、SCRAM,RabbitMQ 的 PLAIN、AMQPPLAIN,RocketMQ 的 AccessKey 和 SecretKey 都属于这类,用户名 + 密码完成身份认证,区别在于底层的实现不一样。
比如 Kafka 的 PLAIN,是把用户账户文件配置到一个静态文件中,每次想要添加新的账户,都需要重启 Kafka 去加载静态文件,才能使之生效,十分不方便。SCRAM 就在这个基础上升级,将用户名和密码存在了 ZooKeeper 上,可动态变更用户名和密码。以此类推。

Kerberos

Kerberos 是一种计算机网络授权协议,用来在非安全网络中对个人通信进行身份认证,属于一种标准的授权协议。很多组件中都会支持它,所以在很多消息队列产品或者大数据产品的认证模块中,我们都会经常看到。
使用 Kerberos 的时候,一般需要先配置一个 Kerberos 配置中心,然后消息队列配置上配置中心的相关信息,收到客户端的验证请求的时候,通过 Kerberos 配置中心完成认证。

OAuth 认证

你应该非常熟悉,在 Web 开发中用得很多。它的授权过程简单理解就是获取令牌(Token)的过程。它允许用户以 Token 的形式,授权第三方应用访问他们存储在另外服务提供者上的信息。其他的比如 JWT、原始 Token 授权、mTLS 等等,原理是类似的,区别是不同厂商推出的满足不同特定的场景下的认证机制。

集群资源的访问控制

因为在很多场景下,比如一个公司规模不大的时候,整个公司会共用一套集群。此时如果一些部门的数据不能让其他部门看到,身份认证就不能满足需求了。

数据类和资源类操作控制

集群有两类操作,一种是集群资源类的操作,比如主题和用户信息的创建删除、限流配额信息的配置。一种是数据资源类的操作,比如生产消费某个数据。

因为职能不同,比如研发一般是不允许执行集群的配置变更的。所以这两类操作是需要隔离的。这两类资源的访问控制,在实现上有两种思路。

  • 独立两条链路,比如数据操作(生产和消费)使用 TCP 链路,集群资源的操作使用 HTTP 链路。
  • 同一条链路上实现两种操作,数据操作和集群资源操作在同一条 TCP 或 HTTP 链路上完成,然后通过接口或资源类型维度的鉴权来实现管控。

访问控制机制 ACL

即使完成了数据和资源链路的独立,我们在数据链路内还是会包含生产、消费两个行为,另外可能有幂等、事务等特性,在资源链路里面也会有资源的增删改查等操作。在一些情况下,我们需要对这些操作做更细粒度的控制,这时候就需要访问控制技术(ACL)登场了。

ACL 全称是访问控制机制,简单来说,就是解决“某个资源能不能被访问,能被谁访问”的问题。因此 ACL 包含两部分:一是定义好哪些行为和资源需要进行鉴权,二是如何实现鉴权。

从被访问主体的角度(即哪些行为和资源需要鉴权),一般分为三类。

  • 资源:主要对主题 / Queue、消费分组 / 订阅、集群三类资源做访问控制。另外一些消息队列独有的概念也会有需要做访问控制,比如 RabbitMQ 的 Exchange。
  • 操作:主要分为读、写、创建、删除、修改、配置等,比如允许生产消费数据、允许创建删除修改 Topic、允许修改集群配置。
  • 接口:一般会限制对集群接口的访问,比如限制某些用户不能访问某些接口。

从访问控制主体的角度(即如何实现鉴权),一般需要包含用户和 IP 两个维度。

  • 用户维度就是指控制某个用户的访问权限。
  • IP 维度是指这个资源只能从某个或某些 IP 发起访问。

那访问控制机制具体如何实现呢?

其实非常简单,在实现上就一个函数的工作量,流程分为两步。

  • 请求接入的时候,获取到当前连接的用户信息或者 IP 信息。
  • 在请求处理的开始,调用访问控制的实现函数(比如 authorizeByResourceType),传入当前访问的操作(比如生产、消费、配置)以及用户或 IP 信息,和内存中的授权数据比较,返回是否具有权限。

虽然鉴权的实现逻辑比较简单,但在具体编码实现上,消息队列一般都会支持一个可插拔的鉴权机制,即可以通过配置自定义的鉴权类来实现自定义的鉴权。

超级用户

访问控制中还有一个非常必要的角色——超级用户。
在系统中有一个默认的超级用户,是非常必要的。如果没有超级用户,一旦分配出去的用户被不小心或者恶意修改,系统就无法恢复访问了,超级用户的存在可以很好地避免这个问题。另外,在系统运维过程中,超级用户会带来很多管理上的便利,比如运维负责人的临时、紧急状态的操作。
超级用户的配置一般是写死固定在配置文件当中的,不能被修改和删除。在内核中会读取超级用户信息,然后在访问控制的时候,对是否是超级用户进行单独判断。Kafka 就有这个机制,而 RabbitMQ 没有,导致 RabbitMQ 在日常运营过程中总会不时遇到用户密码被改动的情况,需要特殊处理恢复访问,运营很不方便也不安全。

20|安全:如何设计高吞吐和大流量分布式集群的限流方案?

数据维度的自我保护主要指如何保证服务端数据的安全,不被窃取。服务维度的自我保护主要指集群的限流机制,通过限制客户端的流量、请求、连接等保护自身不被击垮。

集群中的数据加密

这个问题希望达到的效果是:当客户端发送消息 A(比如 hello world)到 Broker,Broker 保存到磁盘的数据是一串内容 A 加密后的字符串,当消费端消费数据时,Broker 将加密后的字符串解密成消息 A 返回给消费端。

要实现这个效果一般有以下两种方案:

  • 第一种就是上面说的,由服务端自动化做好加密解密。工作量全在服务端,客户端没有任何工作量。
  • 第二种是客户端在生产的时候自助做好加密逻辑,在消费的时候自助做好解密操作。好处在于消息队列服务端没有任何工作量,坏处在于工作量全部在客户端,所有的客户端和消费端都需要感知加密的逻辑,在编码和协调各方的成本方面较高。

如果需要第一种方案,技术上要怎么实现呢?主要分为三步。

  • 首先,把接收到的数据解析为生产端发送出来的原始消息格式。需要解析是因为客户端可能设置了批量发送、压缩等行为,导致服务端收到的格式和原始的消息不一致。
  • 其次,对这些消息进行加密,并存储到磁盘。
  • 最后,在客户端消费数据时,把加密的消息解密成为原始接收到的消息格式返回给客户端。

这里最需要关注的是第二步,加密算法的选择,核心依据是:可以解密获得原始数据、加解密速度快、安全性高。
加密算法分为可逆加密和不可逆加密。可逆加密意思就是经过加密后的数据,能够经过解密步骤还原出原始数据,主要算法有 DES、AES 等。不可逆加密就是指加密后的消息无法还原出原始数据,主要算法有 MD5、SHS 等。从需求来看,我们肯定需要选择可逆加密类的算法。
它们俩最大的区别在于加解密的速度和安全性。对称加密的优点是解密速度快,但保密性差,主要算法有 DES、3DES、AES 等。非对称加密的优点是加密算法保密性好,但是加解密速度要远远低于对称加密,主要算法有 RSA、DSA、ECC 等。因为消息队列需要较高的性能,并且数据的加解密都是在服务端内核完成的,安全性较高。所以我们一般选择对称加密算法,比如 AES。

加解密的过程会消耗很多 CPU 资源,对程序的性能和吞吐影响较大。所以一般情况下,服务端需要配备一些加密算法硬件加速设备,以提高加解密的速度。比如英特尔的 AES-NI,它是商品硬件中最常见的加密加速器。

不过目前业界主流开源消息产品都是不支持消息加密的,我们无法用第一个方案,但一些闭源的商业化消息队列产品具备这个能力。在我看来,这是一个刚需能力,特别在银行、证券行业的服务部署到公有云的场景中。所以,我一般推荐你用第二种方案。

消息队列限流机制思考

img

在业务系统中,这四种算法都比较常用。因为消息队列经常需要应对突发流量,需要尽量平滑的限流机制,所以从实现上来看会比较推荐令牌桶算法。
限流的实现机制主要分为两种:单机限流和全局限流。

  • 单机限流就好理解了,限流配额发送到单机维度,在内存中完成计数、比对、限流决策。它的优点是在单机内存内完成限流逻辑的闭环,几乎不影响主流程的耗时。缺点是集群部署时,无法在多台节点之间共享集群信息,从而导致无法进行集群维度的限流。

img

  • 全局限流,一般基于第三方的集中式服务来实现分布式的多机限流。在集中式服务中完成配额的记录、限流判断等行为,各个服务节点,通过对中心服务的上报和访问完成限流。在第三方组件的选择上,主要有 Redis、Sentinel、ASAS、PolarisMesh 等。优缺点跟单机限流刚好相反,能在多节点之间完成限流信息的共享,但是在限流操作上的耗时较高。

img

全局限流还是单机限流?

当前主流消息队列产品,主要选择还是单机限流机制,比如 Kafka、Pulsar。思路是:放弃集群维度的精准限流,将集群总的配额根据节点数据量均分到每个节点,在每个节点内部完成单机限流。

对哪些资源和维度进行限流

主要对流量、连接数、请求数三类资源进行限流,有些消息队列还会对 CPU 和内存进行限制。
限制的维度一般包括:集群、节点、租户 / Namespace、Topic、Partition、Group/Subscribe 六个维度。

img

  • 流量限制指对生产、消费的流量限制,是消息队列的核心限流指标。因为很多问题都是流量波动引起的,限制好集群的流量,很大程度上能保证集群的稳定。所以你会在各款消息队列里看到对流量的限制。一般会对集群、节点、租户 / Namespace、Topic、Group/Subscribe 这几个维度配置。

  • 连接数限制指对客户端连接到服务端的 TCP 连接数量进行限制。因为 TCP 连接的建立和关闭需要消耗 CPU、内存等资源,限制是为了保护服务端不会因为连接数太多,耗尽资源,导致服务不可用。虽然现在技术上的网络编程有异步 IO、多路复用等技术,但是连接太多还是会出现问题。所以 RabbitMQ 在连接的基础上设计了 Channel 信道,避免 TCP 连接频繁建立关闭、TCP 连接数太多。连接数的限制一般在集群、节点、租户 / Namespace 三个维度配置。

  • 请求数限制指对单个接口的访问频次进行限制,来保护集群自身的可用性。比如消息队列中的获取元数据(Lookup、寻址)接口,这个接口一般需要返回所有 Topic、分区、节点的数据,需要做很多获取、组合、聚合的操作,很消耗 CPU。在客户端很多的情况下,如果客户端同时更新元数据,很容易把服务端的 CPU 耗完,导致集群生产消费异常。请求限流就能起很好的保护作用。连接数的限制一般在集群、租户 / Namespace 两个维度配置。

发生限流后怎么处理

因为消息队列本身的功能是削峰填谷,在有突发流量的时候,流量很容易超过配额。此时,机器层面一般是有能力处理流量的,如果直接拒绝流量,就会导致消息投递失败,客户端请求异常。所以,在限流后,我们一般有两种处理形式。

  • 返回超额错误,拒绝请求或流量。
  • 延时回包,通过加大单次请求的耗时,整体上降低集群的吞吐。因为正常状态下,客户端和服务端的连接数是稳定的,如果提升单次处理请求的耗时,集群整体流量就会相应下降。

Kafka 的主要处理机制是延时回包。延时回包的优点是可以承载突发流量,当有突发流量时,不会对客户端造成严重影响,缺点是无法精准限制流量。比如 Kafka 在 Ack=0 的时候或者客户端不断新建连接打入流量的时候,客户端的流量会突破服务端的限制,极端情况下会打爆集群。此时就需要配合返回超额错误拒绝流量的策略,以达到保护集群的目的。

延时回包的算法设计起来比较复杂,我们看一个例子,Kafka 的延时回包算法代码(https://github.com/apache/kafka/blob/trunk/core/src/main/scala/kafka/server/ClientQuotaManager.scala)。

img

在算法的核心逻辑中,O 是观测到的速率,T 是 W 窗口内的目标速率,为了让 O 降到 T,我们需要给 W 增加一个 X 的延迟,使得 O * W / (W + X) = T。求 X,得到 X = (O - T)/T * W。如果你自己实现延时回包,这个逻辑很值得参考。

消息队列全局限流设计

单机限流方案

单机限流的方案思路比较简单,首先将集群总配额除以集群总的节点数,得到每个节点上可用的配额。在各个节点下发配额数据,然后在单机维度使用漏斗算法等算法,实现单机维度的限流。

  • 计算单节点的配额。
  • 存储每个节点的配额信息,以免节点重启后配额信息丢失,比如 Kafka 和 Pulsar 都是存储在 ZooKeeper 上。
  • 为每个节点下发变更配额信息,节点在重启的时候加载配额信息。
  • 当生产消费的时候,在内存存储计算流量,并和配额数据进行比较,确认是否限流。

img

单机限流还有一个小优化,我们可以实时监控每台节点的限流情况,动态修改每台节点的配额。通过判断,给流量较高的节点分配较多的配额,给流量较少的节点分配较少的配额,从而在流量倾斜的时候,也能够做到较为精准的限流。

全局限流方案

从技术上看,全局限流方案思考的核心有三点。

  • 对当前主流程不能影响或者影响极低。
  • 限流的精度需要仔细权衡,需要考虑限流是否足够精准,是否会有倾斜。
  • 需要设计好回退措施,即限流组件抖动时,不能影响主流程。

接下来我们来看看全局限流的方案思路,相对复杂,主要分为五步。

  • 首先选择一个集中式的限流 Server,你可以选择业界的全局限流的组件,比如 Sentinel 或 PolarisMesh,也可以是消息队列内置实现的一个全局限流的组件,简单点也可以是 MySQL 或者 Redis。
  • 然后把组件中写入限流配额。
  • 在生产和消费时,向限流 Server 记录配额信息,获取限流状态,判断是否进行限流。
  • 同时根据单机限流的方案,在本地缓存一份均分的配额数据,当限流 Server 异常时,直接使用本地缓存的配额数据进行计算限流。
  • 同时提供开关,在某些情况下可以关闭限流。

另外,在代码实现层面,我们还可以插件化地支持多种限流机制,通过配置可生效。从代码实现流程来看,如下图所示,具体实现可以分为五步。

  • 往限流 Server 写入集群配额。
  • 同时 Broker 会获取到均摊的配额信息,流程和单机限流方案一样。
  • 生产消费的时候,会判断是否走本地限流逻辑,是的话,跟单机限流方案一样。
  • 正常会走上报数据和获取限流状态的逻辑,进行限流判断。
  • 同时支持开启关闭限流状态,Broker 允许接收指令开启或关闭限流信息。

img

消息队列的服务降级

消息队列中常见的降级策略一般有三种。

  • 配置 Broker 的 CPU 或内存的使用率额度,当使用率到达配额时,通过拒绝生产或消费流量的形式来保证服务的部分正常。
  • 配置磁盘保护机制,可以保护消费不会有异常。当真实的磁盘使用率使用达到一定的程度时,就禁止流量写入。因为在消息队列中,磁盘较容易被打满,打满的话如果还允许写入服务程序就会有异常,从而影响消费。
  • 判断异常自动重启 Broker,通过自动判断服务的运行情况,决定是否重启 Broker。比如当发现频繁发生 Full GC 的时候,就自动重启自身服务,以达到回收资源的目的。这种方式用得比较少,因为比较危险,可能会导致集群中的所有 Broker 频繁重启。一般需要依赖第三方组件的多维度判断,以降低误重启的风险。

可观测性:如何设计实现一个好用的分布式监控体系?

指标需要关注哪几个维度?

单机维度的指标主要分为操作系统、语言虚拟机、应用进程三层。

  • 操作系统:IaaS 层指标的 CPU、内存、网卡、硬盘等等。
  • 语言虚拟机:Java 虚拟机的 GC、线程池、堆容量等等。
  • 应用进程:进程中的生产消费各阶段耗时、接口的请求数、进程文件句柄数量等等。

消息队列有哪些关键指标?

所有的消息队列有通用的核心指标,主要有五类:集群(Cluster)、节点(Node/Broker)、主题(Topic)、分区 / 队列(Partiton/Queue)、消费分组 / 订阅(Group/Subscription)。

集群

集群指标主要是集群资源数量相关的,比如主题数量、分区数量、节点数量等集群维度的信息,这类信息的影响在于数量。如果某些资源数量过大,有可能影响集群的稳定。

节点

节点指标一般包含节点的生产消费的吞吐量、耗时、消息数,接口的请求数、错误码、耗时,JVM FullGC、YongGC 的次数,节点的 TCP 连接数等等。

主题和分区

主题指标一般是主题维度的吞吐量、消息条数、生产和消费耗时数据。当业务反馈只有某些主题异常时,这些指标可以用来定位问题。

消费分组和订阅

消费分组 / 订阅的指标一般是消费速度、未消费的消息数量(堆积数)。平时用来观察消费的情况,比如消费速度是否跟得上、是否有堆积等等。或者当消费出现异常的时候,我们可以通过这个指标用来判断消费速度是否有问题,然后结合主题和分区维度的指标,最终确认问题。

除了通用的指标,不同的产品因为架构实现不一样,会有各自的独特指标。如果需要进一步定位问题,我们就需要结合这些指标去分析。比如 Kafka 的 Controller 和协调器、Pulsar 的 Ledger、RabbitMQ 的 Exchange 相关的指标,这部分主要和稳定性相关,需要理解各个组件的架构才能理解指标的含义。

如何记录指标?

主流的指标库

从编码实现的角度来看,业界有多种指标的记录方式,如下图所示,比较常见的有以下五种:

  • Java
  • MetricsPrometheus
  • MetricsKafka 基于 Metrics 实现的自定义 KafkaMetrics
  • Golang 的指标库 go-metrics(一般各个语言也会有对应的 Metrics 库)
  • 可观测性标准 OpenTelemetry 中的 Metrics

Prometheus Metrics 提供了四种指标类型。

  • Gauge:记录可增可减的时刻量。
  • Counter:是一个只增不减的计数器。
  • Histogram:直方图,在一段时间范围内对数据进行采样,最后将数据展示为直方图。
  • Summary:概要,反映百分位值,例如某 RPC 接口,95% 的请求耗时低于 100ms,99% 的请求耗时低于 200ms。

可以看到 Java Metrics 库和 Prometheus 指标类型基本相似,但含义和功能并不完全相同。这也是可观测性标准 OpenTelemetry 中特意强调指标(Metrics)的理由,希望在指标定义方面制定一套统一规范,并提供各个语言的代码库,降低重复开发的成本和使用者理解学习的成本。

如何暴露指标?

业界主要指标暴露方案

从技术上看,如下图所示,当前业界主要的指标暴露方案,大致可以分为四种。

  • 自定义 TCP/HTTP 接口

自定义 TCP 接口是指通过服务本身暴露四层的 TCP 接口,来暴露服务内的指标数据。这种方式需要先设计私有协议,然后 Client SDK 封装接口来拉取数据。缺点是私有协议访问,不方便被集成,并且添加定义指标需要修改访问协议,工作量很大。

  • JMX Service Server

JMX(Java Management Extensions)是 Java 提供的一套标准的代理和服务,通过基于 TCP 层的 JMX 协议远程获取数据。早期在 Java 里面用得比较多,近几年用的人相对较少。主要缺点是只能在 Java 里面用,而且只能通过 JMX 私有协议访问。

  • Prometheus 标准接口

Prometheus 是在服务内部启动一个 HTTP 服务,然后暴露 /Metrics 接口,供客户端拉取数据。

  • OpenTelemetry 上报

OpenTelemetry 它定义了一个接收器 Collector,即指标上报方根据 OpenTelemetry 的规范将数据上报到 Collector 中。跟上面三种不一样,前三种是 Pull 模型,OpenTelmetry 是 Push 的模型。在早期,Prometheus 还未发展成熟时,前面两种用得比较多,比如 Kafka 用的是 JMX Service、RocketMQ 5.0 以前用的是集成在 Admin 里面的自定义 TCP Insterface 方式,RabbitMQ 用的是自定义 HTTP 接口。

标准 Prometheus 方案

从技术上看,Prometheus 采集指标主要有下面两种形式。

第一种是在内核中内置 HTTP Server,然后暴露 /metrics 接口,返回 Prometheus 需要的格式数据。天生就支持集成 Peometheus 监控,使用起来很方便,基本没有缺点。Pulsar、RocketMQ5.0 就是这种形式。RabbitMQ 是通过提供 Prometheus 插件来实现的,也可以算是这一类。

img

第二种是额外提供 Export 组件。Kafka、RocketMQ 4.0 就是这种形式。为什么有这种形式呢?因为如果要内置 Prometheus Metrics 接口,首先要内置一个 HTTP Server,然后在指标注册时使用 Prometheus 的格式来注册,以确保符合规范。
但是很多组件已经发展了多年,Metrics 模块已经成熟稳定,投入大力气改造的收益不大。所以一般会先开发一个单独的 Export 组件,使用原先的 TCP/HTTP 方式去拉取指标,然后整合成 Prometheus 需要的格式。最后通过自身暴露的 HTTP /metrics 接口,把指标暴露给 Prometheus 集成。这样既不用改变原先的代码,又能实现 Prometheus 的集成。

img

从集群角度拆解RabbitMQ的架构设计与实现

集群构建

我们前面讲过,集群构建由节点发现和元数据存储两部分组成。RabbitMQ 也是一样的实现思路。
在节点发现方面,RabbitMQ 通过插件化的方式支持了多种发现方式,用来满足不同场景下的集群构建需求。

img

  • 固定配置发现:是指通过在 RabbitMQ 的配置文件中配置集群中所有节点的信息,从而发现集群所有节点的方式。和 ZooKeeper 的节点发现机制是一个思路。
  • 类广播机制发现:是指通过 DNS 本身的机制解析出所有可用 IP 列表,从而发现集群中的所有节点。和 Elasticsearch 通过多播来动态发现集群节点是类似的思路。
  • 第三方组件发现:是指通过多种第三方组件发现集群中的所有节点信息,比如 AWS(EC2)、Kubernetes、Consul、etcd 等。和 Kafka、Pulsar 依赖 ZooKeeper,RocketMQ 依赖 NameServer 是一个思路。
  • 手动管理:是 RabbitMQ 比较特殊的实现方式,是指通过命令 rabbitmqctlctl 工具往集群中手动添加或移除节点。即依赖人工来管理集群,这种方式使用起来不太方便,其他消息队列很少采用这个方案。

再来看一下元数据存储,我们之前说到过,RabbitMQ 的元数据是通过内置数据库 Mnesia 来存储的。这里需要重点注意的是,Mnesia 是一个分布式的数据库,可以简单理解为它是 Erlang 语言自带的内置的分布式数据库(详细参考 Mnesia Wiki)。这点非常重要,因为 Mnesia 已经是分布式存储了,所以在进程启动就具备了元数据存储能力。

数据可靠性

RabbitMQ 集群维度数据可靠性的核心是副本和数据一致性协议。在之前的课程中,我们知道 RabbitMQ 没有 Topic,只有 Queue 的概念。所以是通过在 Queue 维度创建副本来实现数据的高可靠。

RabbitMQ 在创建 Queue 时没有副本概念,即创建出来的 Queue 都是单副本的。如果要支持多副本,在 3.8.0 之前需要通过配置镜像队列来实现,在 3.8.0 后可以使用 Quorum Queue(仲裁队列)来实现。

镜像队列

在 RabbitMQ 中,镜像队列是一个独立的功能。它通过为 Queue 创建副本、完成主从副本之间数据同步、维护主从副本之间数据的一致性等 3 个手段来保证数据的可靠性。

队列的副本数量是通过 All、Exactly、Nodes 3 种策略来设置的。

  • All 是指集群中所有节点上都要有这个 Queue 的副本。
  • Exactly 是指 Queue 的副本分布在几个节点上,可以理解成 Queue 的副本数。
  • Nodes 是指这个 Queue 的副本具体分布在哪几个节点上。

RabbitMQ 的镜像队列是通过内核中的 GM 模块将数据分发到从副本,从而完成主从副本之间的数据同步。GM 模块能保证组播数据的原子性,即保证数据要么能发送到所有模块上,要么不能。副本间数据一致性策略,使用的是强一致的策略。从性能上来看,这个强一致的策略也是影响性能的一个重要原因。

  • 强一致的同步策略很影响性能,导致集群的性能不高。
  • 当节点挂掉后,节点上的队列数据数据都会被清空,需要重新从其他节点同步数据。
  • 队列同步数据时会阻塞整个队列,导致队列不可用。如果数据量很大,同步时间长会导致队列长时间不可用。

仲裁队列

从实现的角度看,仲裁队列是基于 Raft 算法来设计的,依赖 Raft 共识协议来确保数据的一致性和可靠性。

和镜像队列不同的是:当副本挂掉重新启动时,只需要从主节点同步增量数据,并且不会影响主副本的可用性,从而避免了镜像队列的缺点。

在数据一致性方面,仲裁队列通过 Raft 协议来完成副本间的数据同步和一致性。使用的是多数原则,即多数副本写入成功后,就算数据写入成功。

安全控制

传输加密

从内核实现的角度来看,内核对 TLS 的支持是根据 TLS 的官方标准实现的。TLS 是通用的加密通信标准,消息队列对于 TLS 来说只是一个使用者。
从使用的角度来看,需要先创建证书,然后在服务端配置 TLS 相关信息来启用传输加密。客户需要配置公钥等信息来和服务端创建加密的连接。在 RabbitMQ 中支持 TLS,如下图所示,主要有以下两种形式:

  • 直接配置 RabbitMQ 支持 TLS。
  • 在代理或者负载均衡(如 HAProxy)上配置支持 TLS。

img

从安全的角度来看,两种选择并没有太明显的优劣势。在 RabbitMQ Broker 中,当启动 TLS 后,服务端需要指定一个新的端口来支持 TLS。即支持 TLS 和不支持 TLS 的服务端口是不一样的。

身份认证

当前版本的 RabbitMQ 主要支持用户名 / 密码认证和 X.509 证书两种认证形式。
同时通过插件化机制,支持了 LDAP 认证、HTTP 接口认证、IP 来源认证等认证方式。从具体实现的角度,RabbitMQ 基于 SASL 认证框架,实现支持了 PLAIN、AMQPPLAIN、EXTERNAL 三种机制。

在认证配置上,RabbitMQ 支持链式认证。即同时支持多种认证方式,比如在某些高安全要求的场景,需要完成多重身份认证才算认证成功,就可以用到链式认证。

img

资源鉴权

RabbitMQ 的鉴权分为管理页面和数据流两个方面。
管理页面指启用 Management 插件后的 Manager 页面。这个页面可以执行查看监控、创建资源、删除资源等操作,权限很大。所以为了保证集群的安全,需要对这个页面的访问进行权限控制。管理页面的 UI 如下所示:

img

Manager 页面权限控制的方式是,在创建用户时通过指定不同用户的访问角色类型,从而控制不同用户在管理页面上的操作权限。Manager 页面支持 management、policymaker、monitoring、administrator 四种角色类型,分别表示不同的权限粒度,算是一个比较常规的管控页面的权限控制实现方式。

数据流的权限控制,主要包括资源操作(如创建、创建等)、写入、读取三种类型,分别对应 Configure、Write、Read 三种权限。主要支持对 Vhost 和 Exchange 两种资源进行鉴权。

可观测性

RabbitMQ 的监控指标非常丰富且立体,如下图所示,主要可以分为集群、节点、队列、应用程序四个维度。

  • 集群维度:主要包含集群的监控指标,比如 Exchange 数、Queue 数、Channel 数、生产者数、消费者数等等。
  • 应用程序维度:主要包含进程级的监控信息,比如 Socket 连接打开率、Channel 打开率等等。
  • 队列维度:主要包含队列的监控指标,比如队列上的消息总数、未确认的消息总数等等。
  • 节点维度:主要包含节点的信息,比如硬件层面的 CPU、内存、硬盘,操作系统层面的 Socket 连接数、文件描述符数量,Erlang 虚拟机层面的 Erlang 线程数、GC 情况等等。

在某些和内部监控系统或者运营系统集成的场景中,通过 HTTP API 获取监控指标也是常用的方式。在现网排查问题时,直接使用命令行工具 rabbitmq-diagnostics 或 rabbitmq-top 来查看负载,也是非常常用的。下图就是通过 rabbitmq-top 命令行来查看进程监控的结果,非常齐全。

img

值得一提的是,RabbitMQ 内核自带了健康检查机制。即支持通过命令行工具(rabbitmq-diagnostics)或 HTTP API 的方式对集群发起健康检查。检查集群的创建 Exchange、创建 Queue、生产消费消息全流程是否正常。

从集群角度拆解RocketMQ的架构设计与实现

集群构建

RocketMQ 的元数据实际是存储在 Broker 上,不是直接存储在 NameServer 中。NameServer 本身只是一个缓存服务,没有持久化存储的能力,先来看一张图示。

img

元数据信息实际存储在每台 Broker 上,每台 Broker 会在本节点维护持久化文件来存储元数据信息。这些元数据信息主要包括节点信息、节点上的 Topic、分区信息等等。在 Broker 启动时,会先连接 NameServer 注册节点信息,并将保存的元数据上报到所有 NameServer 节点中。此时所有 NameServer 节点就有全量的元数据信息了,从而完成了节点之间的发现。

Broker 和 NameServer 之间会有保活机制,Broker 会定期和 NameServer 保持心跳探测,来确认节点运行正常。当 Broker 异常时,就会被踢出集群。

部署模式

Master/Slave 模式

Master/Slave 模式就是典型的主从架构,和 MySQL 的主从原理一样。

img

集群部署时会先配置 Broker 是 Master 节点还是 Slave 节点。Master 负责写入,Slave 负责备份和读取。早期架构是不支持主从切换的,即当 Master 挂了以后,Slave 无法成为 Master。此时会导致一些只能在 Master 上完成的工作无法完成,比如数据写入、Offset 操作、结束事务等等。

在创建 Topic 时,会建议在多个 Master 节点上同时创建这个 Topic 及其所有分区。所有的 Master 节点都可以同时为这个 Topic 提供服务。当某个 Master 节点挂了后,其他 Master 节点依旧可以提供同样的服务,不影响新数据的写入

  • 这种方式的好处是当负载过高时,可以通过快速横向添加节点来扩容。
  • 缺点是这个模型无法保证生产的消息的有序。节点挂了以后,这个节点上的未消费的数据不能被消费,并且 Topic 和分区数会有放大效应。因为每个节点上都需要创建全量的 Topic 和分区,此时瓶颈就存在单个节点上了。

Dledger 模式

DLedger 模式是为了解决分区选主和主从切换问题而引入的。因为如果要实现主从切换,就需要先保证主从之间数据的一致性,所以 DLeger 的核心是通过 Raft 算法实现的 Raft Commitlog。

img

Dledger 用基于 Raft 算法实现的 Raft Commitlog 代替了原来的 Commitlog,使得 Commitlog 具备了选举和切换的能力。
因为是基于 Raft 算法实现的,所以根据 Raft 算法的多数原则,集群最少必须由三个节点来组成。不同节点的 Raft Commitlog 之间会根据 Raft 算法来完成数据同步和选主操作。当 Master 发生故障后,会先通过内部协商,然后从 Slave 节点中选出新的 Master,从而完成主从切换。

因为实现方式的原因,Deledger 模式最少需要三个节点,并且无法兼容 RocketMQ 原生的存储和复制能力(比如 Master/Slave 模式),而且这个模式维护较困难。

Controller 模式

DLedger Controller 是一个新的部署形态,它的核心是基于 Raft 算法实现了一个选主组件 Controller。Controller 主要用来在副本之间进行 Leader 选举和切换。它是集群部署的,多个 Controller 之间是通过 Raft 算法来完成主 Controller 选举的。

img

如上图所示,Controller 模式跟 Dledger 模式最大的差别在于,Controller 是一个可选的、松耦合的组件,可以选择内嵌在 NameServer 中,也可以独立部署。而且它和底层存储的 Commitlog 模块是独立的,即存储模块不一定非得是 Raft Committlog,也可以是 Commitlog。所以 Controller 可以用在 Master/Slave 模式中,当部署 DLedger Controller 组件后,原本的 Master-Slave 部署模式下的 Broker 组就拥有了容灾切换能力。

Controller 主要由 Active Controller、Alter SyncStateSet、Elect Master、Replication 四个部分组成。

  • Active Controller 是指通过 Raft 算法在多个 Controller 之间选举会选出的主 Controller。
  • Alter SyncStateSet 指分区副本中允许选为 Master 的副本集合。
  • Elect Master 指分区副本间选主操作。
  • Replication 指分区副本间的数据复制的动作。

从运行机制上看,首先会通过 Raft 算法选举出主 Controller。主 Controller 会维护每个分区可用的 SyncStateSet 集合。当节点变动时,Elect Master 会在从 SyncStateSet 集合中选举出新的主节点。主从副本间的数据通过 Replication 模块来完成。

数据可靠性

在 Master/Slave 模式中,RocektMQ 提供了异步复制和同步双写两种模式。

在 Dledger 模式中,因为是基于 Raft 算法实现的 Commitlog。所以在数据一致性上,遵循的是 Raft 的多数原则。

在 Controller 模式中,数据的一致性是可以配置的,可以通过参数 inSyncReplicas 来配置数据写入成功的副本数。比如 3 个副本且 inSyncReplicas 配置为 2,表示写入 2 个副本时算数据写入成功;配置为 1 则表示写入 1 个副本就算数据写入成功,以此类推。同时也提供了 allAckInSyncStateSet 参数,来设置要全部写入成功才算成功。

安全控制

在传输安全方面,RocketMQ Broker 支持 TLS 加密传输。从技术上看,RocketMQ Broker 也是使用标准 Java Server 集成 TLS 的用法来实现的。

在认证方面,当前版本的 RocketMQ 只支持一种明文(PLAIN)的用户名 / 密码认证方式。即先从服务端申请 AccessKey(用户名)和 SecretKey(密码),支持动态申请,然后客户端通过配置传递 AccessKey 和 SecretKey 来完成身份认证。同时 RocketMQ 分为管理员账户和普通账户,管理员账户拥有集群的所有权限,普通账户需要经过授权才能进行某些操作。

在鉴权方面,RocketMQ 支持 Topic 和 Group 两种资源的鉴权。权限分为 DENY、ANY、PUB、SUB 四个类型,分别表示拒绝、全部权限、发送、订阅。同时也支持 IP 白名单的功能,即支持对来源 IP 进行限制。同样的,也支持通过 RocketMQ 的命令行工具 mqadmin 动态增删用户及相关的权限信息,比如通过 mqadmin 查询 ACL 信息。

从底层实现看,用户和权限信息保存在 Broker 上的文件中,并不是存储在某个中央服务上,比如 NameServer。这个设计也符合当前 RocketMQ 元数据存储的实现思路。当变更信息时,就修改文件的内容,Broker 会监听文件的变化,重新加载全量信息。

可观测性

在 5.0 之前的版本中,指标的定义和记录是依赖一个 Broker 内部自定义实现的指标管理器 BrokerStatsManager 来实现的。通过在内存中维护一个 Map 来记录不同的指标,主要支持 Broker、Producer、Consumer Groups、Consumer 四个维度的指标。指标暴露方式是通过 RocketMQ Export + RocketMQ Remoting 来实现的。

img

这种方式主要有 3 个缺点:

  • Broker 指标定义不符合开源规范,难以和其他开源可观测组件搭配使用;
  • 大量 RPC 调用会给 Broker 带来额外的压力;
  • 拓展性较差,当需要增加或修改指标时,必须先修改 Broker 的 Admin 接口。

为了解决这些问题,RocketMQ 在 5.0 版本重构了指标模块。新版的 RocketMQ 基于 OpenTelemtry 规范完全重新设计实现了指标模块。在指标数量方面,新的指标模块在之前版本的基础上,支持了更多维度、更丰富的指标,比如 Broker、Proxy 等。
在指标暴露方面,新版的指标模块提供了 Pull、Push、Export 兼容三种方式。

img

RocketMQ 的底层的日志,使用的是 Java 中标准的 Logback 和 SLF4J 日志框架进行日志记录,因此日志就天然具备日志分级(ERROR、WARN、INFO 等)、日志滚动、按大小时间保留等特性。在日志格式定义方面,RocketMQ 通过独立的日志库来进行封装,属于常见的标准用法。

RocketMQ 的消息轨迹,在我看来是消息队列里面支持得最好的了。因为完整的消息轨迹需要包含生产者、Broker、消费者三部分的信息,如果需要支持生产端和消费端的轨迹信息,就需要在客户端 SDK 中集成轨迹信息上报的功能。RocketMQ 在生产端和消费端实现了这个功能,而其他大部分消息队列在 SDK 是没有这个功能的。

img

RocketMQ 的生产端和消费端的 SDK 集成了轨迹信息上报模块。当数据发送或消费成功时,如果开启轨迹上报,客户端会将轨迹数据上报到集群中的内置 Topic 或者自定义 Topic 中。因此 Broker 端就保存有全链路的轨迹信息了。

img

从集群角度拆解Kafka的架构设计与实现

数据可靠性

Kafka 集群维度的数据可靠性也是通过副本来实现的,而副本间数据一致性是通过 Kafka ISR 协议来保证的。ISR 协议是现有一致性协议的变种,它是参考业界主流的一致性协议,设计出来的符合流消息场景的一致性协议。

ISR 协议的核心思想是:通过副本拉取 Leader 数据、动态维护可用副本集合、控制 Leader 切换和数据截断 3 个方面,来提高性能和可用性。

img

这是一个包含 1 个 Leader、2 个 Follower 的分区。如果是基于 Raft 协议或者多数原则实现的一致性算法,那么当 Leader 接收到数据后,就会直接分发给部分或全部副本。我们在前面两节课说到的 RabbitMQ 的镜像队列、仲裁队列和 RocketMQ 的 Master/Slave、Dledger 都是这么实现的。

这种机制在流消息队列的大吞吐场景中主要有两个缺点:

  • 收到数据立即分发给多个副本,在请求量很大时,和副本之间的频繁交互会导致数据分发的性能不高。
  • 计算一致性的总副本数是固定的,当某个副本异常时,如果还往这个副本分发数据,此时会影响集群性能。

副本拉取 Leader 数据

为了提高数据分发性能,主要的解决思路就是数据批量分发。所以在实现上看,Kakfa 是通过 Follower 批量从 Leader 拉取数据来完成主从副本间的数据同步,并提高性能的。
比如当 Leader 接收到 1000 次数据,在 Leader 主动分发的场景中,就需要往两个 Follower 分别发起 1000 次请求。而在 ISR 模型下,假设 Follower 每批次拉取 50 条数据,此时每个 Follower 和 Leader 间的网络交互次数就减少到了 20 次,从而极大地提高了一致性的性能。

如果是 Follower 来服务端拉取数据,那么当数据写入 Leader 后是直接告诉客户端写入成功吗?

img

从实现的角度看,当配置最终一致(ACK=1)或者强一致(ACK=-1)时,Leader 接收到数据后不会立即告诉客户端写入成功。而是请求会进入一个本地的队列进行等待,等待 Follower 完成数据同步。当数据被副本同步后,Leader 才会告诉客户端写入成功。

等待这个行为的技术实现的核心思想是 Leader 维护的定时器,时间轮。简单理解就是时间轮会定时检查数据是否被同步,是的话就返回成功,否的话就判断是否超时,超时就返回超时错误,否则就继续等待。

动态维护可用副本集合

即有一个三副本的分区,当服务都正常时,可用副本集合就有 3 个元素,比如 [A、B、C]。当某个副本异常时,比如副本宕机或副本性能有问题无法跟上 Leader 时,就会自动把这个副本剔出可用副本的集合,如下所示:

[A、B、C] => [A、B]

那怎么判断副本有异常呢?从技术实现来看,一般通过两种策略来判断。

  • 副本所在节点是否宕机,如果副本的节点挂了,就认为这个副本是不可用的。那如何判断副本的节点挂了呢?那就是我们在前面讲到的节点的心跳的探活机制。
  • 副本的数据拉取进度是否跟不上 Leader,即副本来 Leader Pull 数据的速度跟不上数据写入 Leader 数据的速度。此时如果 Follower 一直追不上 Leader,这个 Follower 就会被踢出 ISR 集合。

在 Kafka 的实现中,最开始支持按数据条数去判断 Follower 是否跟得上 Leader。但是因为不同 Topic 的流量不一样,根据数据条数很难准确判断落后情况。后来支持按照时间来判断落后情况,比如 Follower 落后 Leader 多久,则表示 Follower 跟不上 Leader。当然,这是一个配置,可以调整。

控制 Leader 切换和数据截断

当出现 Leader 切换,如果所有节点的数据是强一致的,则直接进行 Leader 切换,不需要任何其他的处理,也不会有数据丢失的问题。这种方式实现是最简单的,开发成本也最低,但是性能较差。比如 RabbitMQ 的镜像队列和 RocketMQ 的 Master/Slave 的同步双写就是这种方案。

因为 Kafka ISR 协议是最终一致的,所以在某些极端的场景中会出现数据丢失和截断,所以我们需要在实现上做特殊的处理。

img

如上图所示,按照最终一致和多数原则,如果有 2 个副本 A 和 B 写入成功后,就会告诉客户端数据写入成功。此时,如果这两台节点同时挂掉,就会有 C 节点成为 Leader 和 C 节点不成为 Leader 两种场景。

  • 如果是 C 节点不能成为 Leader,此时就不会有丢数据或截断问题。开发实现也简单,只是问题发生时,服务就不可用。
  • 如果 C 节点可以成为 Leader,此时就可能会出现数据截断。比如 A、B 有 100 条数据,C 只有 90 条数据,此时 A、B 挂了,C 成为 Leader 后重新接收数据。当 A 和 B 启动后,因为 C 这里也会有 90~100 之间的数据,如果要合并数据就会冲突。所以一般遇到这种情况,就会丢弃数据,然后就会有数据丢失。

在 Kafka 的实现中,这两种情况都是支持的,支持在 Topic 维度调整配置来选择这两个操作。所以 Kafka 的 ISR 协议的很大一部分工作,就是在代码层面处理 Leader 切换、数据阶段的操作。

安全控制

Kafka 支持 TLS/SSL 进行数据加密传输。从代码实现层面看,服务端和客户端支持这部分能力,和 RocketMQ 是一样的,都是 Java 代码的标准用法,这里就不再赘述了。讲安全时我们讲过,SASL 是一个身份验证和安全的框架。Kafka 在这个框架下实现了 GSSAPI、PLAIN、SCRAM、OAUTHBEARER、Delegation Token 5 种认证方式。

PLAIN 就是明文的用户名、密码认证机制,通过服务端提供的用户名密码来完成校验。PLAIN 是 Kafka 早期提供的明文认证机制,它的用户名和密码是写在 Broker 的配置文件中的,不支持动态修改。为了解决这个问题,Kakfa 支持了 SCRAM 机制,SCRAM 也是明文的认证机制。它跟 PLAIN 最大的区别是,它的用户名和密码是存储在 ZooKeeper 中的,并支持动态修改。

可观测性

Kafka 的指标定义是基于 Yammer Metrics 库来实现的。Yammer Metrics 是 Java 中一个常用的指标库,它提供了 Histogram、Meters、Gauges 等类型来统计数据。对于 Kafka 的作用就是,使用这个库可以完成各种类型指标的统计和记录

在指标暴露方面,Kakfa 只支持 JMX 方式的指标获取。即如果需要从 Kafka 进程采集指标,就需要先在 Broker 上开启 JMX Server。然后客户端通过 JMX Client 去 Broker 采集对应的指标。在实际运营中,主要有 3 种通过指标监控 Broker 的方式。

img

在日志方面,Kafka 也是基于 Log4j 库去打印日志,依赖 Log4j 库的强大,支持常见的日志功能,这里就不再细数。同时通过配置支持,将不同模块的日志打印到不同文件,如 Controller、Coordinator、GC 等等,以便在运营过程中提高问题排查的效率。

从集群角度拆解Pulsar的架构设计与实现

集群构建

在当前版本,Pulsar 集群构建和元数据存储的核心依旧是 ZooKeeper,同时社区也支持了弱 ZooKeeper 化改造。如下图所示,Pulsar Broker 集群的构建思路和 Kafka 是一致的,都是通过 ZooKeeper 来完成节点发现和集群的元数据管理。

主节点

在集群管理方面,每个 Pulsar 集群都有一个主节点(Master Node)。主节点对 Pulsar 的作用,就相当于 Kakfa 中的 Controller。主节点负责管理集群的元数据和状态信息,例如主题、订阅、消费者等。主节点还负责协调集群中的各个节点,例如选举副本、分配分区等。

当一个节点启动时,它会向 ZooKeeper 注册自己,并尝试成为主节点。如果当前没有主节点,或者当前的主节点失效了,那么该节点就会成为新的主节点。如果多个节点同时尝试成为主节点,那么它们会通过 ZooKeeper 的选举机制来进行竞争,最终只有一个节点会成为主节点。从节点机制上看,和 Kafka 的 Controller 选举机制的实现是一样的。从代码的角度,也是依赖 ZooKeeper 的存储和 Watch 来实现分布式协调。所以可以看出,ZooKeeper 作为分布式协调服务,用处非常广泛。

弱 ZooKeeper 实现

分层树结构在读取时需要多层检索,从而导致数据如果存储在硬盘,读取性能会很低。因此 ZooKeeper 只有将所有数据加载到内存中,才能提供较好的性能。此时单个节点可承载的容量上限,就是集群所能承载的容量上限。而 Pulsar 存算分离架构和计算层弹性需要存储很多元数据,所以 ZooKeeper 就成为了瓶颈。为了解决这个问题,Pulsar 走的技术路径是弱 ZooKeeper,而不是去 ZooKeeper。

弱 ZooKeeper 就是允许将 ZooKeeper 替换为其他的单机或分布式协调服务。目前支持 ZooKeeper、etcd、RocksDB、内存四种方案。

img

基于 etcd 的方案是当前集群化部署的推荐方案。etcd 底层存储是 B 树的结构,在硬盘层面的读取性能较高,不一定要把数据加载到内存中,所以存储容量不受单机的限制。

从实现角度,这种可插拔的方案都是先定义好接口,比如获取资源、获取子节点、增加或删除内容等等。然后具体的元数据引擎实现会继承这个接口,去实现不同的逻辑。如下代码是 Pulsar 可插拔的元数据服务所定义的接口 MetadataStore。

数据可靠性

Pulsar 是计算存储分离的架构,数据是通过 Ledger 和 Entry 的形式写入 BookKeeper 的。所以跟其他消息队列不一样的是,Pulsar 的 Topic 没有副本概念,消息数据的可靠性是通过 Ledger 多副本来实现的。

Pulsar 通过在 Broker 中设置 Qw 和 Qa 来设置 Ledger 的总副本数和写入成功的副本数。所以从一致性来看,Pulsar 既可以是强一致,也可以是最终一致。

img

如上图所示,每条消息是一个 Entry ,一批 Entry 组成一个 Ledger ,一批 Ledger 组成一个分区。 所以当数据不断写入分区时,Broker 会根据条件来不停地创建分区维度的 Ledger。这个条件通常是 Ledger 的固定长度,另外当 Ledger 写入流断开时,也会创建新的 Ledger。

在 Ledger 创建时就会根据设置的 Qw 数量,在 多个 Bookie 中创建 Ledger。这个过程就需要控制 Ledger 分布在哪些 BookKeeper 节点,怎么实现的呢?
BookKeeper 可以通过配置机架感知(RackawareEnsemblePlacementPolicy)、区域感知(RegionAwareEnsemblePlacementPolicy)、可用区感知(ZoneawareEnsemblePlacementPolicy)3 种集成放置策略,来控制 Ledger 在 BookKeeper 多节点中的分布,从而实现多副本数据的高可靠和跨机架、跨可用区、跨区域容灾。

安全控制

Pulsar 提供了传输加密、身份认证、资源鉴权、端到端加密四种手段。

传输加密

为了保证数据在传输过程中的安全,Pulsar 支持通过 TLS 对数据进行加密传输。从使用角度,需要先申请或者创建证书,然后在 Broker 中配置启用 TLS,再在客户端配置证书信息来完成访问。

端到端加密

除了支持 TLS 传输加密,Pulsar 还支持数据端到端加密。即在生产者端加密消息,然后在消费者端解密消息,从而保证数据在 Broker 保存的是经过加密后的数据,这能有效避免存储在 Broker 中的数据被泄露。

img

从实现的角度,Pulsar 会使用动态生成的对称会话密钥来加密数据。来看下图,这是 Pulsar 在生产者端加密消息,然后在消费者端解密消息的流程图。

身份认证

Pulsar 当前支持 mTLS、JSON Web Token 令牌、Athenz、Kerberos、OAuth 2.0、OpenID Connect、HTTP 基本身份验证等 7 种认证方式。

资源鉴权

同样的,Pulsar 也提供了插件化的鉴权机制。默认情况下,如果不配置鉴权,认证通过后就可以访问集群中的所有资源。Broker 当前提供了 AuthorizationProvider 和 MultiRolesTokenAuthorizationProvider 两种鉴权实现,其中 MultiRolesTokenAuthorizationProvider 只支持配合 JWT 认证使用。可以在 Broker 配置文件中配置启用哪种鉴权插件。

和其他消息队列直接通过用户名或者客户端信息来完成鉴权不一样的是,Pulsar 是通过 Role Token(角色令牌)来完成鉴权的。Role Token 本质就是一个字符串,是一个逻辑的概念,用来在后续授权中标识客户端身份用的

可观测性

Pulsar 定位云原生消息队列,所以它的指标模块主要围绕 Prometheus 和 Grafana 体系来搭建的。

在指标暴露方面,Pulsar 通过在组件上支持 HTTP 接口 /metrics 来支持 Prometheus 的采集。接口的数据格式是标准 Prometheus 格式,直接配置 Prometheus 采集 + Grafana 展示或告警即可,使用成本较低。跟 RocketMQ 的支持 Prometheus 方案是一样的。

posted @ 2023-09-20 10:45  Blue Mountain  阅读(818)  评论(0)    收藏  举报