结构
- 物理文件commitLog:一个 broker 为多个 topic 服务,这些 topic 的消息都存储同一个文件组中,消息顺序写入,永远 都是当前文件在写,其他文件只读。一般一个文件为1g,写满后就新建一个文件。
- 索引文件:分topic存储,如果一个topic有两个队列(逻辑分区/partition),那就会生成两个文件。记录(offset,len,tag hashcode)该消息在物理文件的物理位置,消息大小,消息类型封装成一个固定大 小的数据结构。和消息文件一样,文件名是 起始字节位置,写满后,产生一个新的文件。
- 逻辑分区:核心跟消费者的负载均衡有关,每个分区对每个消费者group只挂一个消费者,同一个group的多余消费者不参与消费;相反,如果分组内的消费者数目比分区数目小,则有部分消费者要额外承担消息的消费任务,即一个消费者要消费多个partition的数据。
- 文件读写:Metaq 使用了文件内存映射特性,对应的是 MappedByteBuffer 对象。
- 数据丢失:在程序中对 MappedByteBuffer 做的修改不一定会立即同步到文件 系统中。如果在没有同步之 前发生了程序错误,可能导致所做的修改丢失。因此,在执行完某些重要文件内容的更新操作之后, 应该调用 MappedByteBuffer 类 的 force 方法来强制要求把这些更新同步到底层文件中
- 消息消费:
-
- 根据 topic 和 partition 找到逻辑队列:A
- 根据 offset 从 A 定位指定的索引文件:B
- 从 B 中读取所有的索引数据:C
- 遍历 C,根据索引单元的消息物理地址和消息长度,找到物理消息 D,将 D 放入集合,并计 算消息的累加长度,若大于请求里消息最大长度 maxSize,则终止遍历,返回结果。
重复消费
消息结果里有当前批次消息的索引读取结束位置(offset),消费端会将当前 offset 存储在本地, 下次拉取消息时,要将结束位置作为参数放入消息拉取请求里。由于 metaq 是分布式结构,消费端和 生产端的对应关系可能会经常变动,offset 不能仅仅只是保存到本地,必须保存在一个共享的存储里, 比如 zookeeper,数据库,或共享的文件系统。默认情况下,metaq 将 offset 及时保存在本地,并 定时写入 zookeeper。在某些情况下,会发生消息重复消费,比如某个 consumer 挂掉了,新的 consumer 将会接替它继续消费,但是 offset 是异步存储的,可能新的 consumer 起来后,从 zookeeper 上拿到的还是旧的 offset,导致当前批次重复,产生重复消费。
定时消息
-
- MQ新版定时引擎,没有采用多级时间轮的方案,而是设计了一个数据结构,把所有Tick的内容合并到一个文件中,其结构如下:
注:x-y,表示定时在x时刻的第y条消息 - TimerLog
定时消息的记录文件,遵循Append Only原则。每条记录包含一个prev_pos,指向前一条定时到同样时刻的记录。每条记录的内容可以包含定时消息本身,也可以只包含定时消息的位置信息。在MQ定时消息的设计中,TimerLog只记录位置信息,实际消息存储在CommitLog中。 - 入队过程
指写入TimerWheel和TimerLog的过程。
根据定时时间从TimerWheel读出上一条定时到同样时刻的消息的位置(没有就是-1),把该位置与消息相关信息追加到TimerLog中,同时修改TimerWheel指向关系 - 出队过程
指读出TimerWheel和TimerLog的过程。
从TimerWheel找到TimerLog中对应位置,然后按照指向关系依次读取出每条消息投递给使用方。 - 读写分离
也即入队操作和出队操作分离,两者互不影响。
在内存中,指针所指的Tick,可以立即读出所有内容。但是到了磁盘中,每个Tick的内容可能有很多,读出时,会有延迟。如果写入方去等待读出方,则会造成大量的写延迟。因此,实际设计时,是有两个指针的:
- MQ新版定时引擎,没有采用多级时间轮的方案,而是设计了一个数据结构,把所有Tick的内容合并到一个文件中,其结构如下:
-
-
- 写指针,CurrTimePos,指向当前Tick
- 读指针,ReadTimePos,指向已经读到的Tick
-
对应地,TimerWheel的设计是分为读窗口和写窗口:
-
-
- 写窗口,就是TimerWheel的有效长度
- 读窗口,就是TimerWheel留给读指针的缓冲空间。
-
如果读指针移动速度过慢,则还是有可能造成写指针覆盖了读指针,为了避免此类问题,则需要将读窗口设置足够大(MQ定时消息中,读窗口设置成和写窗口一样大,默认都是7天),最好是大到消息已经过期可以被丢弃。
在这种设计下,读TimerWheel和写TimerWheel是各自独立进行的,无任何锁操作。
-
- 删除定时消息
TimerLog遵循Append Only原则,这给设计和实现带来了很多便利,但也带来一个麻烦,如果需要删除定时消息,则不能直接去删除TimerLog中间的内容。
实际实现是,插入一条定时在同时刻的“删除消息”,然后在读取时,进行过滤,类似于消除游戏。
如上图所示,即是利用消息1-5区删除1-4,最后实际投递给适用方的消息只有1-1,1-2,1-3。
重试消息
-
- RocketMQ会为每个消费组都设置一个Topic名称为“%RETRY%+consumerGroup”的重试队列(这里需要注意的是,这个Topic的重试队列是针对消费组,而不是针对每个Topic设置的),用于暂时保存因为各种异常而导致Consumer端无法消费的消息。考虑到异常恢复起来需要一些时间,会为重试队列设置多个重试级别,每个重试级别都有与之对应的重新投递延时,重试次数越多投递延时就越大。RocketMQ对于重试消息的处理是先保存至Topic名称为“SCHEDULE_TOPIC_XXXX”的延迟队列中,后台定时任务按照对应的时间进行Delay后重新保存至“%RETRY%+consumerGroup”的重试队列中。
顺序消息
- 全局有序:全局顺序时使用一个queue;
- 分区有序:局部顺序时多个queue并行消费;
生产端:
Producer端确保消息顺序唯一要做的事情就是将消息路由到特定的分区,在RocketMQ中,通过MessageQueueSelector来实现分区的选择。
实际上,采用队列选择器的方法不能保证消息的严格顺序,我们的目的是将消息发送到同一个队列中,如果某个broker挂了,那么队列就会减少一部分,如果采用取余的方式投递,将可能导致同一个业务中的不同消息被发送到不同的队列中,导致同一个业务的不同消息被存入不同的队列中,短暂的造成部分消息无序。同样的,如果增加了服务器,那么也会造成短暂的造成部分消息无序。
消费端:
MessageListenerConcurrently是拉取到新消息之后就提交到线程池去消费,而MessageListenerOrderly则是通过加分布式锁和本地锁保证同时只有一条线程去消费一个队列上的数据。
即顺序消费模式使用3把锁来保证消费的顺序性:
- broker端的分布式锁:
在负载均衡的处理新分配队列的updateProcessQueueTableInRebalance方法,以及ConsumeMessageOrderlyService服务启动时的start方法中,都会尝试向broker申请当前消费者客户端分配到的messageQueue的分布式锁。
broker端的分布式锁存储结构为ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>>,该分布式锁保证同一个consumerGroup下同一个messageQueue只会被分配给一个consumerClient。
获取到的broker端的分布式锁,在client端的表现形式为processQueue. locked属性为true,且该分布式锁在broker端默认60s过期,而在client端默认30s过期,因此ConsumeMessageOrderlyService#start会启动一个定时任务,每过20s向broker申请分布式锁,刷新过期时间。而负载均衡服务也是每20s进行一次负载均衡。
broker端的分布式锁最先被获取到,如果没有获取到,那么在负载均衡的时候就不会创建processQueue了也不会提交对应的消费请求了。
- messageQueue的本地synchronized锁:
在执行消费任务的开头,便会获取该messageQueue的本地锁对象objLock,它是一个Object对象,然后通过synchronized实现锁定。
这个锁的锁对象存储在MessageQueueLock.mqLockTable属性中,结构为ConcurrentMap<MessageQueue, Object>,所以说,一个MessageQueue对应一个锁,不同的MessageQueue有不同的锁。
因为顺序消费也是通过线程池消费的,所以这个synchronized锁用来保证同一时刻对于同一个队列只有一个线程去消费它。
- ProcessQueue的本地consumeLock:
-
- 在获取到broker端的分布式锁以及messageQueue的本地synchronized锁的之后,在执行真正的消息消费的逻辑messageListener#consumeMessage之前,会获取ProcessQueue的consumeLock,这个本地锁是一个ReentrantLock。
- 那么这把锁有什么作用呢?
在负载均衡时,如果某个队列C被分配给了新的消费者,那么当前客户端消费者需要对该队列进行释放,它会调用removeUnnecessaryMessageQueue方法对该队列C请求broker端分布式锁的解锁。
而在请求broker分布式锁解锁的时候,一个重要的操作就是首先尝试获取这个messageQueue对应的ProcessQueue的本地consumeLock。只有获取了这个锁,才能尝试请求broker端对该messageQueue的分布式锁解锁。
如果consumeLock加锁失败,表示当前消息队列正在消息,不能解锁。那么本次就放弃解锁了,移除消息队列失败,只有等待下次重新分配消费队列时,再进行移除。
-
- 如果没有这把锁,假设该消息队列因为负载均衡而被分配给其他客户端B,但是由于客户端A正在对于拉取的一批消费消息进行消费,还没有提交消费点位,如果此时客户端A能够直接请求broker对该messageQueue解锁,这将导致客户端B获取该messageQueue的分布式锁,进而消费消息,而这些没有commit的消息将会发送重复消费。
- 所以说这把锁的作用,就是防止在消费消息的过程中,该消息队列因为发生负载均衡而被分配给其他客户端,进而导致的两个客户端重复消费消息的行为。
Kafka
- 吞吐量:
-
- 默认异步发送,但是Producer发送的消息到达Broker就会返回成功。此时如果Producer宕机,而消息在Broker刷盘失败时,就会导致消息丢失,从而降低系统的可靠性。
- 单机支持的topic数量
Kafka在Broker端是将一个分区存储在一个文件中的,当topic增加时,分区的数量也会增加,就会产生过多的文件。当消息刷盘时,就会出现性能下降的情况。而RocketMQ/Metaq是将所有消息顺序写入文件的,因此不会出现这种情况。
- 其它特性(事务消息,定时消息,重试队列):kafka不支持
- 顺序消息:
RocketMQ/Metaq和Kafka都支持顺序消息。不同的是,Kafka的顺序消息当一台Broker宕机后,会产生消息乱序;而RocketMQ支持严格的消息顺序。一台Broker宕机后,发送消息会失败,但不会产生乱序的现象。
- 消息过滤:
-
- metaQ:在Broker端进行Message Tag比对,比对的是tag的hash值;在consume端再次比对,比对的是真正的tag值
- Kafka不支持Broker端的消息过滤
- 元数据节点:kafka:zk;metaQ:nameserver
-
- Mq只能保证当一个broker挂了,把原本写到这个broker的请求迁移到其他broker上面,而并不是这个broker对应的slave升级为主。
- 多Master模式: 组成一个集群, 集群每个节点都是Master节点, 配置简单, 性能也是最高, 某节点宕机重启不会影响MetaQ服务;缺点是如果某个节点宕机了, 会导致该节点存在未被消费的消息在节点恢复之前不能被消费;
- 多Master多Slave模式,异步复制:每个Master配置一个Slave, 多对Master-Slave, Master与Slave消息采用异步复制方式, 主从消息一致只会有毫秒级的延迟;
- 为了方便集群维护,直接使用NameServer这一个轻量级工具来存储元数据信息即可。