Kafka
应用场景
Kafka 是一个分布式流式处理平台,早期被用来用于处理海量的日志,后面才慢慢发展成了一款功能全面的高性能消息队列。流平台具有三个关键功能:
1、消息队列:发布和订阅消息流,类似于消息队列。
2、容错的持久方式存储记录消息流:Kafka会把消息持久化到磁盘,有效避免了消息丢失的风险。
3、流式处理平台:在消息发布的时候进行处理,Kafka提供了一个完整的流式处理类库。
主要应用场景:
- 消息队列:建立实时流数据管道,以可靠地在系统或应用程序之间获取数据。
- 数据处理: 构建实时的流数据处理程序来转换或处理数据流。
优势
与 RocketMQ、RabbitMQ 对比,Kafka 主要的优势如下:
- 极致的性能:基于 Scala 和 Java 语言开发,设计中大量使用了批量处理和异步的思想,最高可以每秒处理千万级别的消息。
- 生态系统兼容性无可匹敌:Kafka 与周边生态系统的兼容性是最好的没有之一,尤其在大数据和流计算领域。
消息模型
Kafka 采用的就是发布 - 订阅模型。
在发布 - 订阅模型中,如果只有一个订阅者,那它和队列模型就基本是一样的了。所以说,发布 - 订阅模型在功能层面上是可以兼容队列模型的。
RocketMQ 的消息模型和 Kafka 基本是完全一样的。唯一的区别是 Kafka 中没有队列这个概念,与之对应的是 Partition(分区)
(1)什么是 Producer、Consumer、Broker、Topic、Partition ?
Kafka 将生产者发布的消息发送到 Topic(主题) 中,需要这些消息的消费者可以订阅这些 Topic(主题) ,如下图所示:
-
Producer(生产者) : 产生消息的一方。
-
Consumer(消费者) : 消费消息的一方。
-
Broker(代理) : 可以看作是一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。
Broker 消息文件结构:
同时,你一定也注意到每个 Broker 中又包含了 Topic 以及 Partition 这两个重要的概念:
- Topic(主题) : Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。
- Partition(分区) : Partition 属于 Topic 的一部分。一个 Topic 可以有多个 Partition ,并且同一 Topic 下的 Partition 可以分布在不同的 Broker 上,这也就表明一个 Topic 可以横跨多个 Broker 。这正如我上面所画的图一样。
划重点:Kafka 中的 Partition(分区) 实际上可以对应成为消息队列中的队列。
(2)这些组件有哪些功能可以帮助Kafka提高吞吐或稳定性?
Producer:
批量发送(减少IO次数,从而加强发送能力)、
数据压缩(目前支持 Snappy、Gzip、LZ4、ZSTD压缩算法)
Broker:
顺序写(写入效率高),
消息索引(二分查找偏移量索引文件、时间戳索引文件),
零拷贝
Consumer:
High Level 消费方式:简单来说,就是在我们的 Broker集群中,对于不同的 Consumer Group来讲,都会选取一台Broker当做Coordinator,而Coordinator的作用就是帮助 Consumer Group进行分片的分配,也叫做分片的 rebalance,使用这种方式,如果 Consumer Group中有发生宕机,或者有新的 Consumer 加入,整个partition 和 Consumer 都会重新进行分配来达到一个稳定的消费状态。
多副本机制
还有一点我觉得比较重要的是 Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。
生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。当 leader 副本发生故障时会从 follower 中选举出一个 leader,但是 follower 中如果有和 leader 同步程度达不到要求的参加不了 leader 的竞选。
Kafka 的多分区(Partition)以及多副本(Replica)机制有什么好处呢?
- Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力(负载均衡)。
- Partition 可以指定对应的 Replica 数, 这也极大地提高了消息存储的安全性, 提高了容灾能力,不过也相应的增加了所需要的存储空间。
Zookeeper和Kafka
Zookeeper 主要为 Kafka 做了下面这些事情:
- Broker 注册:在 Zookeeper 上会有一个专门用来进行 Broker 服务器列表记录的节点。每个 Broker 在启动时,都会到 Zookeeper 上进行注册,即到
/brokers/ids下创建属于自己的节点。每个 Broker 就会将自己的 IP 地址和端口等信息记录到该节点中去 - Topic 注册:在 Kafka 中,同一个Topic 的消息会被分成多个分区并将其分布在多个 Broker 上,这些分区信息及与 Broker 的对应关系也都是由 Zookeeper 在维护。比如我创建了一个名字为 my-topic 的主题并且它有两个分区,对应到 zookeeper 中会创建这些文件夹:
/brokers/topics/my-topic/Partitions/0、/brokers/topics/my-topic/Partitions/1 - 负载均衡:上面也说过了 Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力。 对于同一个 Topic 的不同 Partition,Kafka 会尽力将这些 Partition 分布到不同的 Broker 服务器上。当生产者产生消息后也会尽量投递到不同 Broker 的 Partition 里面。当 Consumer 消费的时候,Zookeeper 可以根据当前的 Partition 数量以及 Consumer 数量来实现动态负载均衡。
消费顺序
我们在使用消息队列的过程中经常有业务场景需要严格保证消息的消费顺序,比如我们同时发了 2 个消息,这 2 个消息对应的操作分别对应的数据库操作是:
- 更改用户会员等级。
- 根据会员等级计算订单价格。
假如这两条消息的消费顺序不一样造成的最终结果就会截然不同。
我们知道 Kafka 中 Partition(分区)是真正保存消息的地方,我们发送的消息都被放在了这里。而我们的 Partition(分区) 又存在于 Topic(主题) 这个概念中,并且我们可以给特定 Topic 指定多个 Partition。
每次添加消息到 Partition(分区) 的时候都会采用尾加法,如上图所示。 Kafka 只能为我们保证 Partition(分区) 中的消息有序。
消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。Kafka 通过偏移量(offset)来保证消息在分区内的顺序性。
所以,我们就有一种很简单的保证消息消费顺序的方法:1 个 Topic 只对应一个 Partition。这样当然可以解决问题,但是破坏了 Kafka 的设计初衷。
Kafka 中发送 1 条消息的时候,可以指定 topic, partition, key,data(数据) 4 个参数。如果你发送消息的时候指定了 Partition 的话,所有消息都会被发送到指定的 Partition。并且,同一个 key 的消息可以保证只发送到同一个 partition,这个我们可以采用表/对象的 id 来作为 key 。
总结一下,对于如何保证 Kafka 中消息消费的顺序,有了下面两种方法:
- 1 个 Topic 只对应一个 Partition。
- (推荐)发送消息的时候指定 key/Partition。
如何保证数据不丢失
生产者丢失消息的情况
生产者(Producer) 调用send方法发送消息之后,消息可能因为网络问题并没有发送过去。
所以,我们不能默认在调用send方法发送消息之后消息发送成功了。为了确定消息是发送成功,我们要判断消息发送的结果。但是要注意的是 Kafka 生产者(Producer) 使用 send 方法发送消息实际上是异步的操作,我们可以通过 get()方法获取调用结果,但是这样也让它变为了同步操作。如果消息发送失败的话,我们检查失败的原因之后重新发送即可。
消费者丢失消息的情况
消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。偏移量(offset)表示 Consumer 当前消费到的 Partition(分区)的所在的位置。Kafka 通过偏移量(offset)可以保证消息在分区内的顺序性。
当消费者拉取到了分区的某个消息之后,消费者会自动提交了 offset。但是试想一下,当消费者刚拿到这个消息准备进行真正消费的时候,突然挂掉了,消息实际上并没有被消费,但是 offset 却被自动提交了。解决办法是我们可以手动关闭自动提交 offset,每次在真正消费完消息之后再自己手动提交 offset 。
Kafka 弄丢了消息
我们知道 Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。
试想一种情况:假如 leader 副本所在的 broker 突然挂掉,那么就要从 follower 副本重新选出一个 leader ,但是 leader 的数据还有一些没有被 follower 副本的同步的话,就会造成消息丢失。
设置 acks = all
解决办法就是我们设置 acks = all。acks 是 Kafka 生产者(Producer) 很重要的一个参数。
acks 的默认值即为 1,代表我们的消息被 leader 副本接收之后就算被成功发送。当我们配置 acks = all 表示只有所有 ISR 列表的副本全部收到消息时,生产者才会接收到来自服务器的响应. 这种模式是最高级别的,也是最安全的,可以确保不止一个 Broker 接收到了消息. 该模式的延迟会很高.
设置 replication.factor >= 3
为了保证 leader 副本能有 follower 副本能同步消息,我们一般会为 topic 设置 replication.factor >= 3。这样就可以保证每个 分区(partition) 至少有 3 个副本。虽然造成了数据冗余,但是带来了数据的安全性。
设置 min.insync.replicas > 1
一般情况下我们还需要设置 min.insync.replicas> 1 ,这样配置代表消息至少要被写入到 2 个副本才算是被成功发送。min.insync.replicas 的默认值为 1 ,在实际生产中应尽量避免默认值 1。
但是,为了保证整个 Kafka 服务的高可用性,你需要确保 replication.factor > min.insync.replicas 。为什么呢?设想一下假如两者相等的话,只要是有一个副本挂掉,整个分区就无法正常工作了。这明显违反高可用性!一般推荐设置成 replication.factor = min.insync.replicas + 1。
如何保证消息不重复消费
kafka 出现消息重复消费的原因:
- 服务端侧已经消费的数据没有成功提交 offset(根本原因)。
- Kafka 侧 由于服务端处理业务时间长或者网络链接等等原因让 Kafka 认为服务假死,触发了分区 rebalance。
解决方案:
-
消费消息服务做幂等校验,比如 Redis 的 set、MySQL 的主键等天然的幂等功能。这种方法最有效。
-
enable.auto.commit什么时候提交 offset 合适?
- 处理完消息再提交:依旧有消息重复消费的风险,和自动提交一样
- 拉取到消息即提交:会有消息丢失的风险。允许消息延时的场景,一般会采用这种方式。然后,通过定时任务在业务不繁忙(比如凌晨)的时候做数据兜底。
Kafka的重启、替换、扩容、缩容
Kafka重启:
对于刚刚关闭重启的 Broker来说,和新的leader之间一定会存在数据的滞后,此时这个Broker会追赶数据,重新加入到 ISR当中。当数据追赶完成之后,我们需要回切leader。这个过程,时间成本会比较大。
替换:
本质上就是一个需要追更多数据的重启操作,时间会更长
扩容:
当分片分配到新的机器上以后,也是相当于要从0开始复制一些新的副本
缩容:
缩容节点上面的分片也会分片到集群中剩余节点上面,分配过去的副本也会从0开始去复制数据
Kafka问题总结
1、因为数据复制问题,Kafka运维的时间和人力成本都不低
2、对于负载不均衡的场景,解决方案比较复杂。
3、没有自己的缓存,在进行数据读取的时候,只有 Page Cache可以用,不灵活
4、Controller 和 Coordinator 和 Broker在同一进程中,大量 IO 会造成其性能下降
BMQ
BMQ兼容 Kafka 协议,存算分离,云原生消息队列,初期定位是承接高吞吐的离线业务场景,逐步替换掉对应的Kafka集群
BMQ架构模型
分布式存储系统为HDFS
运维操作上,对于所有节点变更,都仅仅是集群元数据的变化,通常情况下能秒级完成,而真正的数据已经移到下层分布式文件存储去了,所以运维操作不需要额外关心数据复制所带来的时间成本
BMQ读写流程
HDFS写文件流程:
随机选择一定数量的DataNode进行写入。
BMQ中同一个副本(Partition)是由多个segment组成的。BMQ 对于单个文件写入的机制:首先客户端写入前会随机选择与副本数量相当的 DataNode ,然后将一个文件写入到这三个节点上,切换到下一个 segment 之后,又会重新选择三个节点进行写入。这样一来,对于单个副本的所有 segment 来讲,会随机的分配到分布式文件系统的整个集群中
BMQ文件结构:
对于 Kafka 分片数据的写入,是通过先在 Leader 上面写好文件,然后同步到 Follower 上,所以对于同一个副本的所有 Segment 都在同一台机器上面。就会存在之前我们所说到的单分片过大导致负载不均衡的问题,但在 BMQ 集群中,因为对于单个副本来讲,是随机分配到不同的节点上面的,因此不会存在 Kafka 的负载不均问题
Broker-Partition 状态机:
保证对于任意分片在同一时刻只能在一个Broker上存活。
其实对于写入的逻辑来说,我们还有一个状态机的机制,用来保证不会出现同一个分片在两个 Broker 上同时启动的情况,另外也能够保证一个分片的正常运行。
首先, Controller 做好分片的分配之后,如果在该 Broker 分配到了 Broker ,首先会 start 这个分片,然后进入 Recover 状态,这个状态主要有两个目的:第一、获取分片写入权利,也就是说,对于 hdfs 来讲,只会允许我一个分片进行写入,只有拿到这个权利的分片我才能写入。第二、如果上次分片是异常中断的,没有进行 savecheckpoint ,这里会重新进行一次 savecheckpoint ,然后就进入了正常的写流程状态,创建文件,写入数据,到一定大小之后又开始建立新的文件进行写入。
Broker写文件流程:
数据校验: CRC ,参数是否合法校验完成后,会把数据放入 Buffer 中通过一个异步的 WriteThread 线程将数据最终写入到底层的存储系统当中。‘这里有一个地方需要注意一下,就是对于业务的写入来说,可以配置返回方式:① 写完缓存之后直接返回 ②数据真正写入存储系统后再返回。前者损失了数据的可靠性,带来了吞吐性能的优势,因为只写入内存是比较快的,但如果在下一次 flush 前发生宕机了,这个时候数据就有可能丢失了,后者的话,不需要担心数据丢失,但吞吐就相应会小一些。
我们再来看看 Thread 的具体逻辑,首先会将 Buffer 中的数据取出来,调用底层写入逻辑,在一定的时间周期上去 flush , flush 完成后开始建立 Index ,也就是 offset 和 timestamp 对于消息具体位置的映射关系。 Index 建立好以后,会 save 一次 checkpoint ,也就表示, checkpoint 后的数据是可以被消费的了,我们想一下,如果没有 checkpoint 的情况下会发生什么问题,如果 flush 完成之后启机, index 还没有建立,这个数据是不应该被消费的。
最后当文件到达一定大小之后,需要建立一个新的 segment 文件来写入。
Broker 写文件Failover
如果写文件时我们挑选节点中有一个出现了问题,导致不能正常写入了,并不会等着这个节点恢复,而是重新找正常的节点创建新的文件进行写入,这样也就保证了我们写入的可用性。
Proxy
首先 Consumer 发送一个 FetchRequest ,然后会有一个 Wait 流程,那么他的作用是什么呢,想象一个 Topic ,如果一直没有数据写入,那么,此时 consumer 就会一直发送 FetchRequest ,如果 Consumer 数量过多, BMQ 的 server 端是扛不住这个请求的,因此,我们设置了一个等待机制,如果没有 fetch 到指定大小的数据,那么 proxy 会等待一定的时间,再返回给用户侧,这样也就降低了 fetch 请求的 IO 次数,经过我们的 wait 流程,我们会到我们的 Cache 里面去找到是否有存在我们想要的数据,如果有直接返回,如果没有,再开始去存储系统当中寻找,首先会 Open 这个文件,然后通过 Index 找到数据所在的具体位置,从这个位置开始读取数据
BMQ高级特性
泳道
解决主干泳道流量隔离问题以及泳道资源重复创建问题
Databus
直接使用原生 SDK 会有什么问题?
1、客户端配置较为复杂
2、不支持动态配置。更改配置需要停掉服务
3、对于延迟不是很敏感的业务,batch效果不佳
使用Databus的好处:
1、简化消息队列客户端复杂度
2、解耦业务与Topic
3、缓解集群压力,提高吞吐
Mirror
使用 Mirror通过最终一致的方式,解决跨Region读写问题
Index
如果希望通过写入的LogId、UserId或者其他业务字段进行消息的查询,直接在 BMQ中将数据结构化,配置索引 DDL,异步构建索引后,通过 Index Query 服务读出数据
Parquet
Apache Parquet是Hadoop生态圈中一种新型列式存储格式,它可以兼容 Hadoop生态圈中大多数计算框架(Hadoop、Spark),被多种查询引擎支持(Hive、Impala、Drill等)。
RocketMQ
阿里团队开发的具有高性能,高可靠、高实时、分布式等特点的消息队列。
消息模型
RocketMQ 有 Producer Group、Topic、Consumer Group 三个角色:
Producer Group生产者组:代表某一类的生产者,比如我们有多个秒杀系统作为生产者,这多个合在一起就是一个Producer Group生产者组,它们一般生产相同的消息。Consumer Group消费者组:代表某一类的消费者,比如我们有多个短信系统作为消费者,这多个合在一起就是一个Consumer Group消费者组,它们一般消费相同的消息。Topic主题:代表一类消息,比如订单消息,物流消息等等。
你可以看到图中生产者组中的生产者会向主题发送消息,而 主题中存在多个队列,生产者每次生产消息之后是指定主题中的某个队列发送消息的。
每个主题中都有多个队列(分布在不同的 Broker中,如果是集群的话,Broker又分布在不同的服务器中),集群消费模式下,一个消费者集群多台机器共同消费一个 topic 的多个队列,一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 Consumer1 和 Consumer2 分别对应着两个队列,而 Consumer3 是没有队列对应的,所以一般来讲要控制 消费者组中的消费者个数和主题中队列个数相同 。
每个消费组在每个队列上维护一个消费位置 ,为什么呢?
因为我们刚刚画的仅仅是一个消费者组,我们知道在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。如果此时有多个消费者组,那么消息被一个消费者组消费完之后是不会删除的(因为其它消费者组也需要 呀),它仅仅是为每个消费者组维护一个 消费位移(offset) ,每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了 。
总结来说,RocketMQ 通过使用在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置 实现了 主题模式/发布订阅模式 。
技术架构
RocketMQ 技术架构中有四大角色 NameServer、Broker、Producer、Consumer 。我来向大家分别解释一下这四个角色是干啥的。
-
Broker:主要负责消息的存储、投递和查询以及服务高可用保证。说白了就是消息队列服务器嘛,生产者生产消息到Broker,消费者从Broker拉取消息并消费。这里,我还得普及一下关于
Broker、Topic和 队列的关系。上面我讲解了Topic和队列的关系——一个Topic中存在多个队列,那么这个Topic和队列存放在哪呢?一个 Topic 分布在多个 Broker上,一个 Broker 可以配置多个 Topic ,它们是多对多的关系。
如果某个
Topic消息量很大,应该给它多配置几个队列(上文中提到了提高并发能力),并且 尽量多分布在不同 Broker 上,以减轻某个 Broker 的压力 。Topic消息量都比较均匀的情况下,如果某个broker上的队列越多,则该broker压力越大。 -
所以说我们需要配置多个 Broker。
-
NameServer:不知道你们有没有接触过ZooKeeper和Spring Cloud中的Eureka,它其实也是一个 注册中心 ,主要提供两个功能:Broker 管理 和 路由信息管理 。说白了就是Broker会将自己的信息注册到NameServer中,此时NameServer就存放了很多Broker的信息(Broker 的路由表),消费者和生产者就从NameServer中获取路由表然后照着路由表的信息和对应的Broker进行通信(生产者和消费者定期会向NameServer去查询相关的Broker的信息)。 -
Producer:消息发布的角色,支持分布式集群方式部署。说白了就是生产者。 -
Consumer:消息消费的角色,支持分布式集群方式部署。支持以 push 推,pull 拉两种模式对消息进行消费。同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制。说白了就是消费者。
RocketMQ 中的技术架构图:
第一、我们的 Broker 做了集群并且还进行了主从部署 ,由于消息分布在各个 Broker 上,一旦某个 Broker 宕机,则该Broker 上的消息读写都会受到影响。所以 Rocketmq 提供了 master/slave 的结构,salve 定时从 master 同步数据(同步刷盘或者异步刷盘),如果 master 宕机,则 slave 提供消费服务,但是不能写入消息 (后面我还会提到哦)。
第二、为了保证 HA ,我们的 NameServer 也做了集群部署,但是请注意它是 去中心化 的。也就意味着它没有主节点,你可以很明显地看出 NameServer 的所有节点是没有进行 Info Replicate 的,在 RocketMQ 中是通过 单个 Broker 和所有 NameServer 保持长连接 ,并且在每隔 30 秒 Broker 会向所有 Nameserver 发送心跳,心跳包含了自身的 Topic 配置信息,这个步骤就对应这上面的 Routing Info 。
第三、在生产者需要向 Broker 发送消息的时候,需要先从 NameServer 获取关于 Broker 的路由信息,然后通过 轮询 的方法去向每个队列中生产数据以达到 负载均衡 的效果。
第四、消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 Pull 请求来获取消息数据。Consumer 可以以两种模式启动—— 广播(Broadcast)和集群(Cluster) 。广播模式下,一条消息会发送给 同一个消费组中的所有消费者 ,集群模式下消息只会发送给一个消费者。
RocketMQ 如何实现分布式事务?
如今比较常见的分布式事务实现有 2PC、TCC 和事务消息(half 半消息机制)。每一种实现都有其特定的使用场景,但是也有各自的问题,都不是完美的解决方案。
在 RocketMQ 中使用的是 事务消息加上事务反查机制 来解决分布式事务问题的。我画了张图,大家可以对照着图进行理解。
在第一步发送的 half 消息 ,它的意思是 在事务提交之前,对于消费者来说,这个消息是不可见的 。
那么,如何做到写入消息但是对用户不可见呢?RocketMQ 事务消息的做法是:如果消息是 half 消息,将备份原消息的主题与消息消费队列,然后 改变主题 为 RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费 half 类型的消息,然后 RocketMQ 会开启一个定时任务,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。
你可以试想一下,如果没有从第 5 步开始的 事务反查机制 ,如果出现网路波动第 4 步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题,他就像一个无头苍蝇一样。在 RocketMQ 中就是使用的上述的事务反查来解决的,而在 Kafka 中通常是直接抛出一个异常让用户来自行解决。
你还需要注意的是,在 MQ Server 指向系统 B 的操作已经和系统 A 不相关了,也就是说在消息队列中的分布式事务是——本地事务和存储消息到消息队列才是同一个事务。这样也就产生了事务的最终一致性,因为整个过程是异步的,每个系统只要保证它自己那一部分的事务就行了。
存储机制
在 Topic 中的 队列是以什么样的形式存在的?队列中的消息又是如何进行存储持久化的呢? RocketMQ 消息存储架构中的三大角色——CommitLog、ConsumeQueue 和 IndexFile 。
CommitLog:消息主体以及元数据的存储主体,存储Producer端写入的消息主体内容,消息内容不是定长的。单个文件大小默认 1G ,文件名长度为 20 位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表了第一个文件,起始偏移量为 0,文件大小为 1G=1073741824;当第一个文件写满了,第二个文件为 00000000001073741824,起始偏移量为 1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件。ConsumeQueue:消息消费队列,引入的目的主要是提高消息消费的性能(我们再前面也讲了),由于RocketMQ是基于主题Topic的订阅模式,消息消费是针对主题进行的,如果要遍历commitlog文件中根据Topic检索消息是非常低效的。Consumer即可根据ConsumeQueue来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量 offset ,消息大小 size 和消息 Tag 的 HashCode 值。consumequeue 文件可以看成是基于 topic 的 commitlog 索引文件,故consumequeue文件夹的组织方式如下:topic/queue/file 三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样consumequeue文件采取定长设计,每一个条目共 20 个字节,分别为 8 字节的commitlog物理偏移量、4 字节的消息长度、8 字节 taghashcode,单个文件由 30W 个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue文件大小约 5.72M;IndexFile:IndexFile(索引文件)提供了一种可以通过 key 或时间区间来查询消息的方法。这里只做科普不做详细介绍。
总结来说,整个消息存储的结构,最主要的就是 CommitLoq 和 ConsumeQueue 。而 ConsumeQueue 你可以大概理解为 Topic 中的队列。
RocketMQ 采用的是 混合型的存储结构 ,即为 Broker 单个实例下所有的队列共用一个日志数据文件来存储消息。有意思的是在同样高并发的 Kafka 中会为每个 Topic 分配一个存储文件。这就有点类似于我们有一大堆书需要装上书架,RockeMQ 是不分书的种类直接成批的塞上去的,而 Kafka 是将书本放入指定的分类区域的。
而 RocketMQ 为什么要这么做呢?原因是 提高数据的写入效率 ,不分 Topic 意味着我们有更大的几率获取 成批 的消息进行数据写入,但也会带来一个麻烦就是读取消息的时候需要遍历整个大文件,这是非常耗时的。
所以,在 RocketMQ 中又使用了 ConsumeQueue 作为每个队列的索引文件来 提升读取消息的效率。我们可以直接根据队列的消息序号,计算出索引的全局位置(索引序号*索引固定⻓度 20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。
讲到这里,你可能对 RockeMQ 的存储架构还有些模糊,没事,我们结合着图来理解一下。
首先,在最上面的那一块就是我刚刚讲的你现在可以直接 把 ConsumerQueue 理解为 Queue。
在图中最左边说明了红色方块代表被写入的消息,虚线方块代表等待被写入的。左边的生产者发送消息会指定 Topic、QueueId 和具体消息内容,而在 Broker 中管你是哪门子消息,他直接 全部顺序存储到了 CommitLog。而根据生产者指定的 Topic 和 QueueId 将这条消息本身在 CommitLog 的偏移(offset),消息本身大小,和 tag 的 hash 值存入对应的 ConsumeQueue 索引文件中。而在每个队列中都保存了 ConsumeOffset 即每个消费者组的消费位置(我在架构那里提到了,忘了的同学可以回去看一下),而消费者拉取消息进行消费的时候只需要根据 ConsumeOffset 获取下一个未被消费的消息就行了。