MQ009——分布式消息队列(上)

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

有状态服务和无状态服务

正式讲解如何构建一个分布式的消息队列集群之前,我们可以先来了解一下什么是有状态服务,以及什么是无状态服务。

这两个词在我们日常开发中也是经常遇到的,这二者之间最重要的一个区别在于:是否需要在本地存储持久化数据。简单来说就是,需要在本地存储持久化数据的就是有状态服务,反之就是无状态服务。

说这两个,主要是因为有状态服务和无状态服务构建集群的思路完全是不一样的。HTTP Web Server 就是典型的无状态服务。在搭建 HTTP Web 集群的时候,我们经常会使用 Nginx 或者其他网关后面挂一批 HTTP 节点,此时后端的这批 HTTP 服务节点就是一套集群。

nginx

如上图所示,因为 HTTP Web 是无状态的服务,不同的节点不需要知道其他节点的存在。Nginx 认为后端所有的节点的功能是一样的,所以请求经过 Nginx 后,只需要根据一定转发策略,如轮询、加权轮询、按 Key Hash 等将请求转发给后端的 Web 服务节点即可。然后在节点增减的时候,Nginx 会感知到节点的增减。执行转发或者不转发就可以咯。

至于消息队列通常来说都是有状态服务。消息是和分片绑定,分片是和节点绑定。所以,当需要发送一个消息后,就需要发送到固定的节点,如果把消息发送到错误的节点,就会失败。所以,为了将消息发送到对的节点和从对的节点削峰数据,消息队列在消息的收发上,就有服务端转发和客户端寻址两种方案。

所以,消息队列集群应该是按照有状态来设计的。接下来,我们就看看如何设计出一个集群化的消息队列服务。

消息队列的集群设计思路

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

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

broker

元数据存储

我们先来看一下集群中的元数据是如何存储的。

消息队列集群元数据是指集群中 Topic、分区、配置、节点和权限等信息。元数据必须保证可靠、高校的存储,不允许丢失。因为一旦元数据丢失,其实际的消息数据也会变得没有意义。

从技术上看,业界主要有第三方存储引擎和集群内部自实现存储两种方案。

依赖第三方存储引擎是指直接使用第三方组件来完成元数据信息的存储,比如 Zookeeper、etcd、单机或分布式数据库等等。这种方案的优点是拿来即用,无需额外的开发成本,产品成型快,稳定性较高。缺点是需要依赖第三方组件,会增加额外的部署维护成本,并且受限于第三方组件的瓶颈和稳定性,也可能会有数据一致性问题。像 Kafka、Pulsar 基于 Zookeeper 都是用的这种方式。

集群内部自实现存储是指在消息队列应用内部自定义实现元数据存储服务,相当于在消息队列集群中实现一个小型的 Zookeeper。这种方案的优点是集群内部集成了这部分能力,部署架构就很简单轻量,应用自我把控性高,不会有第三方以来问题。缺点是开发成本较高,从头开始自研,相对于成熟组件而言,稳定性上短期内会比较弱,需要投入时间打磨。Kafka 去 Zookeeper 后的 KRaft 架构中的元数据存储,就是基于这个思路实现的。

节点发现

接下来,我们一起看看看如何完成节点发现。我们知道集群是由多个节点组成的,此时组成集群的最基本要求就是:所有节点知道对方的存在或者有一个组件知道所有节点的存在,这样才能完成后续的集群管理和调度。这个过程就是节点发现的过程。

从技术上看,当前业界主要有配置文件、类广播机制、集中式组件三种手段来完成节点发现。

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

第一种方案的好处是实现简单,在节点发现这块几乎不需要额外的开发成本,缺点就是集群扩容需要修改配置文件,水平扩容不方便,需要重启。比如 Zookeeper 和 KRaft 就是用的这种方案。

第二种方案好处是可以自动发现新节点,自动扩容集群。缺点是开发成本很高,需要通过广播或者类似的机制发现集群中的其他节点。

第三种的好处是可以动态地感知节点的变更,水平扩容非常方便,实现也简单。所以当前主流消息队列都是用的这种方案。Kafka 基于 Zookeeper 的版本,RocketMQ 和 Pulsar 都是用的这种方案。

完成节点后,接下来就需要能够感知节点的变更,以便在节点故障时及时将其踢出集群。而这种动作就得依靠节点探活来实现。

节点探活

从实现角度来看,一般需要有一个角色来对集群内所有节点进行探活或者保活,这个角色一般是主节点或第三方组件。

如下图所示,技术上一般分为主动和定时上探测两种,这两种方式的主要区别在于心跳探活发起方的不同。从技术和实现上看,差别都不大;从稳定性来看,一般推荐主动上报。因为由中心组件主动发起探测,当节点较多时,中心组件可能会有性能瓶颈,所以目前业界主要的探活实现方式也是主动上报。

ping-pong

从探测策略上看,基础都是基于 ping-pong 的方式来完成探活。心跳发起一般会根据一定的时间间隔发起心跳探测。如果保活组件一段时间没有接收到心跳或者主动心跳探测失败,就会提出这个节点。比如每 3 秒探测一次,连续 3 次探测失败就剔除节点。探测行为一般会设置较短的超时时间,以便尽快完成探测。

以 Kafka 为例,它是基于 Zookeeper 提供的临时节点和 Hook 机制来实现节点保活的。即节点加入集群时会创建 TCP 长连接并创建临时节点,当 TCP 连接断开时就会删除临时节点。临时节点的变更会触发后续的相关操作,比如将节点加入集群、将节点剔除集群等等。

所以基于 Zookeeper 实现节点发现和保活就很简单,只要通过 SDK 创建临时节点即可,只要 TCP 连接存活,临时节点就会存在。那么怎样确认连接存活呢?底层还是通过 ping-pong 机制、客户端主动上报心跳的形式实现的。

因为 Zookeeper 具备这两个机制且组件相对成熟、稳定性较高,所以很多消息列队都会用 Zookeeper 来实现节点发现和探活。完成节点探活后,接下来我们看看集群的主节点是怎么选举出来的。

主节点选举

从技术上看,理论上只要完成了节点探活,即节点健康的情况下,这批节点就都是能被选为主节点的。当然,有的集群可以配置哪些节点可以被选举为主节点,哪些节点不能被选举主节点,但是这点不影响后续的选举流程。

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

相互选举是指所有节点之间相互投票,选出一个得票最多的节点成为 Leader。投票的具体实现可以参考 Raft 算法,这里就不展开。目前业界 Zookeeper、ElasticSearch、Kafka KRaft 版本等都是用的这种方案。

依赖第三方组件争抢注册是通过引入一个集中式组件来辅助完成节点选举。比如可以在 Zookeeper、etcd 上的某个位置写入数据,哪个节点先写入成功它就是 Leader 节点。当节点异常时,会触发其他节点争抢写入数据。依此类推,从而完成主节点的选举。

在消息队列中,这个主节点一般称为 Controller,Controller 主要是用来完成集群管理相关的工作,集群的管理操作一般指创建和删除 Topic、配置变更等等行为。

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

vote

其中,Metadata Service 负责元数据的存储,Controller 负责读取、管理元数据信息,并通过集群中的 Broker 执行各种操作。此时从实际架构实现的角度来看,Broker 的元数据上报可以走路径 1,通过 Controller 上报元数据到 Metadata Service,也可以直连 Metadata Service 走路径 2 上报元数据。两条路径没有明显的优劣,一般根据实际的架构实现时的选型做考虑。

当完成元数据存储、节点发现、节点探活、主节点选举后,消息队列的集群就创建完成了。接下来我们通过集群启动、创建 Topic、Leader 切换三个动作来分析一下集群的运行机制。先来看一下集群启动的流程。

消息队列的集群构建流程

集群启动

集群启动其实就是节点启动的过程,可以看下图:

start

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

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

从运行的角度看,完成这一步,集群就算已经构建完成了。接下来我们看看如何创建 Topic。

创建 Topic

topic

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

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

再来看看删除 Topic 和扩容分区是如何执行的。

如果要删除 Topic,首先依旧要先往 Controller 发送一个删除 Topic 的指令;然后 Controller 会通知 Topic 分区所在的节点,删除分区和副本数据,删除 Topic;最后再删除 Metadata Service 中的 Topic 元数据/扩容分区的操作也是类似的,Controller 接收到扩容分区的指令,根据逻辑计算出新分区所在的节点,然后通知对应的节点创建分区,同时保存相关元数据。

Leader 切换

leader

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

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

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

总结

集群构建的思路分为有状态服务和无状态服务,两种类型服务的构建思路是不一样的。有状态服务需要解决元数据存储、节点发现、节点探活、主节点选举等四部分。

元数据存储主要有依赖第三方组件实现和集群内自定义实现元数据存储两个思路。第三方组件主要有 ZooKeeper、etcd 等,依赖第三方组件是当前主流的选择,因为其实现较为简单,前期稳定性较高。自定义实现元数据存储是指在消息队列 Broker 集群内实现元数据存储服务,从而简化架构,实现虽较为复杂,但长期来看相对更合理。

节点发现主要有静态发现和动态发现两个思路。静态发现是指通过配置文件配置好集群的所有节点,各个节点之间通过配置内容来发现对方,从而组建成一个集群。动态发现是指依赖一个中心组件或者类广播机制来动态完成节点之间的相互发现,即当节点上线或下线的时候,及时感知到变化,从而将节点加入到集群或者从集群中剔除。

节点探活主要分为主动上报和定时探测两种,业界主要使用主动上报的实现形式。

主节点在消息队列中一般叫做 Controller,一般通过节点间选举或者依赖第三方组件争抢注册来完成选举。Controller 主要用来完成集群内的管理类操作,如节点上下线、Topic 创建 / 删除 / 修改、Leader 切换等等。Controller 由集群中的某个 Broker 担任。