消息队列详解 | 青训营

128 阅读19分钟

消息队列

案例分析

案例一

系统崩溃

image.png

如果此时记录存储程序所在的机房被删库跑路了,上面这个流程会发生什么问题

解决方案

解决方案:解耦

image.png

案例二

服务能力有限

image.png

面对庞大的请求量,处理订单的服务一脸茫然,它的命运该何去何从?

解决方案

解决方案:削峰

image.png

案例三

链路耗时长尾

image.png

对于这个流程应该怎么优化来挽回这个暴躁的用户?

解决方案

解决方案:异步

image.png

案例四

日志处理

image.png

消息队列

定义

消息队列(MQ),指保存消息的一个容器,本质是个队列。但这个队列,需要支持高吞吐,高并发,并且高可用

image.png

消息队列发展历程

image.png

业界消息队列对比

kafka

分布式的、分区的、多副本的日志提交服务,在高吞吐场景下发挥较为出色

RocketMQ

低延迟、强一致、高性能、高可靠、万亿级容量和灵活的可扩展性,在一些实时场景中运用较广

Pulsar

是下一代云原生分布式消息流平台,集消息、存储、轻量化函数式计算为一体、采用存算分离的架构设计

BMQ

和Pulsar架构类似,存算分离,初期定位是承载高吞吐的离线业务场景,逐步替换掉对应的Kafka集群

Kafka

使用场景

适用于搜索服务、直播服务、订单服务、支付服务

image.png

如何使用Kafka

创建集群
新增Topic
编写生产者逻辑
编写消费者逻辑

第一步:首先需要创建一个kafka集群

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

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

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

基本概念

image.png

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

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

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

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

ConsumerGroup:消费者组,不同组Consumer消费进度互不干涉

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

Offset

offset:消息在partition内的相对位置信息,可以理解为唯一ID,在partition内部严格递增

image.png

Replica

每个分片有多个Replica,Leader Replica将会从ISR中选出

Replica:分片的副本,分布在不同的机器上,可用来容灾,Leader对外服务,Follower异步去拉取leader的数据进行一个同步,如果leader挂掉了,可以将Follower提升成leader在对外进行服务

ISR:意思是同步中的副本,对于follower来说,始终和leader是有一定差距的,但当这个差距比较小的时候,我们就可以将这个follower副本加入到ISR中,不在ISR中的副本是不允许提升成Leader的

image.png

数据复制

image.png

途中Broker代表每一个kafka的节点,所有的broker节点最终组成了一个集群。整个图表示,图中整个集群,包含了4个broker机器节点,集群有两个topic,分别是topic1和topic2,topic1有两个分片,topic2有1个分片,每个分片都是三副本的状态。这里中间有一个broker同时也扮演了controller的角色,controller是整个集群的大脑,负责对副本和broker进行分配

kafka架构

image.png

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

一条消息的自述

image.png

了解完整个kafka的基本概念和架构之后,我们从一条消息的视角来看看完整的处理流程,了解一下kafka为什么可以支撑如此高的吞吐

思考

首先思考一下

image.png

Producer

批量发送

image.png

数据压缩

image.png

Broker

数据的存储

image.png

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

消息文件结构

image.png

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

在每一个broker,都分布着不同topic的不同分片

磁盘结构

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

image.png

顺序写

image.png

采用顺序写的方式进行写入,以提高写入效率

如何找到消息

Consumer通过发送FetchRequest请求消息数据,Broker会将指定offset处的消息,按照时间窗口和消息大小窗口发送给Consumer,寻找数据这个细节是如何做到的呢?

image.png

偏移量索引文件

image.png

目标:寻找offset=28

二分找到小于目标offset的最大文件

二分找到小于目标offset的最大索引位置

image.png

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

时间戳索引文件

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

image.png

传统数据拷贝

image.png

零拷贝

image.png

Consumer从Broker中读取数据,通过sendfile的方式,将磁盘读到os内核缓冲区后,直接转到socket buffer进行网络发送

Producer生产的数据持久化到broker,采用mmap文件映射,实现顺序的快速写入

Consumer

消息的接收端

image.png

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

Low Level

通过手动进行分配,哪一个consumer消费哪一个partition完全由业务来决定

image.png

第一,手动分配,也就是kafka中所说的Low Level消费方式进行消费,这种分配方式的一个好处就是启动比较快,因为对于每一个consumer来说,启动的时候就已经知道了自己应该去消费那个消费方式,就好比图中的Consumer Group1来说,Consumer1去消费Partiyion1,2,3 Consumer2,去消费456,Consumer3去消费78。这些Consumer再启动的时候就已经知道分配方案了,但这样这种方式的缺点又是什么呢,想象一下,如果我们的Consumer3挂掉了。我们的7,8分片是不是就停止消费了。又或者,如果我们新增了一台consumer4,那是不是又需要停掉整个集群,重新修改配置再上线,保证consumer4也可以消费数据,其实上面两个问题,有时候对于线上业务来说是致命的

High Level

image.png

所以kafka也提供了自动分配的方式,这里也叫做High Level的消费方式,简单来说,就是在我们的broker集群中对于不同的Consumer Group来讲,都会选取一台Broker当做Coordinator,而Coordinator的作用就是帮助Consumer Group进行分片的分配,也叫做分片的rebalance,使用这种方式,如果ConsumerGroup中有发生宕机,或者有新的Consumer加入,整个partition和consumer都会重新进行分配来达到一个稳定的消费状态

Consumer Rebalance

image.png

image.png

image.png

帮助kafka提高吞吐或者稳定性的功能

Producer:批量发送、数据压缩

Broker:顺序写、消息索引、零拷贝

Consumer:Rebalance

数据复制问题

image.png

对于kafka来说,每一个broker上都有不同topic分区的不同副本,而每一个副本,会将其数据存储到该Kafka节点上面,对于不同的节点之间,通过副本直接的数据复制,来保证数据的最终一致性,与集群的高可用

重启操作

image.png

举个例子来说,如果我们对一个机器进行重启:首先,我们会关闭一个broker,此时如果该broker上存在副本的Leader,那么该副本将发生leader切换,切换到其他节点上面并且在ISR中的Follower副本,可以看到图中是切换到了第二个broker上面。而此时,因为数据在不断的写入,对于刚刚关闭重启的Broker来说,和新Leader之间一定会存在数据的滞后,此时这个Broker会追赶数据,重新加入到ISR当中当数据追赶完成之后,我们需要回切leader,这一步叫做prefer leader,这一步的目的是为了避免,在一个集群长期运行后,所有的leader都分布在少数节点上,导致数据的不均衡。通过上面的一个流程分析,我们可以发现对于一个Broker的重启来说,需要进行数据复制,所以时间成本会比较大,比如一个节点重启需要10分钟,一个集群有1000个节点,如果该集群需要重启升级,则需要10000分钟,那差不多就是一个星期,这样的时间成本是非常大的。

替换、扩容、缩容

image.png

如果是替换,和刚刚的重启有什么区别,其实替换,本质上来讲就是一个需要追更多数据的重启操作,因为正常重启只需要追一小部分,而替换则是需要复制整个leader的数据,时间会更长

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

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

负载不均衡

image.png

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

image.png

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

问题总结

1.因为有数据复制的问题,所以kafka运维的时间成本和人力人本都不低

2.对于负载不均衡的场景,我们需要有一个较为复杂的解决方案进行数据迁移,从而来权衡IO升高的问题

3.kafka没有自己的缓存,在进行数据读取的时候,只有Page Cache可以用,所以不是很灵活

4.kafka的Controller和Coordinator都是和Broker部署在一起的,Broker因为承载大量IO的原因,会导致Controller和Coordinator的性能下降,如果到一定程度,可能会影响整个集群的可用性

BMQ

简介

BMQ兼容Kafka协议,存算分离,云原生消息队列,初期定位是承接高吞吐的离线业务场景,逐步替换掉对应的Kafka集群

介绍

BMQ架构图

image.png

Producer → Consumer → Proxy → Broker → HDFS → Controller → Coordinator → Meta

着重强调一下Proxy和Broker无状态,为下面运维比较做铺垫。这里简单介绍一下存算分离,适配Kafka协议,为什么不选择Pulsar的原因

运维操作对比

实际上对于所有节点变更的操作,都仅仅只是集群元数据的变化,通常情况下都能秒级完成,而真正的数据已经移到下层分布式文件存储去了,所以运维操作不需要额外关心数据复制所带来的时间成本

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

HDFS写文件流程

随机选择一定数量的DataNode进行写入

image.png

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

文件结构

image.png

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

Broker

Partition状态机

image.png

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

其实对于写入的逻辑来说,我们还有一个状态机的机制,用来保证不会出现同一个分片在两个Broker上同时启动的情况,另外也能够保证一个分片的正常运行。首先,Controller做好分片的分配之后,如果在该Broker分配到了Broker,首先会start这个分片,然后进入Recover状态,这个状态主要有两个目的获取分片写入权利,也就是说,对于hdfs来讲,只会允许我一个分片进行写入,只有拿到这个权利的分片我才能写入,第二一个目的是如果上次分片是异常中断的,没有进行save checkpoint,这里会重新进行一次save checkpoint,然后就进入了正常的写流程状态,创建文件,写入数据,到一定大小之后又开始建立新的文件进行写入

写文件流程

image.png

数据校验:CRC,参数是否合法

校验完成后,会把数据放入Buffer中

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

我们再来看看Thread的具体逻辑,首先会将Buffer中的数据取出来,调用底层写入逻辑,在一定的时间周期上去flush,flush完成后开始建立index,也就是offset和timestamp对于消息具体位置的映射关系

index建立好以后,会save一次checkpoint,也就表示,checkpoint后的数据是可以被消费的了,我们想一下如果没有checkpoint的情况下会发生什么问题,如果flush完成之后宕机,index还没有建立,这个数据是不应该被消费的

最后当文件到达一定大小之后,需要建立一个新的segment文件来写入

写文件Failover

image.png

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

Proxy

image.png

首先Consumer发送一个Fetch Request,然后会有一个Wait流程,那么他的左右是什么呢,想象一个Topic,如果一直没有数据写入,那么,此时consumer就会一直发送Fetch Request,如果Consumer数量过多,BMQ的server端是扛不住这个请求的,因此,我们设置了一个等待机制,如果没有fetch到指定大小的数据,那么proxy会等待一定的时间,在返回给用户侧,这样也就降低了fetch请求的IO次数,经过我们的wait流程后,我们会到我们的Cache里面去找到是否有存在我们想要的数据,如果有直接返回,如果没有,再开始去存储系统当中寻找,首先会Open这个文件,然后通过index找到数据所在的具体位置,从这个位置开始读取数据

多机房部署

image.png

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

BMQ-高级特性

image.png

泳道 → Databus → Mirror → Index → Parquet

泳道消息

开发流程

image.png

BOE:Bytedance Offline Environment,是一套完全独立的线下机房环境

PPE:Product Preview Environment,即产品预览环境

BOE测试

多个人同时测试,需要等待上一个人测试完成

image.png

每多一个测试人员,都需要重新搭建一个相同配置的Topic,造成人力和资源的浪费

image.png

PPE验证

对于PPE的消费者来说,资源没有生产环境多,所以无法承受生产环境的流量

image.png

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

image.png

Databus

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

image.png

1.客户端配置较为复杂

2.不支持动态配置,更改配置需要停掉服务

3.对于latency不是很敏感的业务,batch效果不佳

使用Databus

image.png

1.简化消息队列客户端复杂度

2.解耦业务与Topic

3.缓解集群压力,提高吞吐

Mirror

image.png

思考一下,我们是否可以通过多机房部署的方式,解决跨Region读写的问题?

image.png

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

Index

image.png

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

image.png

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

Parquet

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

image.png

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

image.png

RocketMQ

使用场景

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

RocketMQ基本概念

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

image.png

根据我们刚刚的介绍,可以看到Producer,Consumer,Broker这三个部分,kafka和RocketMQ是一样的,而Kafka中的partition概念在这里叫做ConsumerQueue

RocketMQ架构

image.png

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

Broker节点有Master和Slave的概念

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

存储模型

image.png

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

高级特性

事务场景

image.png

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

事务消息

image.png

延迟发送

场景一

image.png

场景二

image.png

延迟消息

image.png

处理失败

image.png

该如何处理失败的消息呢?

消费重试和死信队列

image.png