走进消息队列 | 青训营

181 阅读21分钟

1. 案例介绍

案例一:系统崩溃

image-20230812155516624.png

解决方案:解耦

image-20230812155743045.png

案例二:服务能力有限

image-20230812155633909.png

解决方案:削峰

image-20230812155847133.png

案例三:链路耗时较长

image-20230812155704579.png

解决方案:异步

image-20230812155917494.png

案例四:日志存储

image-20230812155930538.png

2. 消息队列的前世今生

什么是消息队列? 消息队列(MQ),只保存消息的一个容器,本质是一个队列,但这个队列支持高吞吐、高并发,并且高可用。

image-20230812160132100.png

消息队列发展历程:

image-20230812160031243.png

  • 时代的洪流不可逆转,有壁垒就有打破壁垒的后来者,2001年sun发布了jms技术,试图在各大厂商的层面上再包装一层统一的java规范。java程序只需要针对jms api编程就可以了,不需要再关注使用了什么样的消息中间件,但是jms仅仅适用于java。

  • 2004年AMQP(高级消息队列协议)诞生了,才是真正促进了消息队列的繁荣发展,任何人都可以针对AMQP的标准进行编码。有好的协议指导,再加上互联网分布式应用的迅猛发展成为了消息中间件一飞冲天的最大动力,程序应用的互联互通,发布订阅,最大契合了消息中间件的最初的设计初衷。除了刚才介绍过的收费中间件,后来开源消息中间件开始层出不穷,常见比较流行的有ActiveMQ、RabbitMQ 、Kafak、RocketMQ。

以下几个消息队列是业内比较流行的几种:

  • Kafka:分布式的、分区的、多副本的日志提交服务,==在高吞吐场景下==发挥较为出色
  • RocketMQ:低延迟、强一致、高性能、高可靠、万亿级容量和灵活的可扩展性,在一些==实时场景==中运用较广
  • Pulsar:是下一代云原生分布式消息流平台,集消息、存储、轻量化函数式计算为一体、采用存算分离的架构设计
  • BMQ:和Pulsar架构类似,存算分离,初期定位是承接高吞吐的离线业务场景,逐步替换掉对应的Kafka集群

3. Kafka

3.1 使用场景

离线的消息处理、Metrics(程序运行当中程序状态的采集)数据、用户行为;

3.2 如何使用

第一步:首先创建一个Kafka集群,但如果你在字节工作,恭喜你这一步消息团队的小伙伴已经帮你完成了

第二步:需要在这个集群中创建一个Topic,并且设置好分片数量

第三步:引入对应语言的SDK,配置好集群和Topic等参数,初始化一个生产者,调用Send方法,将你的Hello World发送出去

第四步:引入对应语言的SDK,配置好集群和Topic等参数,初始化一个消费者,调用Poll方法,你将收到你刚刚发送的Hello World

image-20230812160904262.png

Topic:Kakfa中的逻辑队列,可以理解成每一个不同的业务场景就是一个不同的topic,对于这个业务来说,所有的数据都存储在这个topic中

Cluster:Kafka的物理集群,每个集群中可以新建多个不同的topic

Producer:顾名思义,也就是消息的生产端,负责将业务消息发送到Topic当中

Consumer:消息的消费端,负责消费已经发送到topic中的消息

Partition:通常topic会有多个分片,不同分片直接消息是可以并发来处理的,这样提高单个Topic的吞吐对于

image-20230812161022254.png

Offset:每一个Partition来说,每一条消息都有一个唯一的Offset,消息在partition内的相对位置信息,并且严格递增。

image-20230812161206976.png

3.3 Replica副本

Replica:分片的副本,分布在不同的机器上,可用来容灾。每个分片有多个 Replica,Leader Replica 将会从 ISR 中选出。

  • Leader对外服务,Follower异步拉取leader的数据进行同步。如果Leader挂了,可以将ISR的Follower提升成Leader对外进行服务。
  • ISR(In-Sync Replicas): 意思是同步中的副本,对于Follower来说,始终和Leader是有一定差距的;当这个差距比较小的时候,我们可以将这个Follower副本加入到ISR中;差距大就踢出ISR;不在ISR中的副本是不允许提升成Leader的。

image-20230812161347209.png

3.4 数据复制

下面这幅图代表着Kafka中副本的分布图, 图中Broker代表每一个Kafka的节点,所有的Broker节点最终组成了一个集群。

整个图表示,图中整个集群,包含了4个Broker机器节点,集群有两个Topic,分别是Topic1和Topic2,Topic1有两个分片,Topic2有1个分片,每个分片都是三副本的状态。 中间有一个Broker同时也扮演了Controller的角色, Controller是整个集群的大脑,负责对副本和Broker进行分配,再告诉各个Broker怎么去处理。

image-20230812161648483.png

3.5 Kafka架构

在集群的基础上,还有一个模块是ZooKeeper,这个模块其实是存储了集群的元数据信息,和Controller配合,比如副本的分配信息等等,Controller计算好的方案都会放到这个地方。

image-20230812161914355.png

3.6 高吞吐的Kafka

从一条消息的视角来看看完整的处理流程,了解一下Kafka为什么可以支撑如此高的吞吐?

image-20230812162106881.png

一秒几千万条数据,吞吐量达不到要求,不能等:

image-20230812162218193.png

总结:

  • Producer:批量发送、数据压缩
  • Broker:顺序写,消息索引,零拷贝
  • Consumer:Rebalance

3.6.1 Producer-批量发送

image-20230812162355392.png

3.6.2 Producer-数据压缩

如果消息量很大,网络带宽不够用,如何解决?数据压缩。

image-20230812162458913.png

3.6.3 Broker-顺序写

如何写入到磁盘呢,我们先来看一下Kafka最终存储的文件结构是什么样子的。

在每一个Broker,都分布着不同Topic的不同分片,不同副本以Log形式 写入磁盘;Log会切分成不同的有序的LogSegment:.log存真实数据;.index 日志具体位置的映射;

image-20230812162730826.png

数据路径:/Topic/Partition/Segment/(log|index|timeindex|...)

移动磁头找到对应磁道,磁盘转动,找到对应扇区,最后写入。寻道成本比较高,因此顺序写可以减少寻道所带来的时间成本。

image-20230812162932925.png

顺序写,提高写入效率:

image-20230812163102304.png

3.6.4 Broker-消息索引

此时我们的消息写入到Broker的磁盘上了,Consumer 通过发送 FetchRequest 请求消息数据,Broker 会将指定 Offset 处的消息,按照时间窗口和消息大小窗口发送给 Consumer,寻找数据这个细节是如何做到的呢?

image-20230812163146338.png

文件名是文件中第一条消息的offset,具体的查找方式通过二分查找:

  • 首先,通过二分找到小于目标文件的最大文件

image-20230812163427389.png

  • 然后,通过二分找到小于目标offset最大的索引位置,再遍历找到目标offset

image-20230812163435643.png

也可以使用时间戳文件索引,如果我们需要使用时间戳来寻找的时候,和offset相比只是多加了以及索引,也就是通过二分找到时间戳对应的offset,再重复之前的步骤找到相应的文件数据:

image-20230812163612559.png

3.6.5 Broker-零拷贝

传统数据拷贝:从磁盘读到内核态,再拷贝到用户态的应用空间,然后网卡发送到消费者。

image-20230812163755688.png

零拷贝:直接从内核空间传到NIC网卡 ,减少三次传统拷贝。

image-20230812163832861.png

3.6.3 Consumer-Rebalance

对于一个Consumer Group来说,多个分片可以并发的消费,这样可以大大提高消费的效率, 但需要解决的问题是,Consumer和Partition的分配问题, 也就是对于每一个Partition来讲,该由哪一个Consumer来消费的问题。 对于这个问题,我们一般有两种解决方法,手动分配和自动分配。

  • 手动分配,也就是Kafka中所说的Low Level消费方式。
    • 一个好处就是启动比较快,对于每一个Consumer来说,启动的时候就已经知道了自己应该去消费哪个Partition,比如对于图中的Consumer Group1,Consumer1去消费Partition123,Consumer2去消费Partition456, Consumer3去消费Partition78。
    • 这样这种方式的缺点又是什么呢,如果我们的Consumer3挂掉了,我们的7,8分片是停止消费了。 如果我们新增了一台Consumer4,我们需要停掉整个集群,重新修改配置再上线。 其实这两个问题,有时候对于线上业务来说是致命的。

image-20230812164000633.png

  • 自动分配,也叫做High Level的消费方式。
    • 在我们的Broker集群中,对于不同的Consumer Group来讲,都会选取一台Broker当做Coordinator(协调者)
    • Coordinator的作用就是帮助Consumer Group进行分片的自动分配,也叫做分片的==rebalance==, 使用这种方式,如果ConsumerGroup中有发生宕机,或者有新的Consumer加入,整个partition和Consumer都会重新进行分配来达到一个稳定的消费状态。

image-20230812164408323.png

Rebalance具体算法:

  1. 开始的时候随机寻找一个Broker作为Coordinator,随机发送请求:

image-20230812164628836.png

  1. 确定Coordinator:

image-20230812164719618.png

  1. 设置Leader Consumer:

image-20230812164745865.png

  1. 开始同步消费方案:

image-20230812164809710.png

  1. 心跳检测每个consumer的状态:

image-20230812164831318.png

3.7 数据复制带来的问题

通过前面的介绍我们可以知道,对于Kafka来说, 每一个Broker上都有不同Topic分区的不同副本,而每一个副本,会将其数据存储到该Kafka节点上面,对于不同的节点之间,通过副本直接的数据复制,来保证数据的最终一致性,与集群的高可用。

image-20230812171907145.png

3.7.1 重启操作

如果我们对一个机器进行重启: 首先,我们会关闭一个Broker,此时如果该Broker上存在副本的Leader,那么该副本将发生Leader切换,切换到其他节点上面并且在ISR中的Follower副本, 可以看到图中是切换到了第二个Broker上面,而此时因为数据在不断的写入,对于刚刚关闭重启的Broker来说,和新Leader之间一定会存在数据的滞后,此时这个Broker会追赶数据,重新加入到ISR当中,当数据追赶完成之后,我们需要回切Leader。这叫做==prefer leader==,目的是为了避免:在一个集群长期运行后,所有的Leader都分布在少数节点上,导致数据的不均衡。

通过上面的一个流程分析,我们可以发现对于一个Broker的重启来说,需要进行数据复制,所以时间成本会比较大,比如一个节点重启需要10分钟,一个集群有1000个节点,如果该集群需要重启升级,则需要10000分钟,那差不多就是一个星期,这样的时间成本是非常大的。

有同学可能会说,老师可以不可以并发多台重启呀,问的好,不可以。 为什么呢,在一个两副本(一个分片 有两个副本)的集群中,重启了两台机器,对某一分片来讲,可能两个分片都在这两台机器上面,则会导致该集群处于不可用的状态(影响整个Topic),这是更不能接受的。

image-20230812171451067.png

3.7.2 替换,扩容,缩容操作

替换,本质上来讲就是一个需要追更多数据的重启操作,正常重启只需要追一小部分,而替换则是需要复制整个Leader的数据,时间会更长。

扩容,当分片分配到新的机器上以后,也是相当于要从0开始复制一些新的副本。

缩容,缩容节点上面的分片也会分片到集群中剩余节点上面,分配过去的副本也会从0开始去复制数据。

以上三个操作均有数据复制所带来的时间成本问题,对于Kafka来说,运维操作带来的时间成本是不容忽视的。

3.8 负载不均衡

image-20230812171941905.png

这个场景当中,同一个Topic有4个分片,两副本, 可以看到,对于分片1来说,数据量是明显比其他分片要大的,当我们机器IO达到瓶颈的时候,可能就需要把第一台Broker上面的Partition3 迁移到其他负载小的Broker上面,接着往下看。

image-20230812172021248.png

但我们的数据复制又会引起Broker1的IO升高,所以问题就变成了,我为了去解决IO升高,但解决问题的过程又会带来更高的IO,所以就需要权衡IO设计出一个极其复杂的负载均衡策略。

3.9 问题总结

运维成本高(数据复制的问题)

对于负载不均衡的场景,涉及到权衡IO升高的问题,解决方案复杂

没有自己的缓存,完全依赖 Page Cache,不是很灵活

Controller 和 Coordinator和Broker 在同一进程中,大量 IO 会造成其性能下降,如果到一定程度可能会影响整个集群的可用性

4. BMQ

字节自研的消息队列,ByteMQ,简称BMQ。

BMQ兼容Kafka协议,存算分离,云原生消息队列,初期定位是承接高吞吐的离线业务场景,逐步替换掉对应的Kafka集群,我们来了解一下BMQ的架构特点。

4.1 BMQ介绍

BMQ架构图:

image-20230812195759637.png

和Kafka相比:

  • 多了Proxy Cluster
  • Controller和Coordinator独立部署
  • 存算分离,底层使用 HDFS 作为存储系统
  • Meta Storage 可以类比 Zookeeper

写入:Producer -> Proxy Cluster -> Broker Cluster -> HDFS

读取:Consumer -> Proxy Cluster -> HDFS

着重强调一下Prox和Broker无状态,为下面运维比较做铺垫:

运维操作KafkaBMQ
重启需要数据复制,分钟级重启重启后可直接对外服务,秒级完成
替换需要数据复制,分钟级替换,甚至天级别替换后可直接对外服务,秒级完成
扩容需要数据复制,分钟级扩容,甚至天级别扩容后可直接对外服务,秒级完成
缩容需要数据复制,分钟级缩容,甚至天级别缩容后可直接对外服务,秒级完成

4.2 HDFS写文件

同一个副本是由多个segment组成,随机选择一定数量的 DataNode 进行写入。

首先客户端写入前会选择一定数量的DataNode,这个数量是副本数;然后将一个文件写入到这几个节点上,切换到下一个segment之后,又会重新选择相同数量的节点进行写入。这样一来,对于单个副本的所有segment来讲,会随机的分配到分布式文件系统的整个集群中。

image-20230812200811318.png

4.3 BMQ文件结构

对于Kafka分片数据的写入,是通过先在Leader上面写好文件,然后同步到Follower上,所以对于同一个副本的所有Segment都在同一台机器上面,就会存在之前我们所说到的单分片过大导致负载不均衡的问题。

在BMQ集群中,因为对于单个副本来进,是随机分配到不同的节点上面的,因此不会存在Kafka的负载不均问题。图中P表示Partition,S表示Segment。

image-20230812201017928.png

4.4 Broker

4.4.1 Partition 状态机

其实对于写入的逻缉来说,我们还有一个状态机的机制,用来保证不会出现同一个分片在两个Broker上同时启动的情况,另外也能够保正一个分片的正常运行。首先,Controller做好分片的分配之后,如果在该Broker分配到了Broker,首先会start这个分片,然后进入Recover 状态,这个状态主要有两个目的:

  • 获取分片写入权利,也就是说,对于hdfs来讲,只会允许一个分片进行写入,只有拿到这个权利的分片才能写入
  • 如果上次分片是异常中断的,没有进行save checkpoint,这里会重新进行一次save checkpoint,然后就进入了正常的写流程状态,创建文件,写入数据,到一定大小之后又开始建立新的文件进行写入。

image-20230812201819475.png

保证对于任意分片在同一时刻只能在一个 Broker上存活

4.4.2 写文件流程

数据校验使用 CRC,检验参数是否合法。校验完成后,会把数据放入Buffer中,通过一个异步的Write Thread线程将数据最终写入到底层的存储系统当中。

这里有一个地方需要注意一下,就是对于业务的写入来说,可以配置返回方式,可以在写完缓存之后直接返回,另外我也可以数据真正写入存储系统后再返回,对于这两个来说前者损失了数据的可靠性,带来了吞吐性能的优势,因为只写入内存是比较快的,但如果在下一次flush前发生宕机了,这个时候数据就有可能丢失了;后者的话,因为数据已经写入了存储系统,这个时候也不需要担心数据据丢失,相对来说吞吐就会小一些。

我们再来看看Thread的具体逻辑,首先会将Bufer中的数据取出来,调用底层写入逻辑,在一定的时间周期上去flush,flush完成后开始建立Index,也就是offset和timestamp对于消息具体位置的映射关系。Index建立好以后,会save一次checkpoint,也就表示,checkpint后的数据是可以被消费,我们想一下,如果没有checkpoint的情况下会发生什么问题,如果flush完成之后宕机,indexi还没有建立,这个数据是不应该被消费的。最后当文件到达一定大小之后,需要建立一个新的segment文件来写入。

image-20230812202117883.png

4.4.3 写文件Failover

我们之前说到了,建立一个新的文件,会随机挑选与副本数量相当的数据节点进行写入,那如此时我们挑选节点中有一个出现了问题,导致不能正常写入了,我们应该么处理,是需要在这里等着这个节点恢复吗?当然不行,谁知道这个节点什么恢复,既然你不行,那就把你换了,可以重新找正常的节点创建新的文件进行写入,这样也就保证了我们写入的可用性。

image-20230812202731859.png

4.5 Proxy

首先Consumer发送一个Fetch Request,然后会有一个Wait流程,那么他的左右是什么呢,想象一个Topic,如果一直没有数据写入,那么此时consumer就会一直发送Fetch Request,如果Consumer数量过多,BMQ的server端是扛不住这个请求的,因此,我们设置了一个等待机制,如果没有tetch到指定大小的数据,那么proxy会等待一定的时间,再返回给用户侧,这样也就降低了fetch请求的IO次数。

经过我们的wait流程后,回到我们的Cache里面去找到是否有存在我们想要的数据,如果有直接返回,如果没有,再开始去存储系统当中寻找,首先会Open这个文件,然后通过Index找到数据所在的具体位置,从这个位置开始读取数据。

image-20230812202909454.png

4.6 多机房部署

为什么需要多机房部署,其实对于一个高可用的服务,除了要防止单机故障所带来的的影响意外,也要防止机房级故障所带来的影响,比如机房断电,机房之间网络故障等等。那我们来看看BMQ的多机房部署是怎么做的?

Proxy -> Broker -> Meta -> HDFS

image-20230812203202971.png

4.7 BMQ高级特性

4.7.1 泳道消息

image-20230812203328153.png

  • BOE:Bytedance Offline Environment,是一套完全独立的线下机房环境
  • PPE:Product Preview Environment,即产品预览环境

开发流程:

image-20230812203414351.png

BOE测试:

image-20230812203448074转存失败,建议直接上传图片文件

PPE验证:

image-20230812203509396.png

解决主干泳道流量隔离问题以及泳道资源重复创建问题:

image-20230812203539989.png

4.7.2 Databus

直接使用原生 SDK 会有什么问题?

  1. 客户端配置较为复杂
  2. 不支持动态配置,更改配置需要停掉服务
  3. 对于 latency 不是很敏感的业务,batch 效果不佳

image-20230812203601012.png

使用Databus:

  1. 简化消息队列客户端复杂度
  2. 解耦业务与 Topic
  3. 缓解集群压力,提高吞吐

4.7.3 Mirror

使用 Mirror 通过最终一致的方式,解决跨 Region 读写问题:

image-20230812203706860.png

4.7.4 Index

如果希望通过写入的 Logld、 Userld 或者其他的业务字段进行消息的查询,应该怎么做?

直接在 BMQ 中将数据结构化,配置索引 DDL,异步构建索引后,通过 ndex Query 服务读出数据。

image-20230812203753964.png

4.7.5 Parquet

Apache Parquet 是 Hadoop 生态圈中一种新型列式存储格式,它可以兼容 Hadoop 生态圈中大多数计算框架(Hadoop、Spark等),被多种查询引擎支持。

直接在 BMQ 中将数据结构化,通过 Parquet Engine,可以使用不同的方式构建 Parquet 格式文件。

image-20230812203914359.png

4.8 BMQ总结

BMQ 的架构模型:解决 Kafka 存在的问题

BMQ 读写流程:Failover 机制,写入状态机

BMQ 高级特性:泳道、Databus、Mirror、Index、Parquet

5. RocketMQ

5.1 使用场景

针对电商业务线,其业务涉及广泛,如注册、订单、库存、物流等,同时也会涉及许多业务峰值时刻,如秒杀活动、周年庆、定期特惠等。

5.2 RocketMQ介绍

RocketMQ是阿里研发的一个队列模型的消息中间件,后开源给apache基金会成为了apache的顶级开源项目,具有高性能、高可靠、高实时、分布式特点。

名称KafkaRocketMQ
逻辑队列TopicTopic
消息体 020MessageMessage
标签Tag
分区PartitionConsumerQueue
生产者ProducerProducer
生产者集群Producer Group
消费者ConsumerConsumer
消费者集群Consumer GroupConsumer Group
集群控制器ControllerNameserver

可以看到Producer,Consumer,Broker这个部分,Kaka和RocketMQ是一样的,而KaFka中的Partion概念在这里叫做ConsumerQueue。

image-20230812204451077.png

5.3 RocketMQ架构

数据流也是通过Producer发送给Broker集群,再由Consumer进行消费;

Broker节点有Master和Slave的概念;

NameServer为集群提供轻量级服务发现和路由。

image-20230812204635420.png

5.4 存储模型

对于一个Broker来说,所有的消息的会append到一个Commitlog上面,然后按照不同的Queue,重新Dispatch到不同的Consumer中,这样Consumer就可以按照Queue进行拉取消费,但需要注意的是,这里的ConsumerQueue所存储的并不是真实的数据,真实的数据其实只存在Commitlog中,这里存的仅仅是这个Queue所有消息在CommitLog上面的位置,相当于是这个Queue的一个密集素引。

image-20230812204732872.png

5.5 RocketMQ高级特性

5.5.1 事务场景

image-20230812204920732.png

先看一下我们最开始说的这个场景,正常情况下,这个下单的流程应该是这个样子,首先我保证库存足够能够顺利-1,这个时候通过消息队列让其他系统来处理,比如订单系统和商家系统,但这里有个比较重要的点,库存服务和消息队列必须要是在同一个事务内的,大家还记不记得事务的基本特性是什么。ACID,这里库存记录和往消息队列里面发的消息这两个事情,是需要有事务保证的,这样不至于发生,库存已经-1,但我的订单没有增加,或者商家也没有收到通知要发货。因RocketMQ提供事务消息来保证类似的场景,我们来看看其原理是怎么样的:

image-20230812205200734.png

5.5.2 延迟服务

定时消息是 RocketMQ 提供的一种高级消息类型,消息被发送至服务端后,在指定时间后才能被消费者消费。

通过设置一定的定时时间可以实现分布式场景的延时调度触发效果。

image-20230812205254781.png

5.5.3 消费重试和死信队列

该如何处理失败的消息呢?处理失败消息,一般两种处理方法,重试和丢弃失败消息。

生产者在初始化时设置消息发送最大重试次数,当出现上述触发条件的场景时,生产者客户端会按照设置的重试次数一直重试发送消息,直到消息发送成功或达到最大重试次数重试结束,并在最后一次重试失败后返回调用错误响应。

  • 同步发送:调用线程会一直阻塞,直到某次重试成功或最终重试失败,抛出错误码和异常。
  • 异步发送:调用线程不会阻塞,但调用结果会通过异常事件或者成功事件返回。

image-20230812205334454.png

5.6 RocketMQ总结

RocketMQ 的基本概念:Queue、Tag

RocketMQ 的底层原理:架构模型、存储模型

RocketMQ 的高级特性:事务消息、重试和死信队列、延迟队列