15 集群:如何构建分布式的消息队列集群?(上)
你好,我是文强。
上节课我们讲到集群的主要功能就是用来提高性能和数据可靠性。从技术上看,设计实现集群化的消息队列主要包含 节点发现、 节点探活、 元数据存储、 集群管理 四个方面。接下来我们将围绕着这四个方面,用两节课来讲一下具体是怎么思考、怎么实现集群的。
有状态服务和无状态服务
在此之前,我们先来了解一下什么是有状态服务和无状态服务,后面会用到。
在日常开发中,我们经常听到有状态服务和无状态服务这两个词。二者之间最重要的一个区别在于: 是否需要在本地存储持久化数据。需要在本地存储持久化数据的就是有状态服务,反之就是无状态服务。
其实我这里想说明的是,有状态服务和无状态服务构建集群的思路是完全不一样的。HTTP Web 服务就是典型的无状态服务。在搭建HTTP Web 集群的时候,我们经常会使用 Nginx 或者在其他网关后面挂一批 HTTP 节点,此时后端的这批 HTTP 服务节点就是一套集群。
如上图所示,因为HTTP Web是无状态的服务,不同的节点不需要知道其他节点的存在。Nginx 认为后端所有的节点的功能是一样的,所以请求经过Nginx后,只需要根据一定转发策略,如轮询、加权轮询、按Key Hash等将请求转发给后端的Web 服务节点即可。然后在节点增减的时候,Nginx 会感知到节点的增减,执行转发或者不转发就行了。
我们知道,消息队列是有状态服务。消息是和分片绑定,分片是和节点绑定。所以,当需要发送一个消息后,就需要发送到固定的节点,如果把消息发送到错误的节点,就会失败。所以,为了将消息发送到对的节点和从对的节点削峰数据,消息队列在消息的收发上,就有服务端转发和客户端寻址两种方案。
所以,消息队列集群应该是按照有状态服务来设计的。接下来我们开始看看如何设计出一个集群化的消息队列服务。
消息队列的集群设计思路
当前业界主流的分布式集群,一般都是基于主从(Master/Slave)思想来设计的。即通过一个组件来管理整个集群的相关工作,比如创建和删除主题、节点上下线等等。这个组件一般叫做Master(主节点)或Controller(控制器)。
然后还需要有一个组件来完成集群元数据(比如节点信息、Topic信息等等)的存储,这个组件一般叫做元数据服务。当然还有一批数据流节点来完成数据的读写和存储工作,这个组件一般叫做 Broker 或者节点。
所以一个消息队列集群的架构可能是下面图中的这个样子吗?带着这个疑问,我们正式开始设计我们的集群。
元数据存储
我们先来看一下集群中的元数据是如何存储的。
消息队列集群元数据是指集群中Topic、分区、配置、节点、权限等信息。元数据必须保证可靠、高效地存储,不允许丢失。因为一旦元数据丢失,其实际的消息数据也会变得没有意义。
从技术上看,业界主要有第三方存储引擎和集群内部自实现存储两种方案。
依赖第三方存储引擎 是指直接使用第三方组件来完成元数据信息的存储,比如ZooKeeper、etcd、单机或者分布式数据库等等。这种方案的优点是拿来即用,无需额外的开发成本,产品成型快,稳定性较高。缺点是需要依赖第三方组件,会增加额外的部署维护成本,并且受限于第三方组件的瓶颈和稳定性,也可能会有数据一致性问题。
目前业界主流消息队列都是选用的这个方案,比如 RabbitMQ 基于 Mnesia 或 etcd、Kafka,Pulsar基于ZooKeeper都是用的这个方案。
集群内部自实现存储 是指在消息队列应用内部自定义实现元数据存储服务,相当于在消息队列集群中实现一个小型的ZooKeeper。这种方案的优点是集群内部集成了这部分能力,部署架构就很简单轻量,应用自我把控性高,不会有第三方依赖问题。缺点是开发成本高,从头开始自研,相对于成熟组件而言,稳定性上短期会比较弱,需要投入时间打磨。
业界的消息队列中,Kafka 去 ZooKeeper 后的 KRaft 架构中的元数据存储,就是基于这个思路实现的。我个人是比较倾向于第二种方案的,因为长期来看,当组件成熟后,在产品后期架构简单是一个非常大的优势。
节点发现
接下来我们来看看如何完成节点发现。我们知道集群是由多个节点组成的,此时组成集群的最基本要求就是:所有节点知道对方的存在或者有一个组件知道所有节点的存在,这样才能完成后续的集群管理和调度。这个过程就是节点发现的过程。
从技术上看,当前业界主要有配置文件、类广播机制、集中式组件三种手段来完成节点发现。
- 配置文件 是指通过配置文件配置所有节点IP,然后节点启动后根据配置文件去找到所有的节点,从而完成节点发现。
- 类广播机制 是指通过广播、DNS解析等机制,自动去发现集群中所有节点。比如通过解析 DNS 域名,得到域名绑定的所有 IP,从而发现集群中所有节点。
- 集中式组件 是指所有节点都向集中式组件去注册和删除自身的节点信息,此时这个组件就会包含所有节点的信息,从而完成节点发现。
第一种方案的好处就是实现简单,在节点发现这块几乎不需要额外的开发成本,缺点就是集群扩容需要修改配置文件,水平扩容不方便,需要重启。业界ZooKeeper和Kafka KRaft就是用的这种方案。
第二种方案的好处是可以自动发现新节点,自动扩容集群。缺点是开发成本很高,需要通过广播或者类似的机制发现集群中的其他节点。业界的RabbitMQ和Elasticsearch用的就是这种方案。
第三种方案的好处是可以动态地感知节点的变更,水平扩容非常方便,实现也简单。所以当前主流消息队列都是用的这种方案。业界Kafka 基于ZooKeeper的版本,RocketMQ、Pulsar 用的都是这种方案。
完成节点发现后,接下来就需要能感知节点的变更,以便在节点故障时及时将其踢出集群。而这种动作就得依靠节点探活来实现。
节点探活
从实现角度来看,一般需要有一个角色来对集群内所有节点进行探活或者保活,这个角色一般是主节点(Master/Leader/Controller)或第三方组件。
如下图所示, 技术上一般分为主动上报和定时探测两种,这两种方式的主要区别在于心跳探活发起方的不同。 从技术和实现上看,差别都不大,从稳定性来看,一般推荐主动上报。因为由中心组件主动发起探测,当节点较多时,中心组件可能会有性能瓶颈,所以目前业界主要的探活实现方式也是主动上报。
从探测策略上看,基本都是基于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、配置变更等等行为。
所以抽象来看,一般情况下消息队列的集群架构如下所示:
其中,Metadata Service负责元数据的存储,Controller负责读取、管理元数据信息,并通知集群中的Broker执行各种操作。此时从实际架构实现的角度来看,Broker 的元数据上报可以走路径1,通过 Controller 上报元数据到Metadata Service,也可以直连Metadata Service走路径2上报元数据。两条路径没有明显的优劣,一般根据实际的架构实现时的选型做考虑。
当完成元数据存储、节点发现、节点探活、主节点选举后,消息队列的集群就创建完成了。接下来我们通过集群启动、创建Topic、Leader切换三个动作来分析一下集群的运行机制。先来看一下集群启动的流程。
集群构建流程拆解
集群启动其实就是节点启动的过程,来看下图:
节点启动大致分为以下四步:
- 节点启动时在某个组件(如图中的Controller 或 Metadata Service)上注册节点数据,该组件会保存该节点的元数据信息。
- 节点注册完成后,会触发选举流程选举出一个主节点(Controller)。
- 节点会定期向主节点(或Metadata Service)上报心跳用来确保异常节点能快速被剔除。
- 当节点异常下线或有新节点上线时,同步更新集群中的元数据信息。
从运行的角度看,完成这一步,集群就算已经构建完成了。接下来我们来看下创建 Topic 的流程是怎样运行的。
创建Topic
下面是一张创建 Topic 流程各个组件的交互图。
创建 Topic 大致分为以下四步:
- 客户端指定分区和副本数量,调用Controller创建Topic。
- Controller根据当前集群中的节点、节点上的Topic和分区等元数据信息,再根据一定的规则,计算出新的Topic的分区、副本的分布,同时选出分区的Leader(主分片)。
- Controller调用Metadata Service保存元数据信息。
- Controller调用各个Broker节点创建Topic、分区、副本。
再来看看删除Topic和扩容分区是如何执行的。
如果要删除Topic,首先依旧要先往 Controller 发送一个删除Topic的指令;然后Controller会通知 Topic 分区所在的节点,删除分区和副本数据,删除Topic;最后再删除Metadata Service中的Topic元数据。扩容分区的操作也是类似的,Controller接收到扩容分区的指令,根据逻辑计算出新分区所在的节点,然后通知对应的节点创建分区,同时保存相关元数据。
接下来再看看 Leader 切换是如何执行的。
Leader切换
当新的 Broker 节点加入集群,这个节点就需要在 Controller上进行注册。此时如果节点宕机,因为新节点上没有分区或Topic数据,所以不需要进行 Leader 切换。
而如果已有节点下线,因为节点上有分区的 Leader 存在,所以需要进行Leader切换,以便实现服务的高可用。
因为集群会监听所有 Broker 的服务状态。当 Broker 挂掉时,Controller就会感知从而触发 Leader 切换操作。下面是一张 Leader 切换的流程图。
Leader 切换的流程可以分为以下四步:
- Controller会持续监听节点的存活状态,持续监控Broker节点是否可用。
- 根据一定的机制,判断节点挂掉后,开始触发执行Leader切换操作。
- Controller通过RPC调用通知存活的Broker2和Broker3,将对应分区的Follower提升为Leader。
- 变更保存所有元数据。
从客户端的视角来看,服务端是没有机制通知客户端 Leader 发生切换的。此时需要依靠客户端主动更新元数据信息来感知已经发生Leader切换。客户端一般会在接收到某些错误码或者定期更新元数据来感知到 Leader 的切换。
讲到这里,你应该已经了解了集群构建的整体思路。下节课我们会详细聊一聊元数据存储服务的具体选型和实现,同时详细讲一下ZooKeeper 和 Kafka的集群构建思路。
总结
集群构建的思路分为有状态服务和无状态服务,两种类型服务的构建思路是不一样的。有状态服务需要解决元数据存储、节点发现、节点探活、主节点选举等四部分。
元数据存储主要有依赖第三方组件实现和集群内自定义实现元数据存储两个思路。第三方组件主要有ZooKeeper、etcd等,依赖第三方组件是当前主流的选择,因为其实现较为简单,前期稳定性较高。自定义实现元数据存储是指在消息队列Broker集群内实现元数据存储服务,从而简化架构,实现虽较为复杂,但长期来看相对更合理。
节点发现主要有静态发现和动态发现两个思路。静态发现是指通过配置文件配置好集群的所有节点,各个节点之间通过配置内容来发现对方,从而组建成一个集群。动态发现是指依赖一个中心组件或者类广播机制来动态完成节点之间的相互发现,即当节点上线或下线的时候,及时感知到变化,从而将节点加入到集群或者从集群中剔除。
节点探活主要分为主动上报和定时探测两种,业界主要使用主动上报的实现形式。
主节点在消息队列中一般叫做Controller,一般通过节点间选举或者依赖第三方组件争抢注册来完成选举。Controller 主要用来完成集群内的管理类操作,如节点上下线、Topic创建/删除/修改、Leader切换等等。Controller 由集群中的某个Broker担任。
思考题
基于上面的集群方案,如果要实现分区缩容你会怎么做?你觉得需要注意哪些事情?
期待你的思考,如果觉得有收获,也欢迎你把这节课分享给身边的朋友。我们下节课再见!
上节课思考闭环
根据二八原则,集群中应该有某些点对于集群的性能和可靠性影响很大,你认为主要有哪些?
1. 生产者是否批量发送
2. 客户端和服务端的网络延时情况
3. Broker 单机维度的网络模块
4. Broker 单机维度的存储层
5. Broker 集群维度的分区和副本数
6. 消费模型的选择
7. 消费是否批量拉取数据
8. 消费模式的选择