RocketMq设计要点介绍

1,470 阅读20分钟

分享的目标

  • 了解Rocketmq的架构
  • 了解到Rocketmq内部主要使用的技术手段
  • Rocketmq拥有的不同的feature
  • 介绍一下其他的消息队列

RocketMq架构图

-w1060

RocketMq的一些基本概念

在了解Rocketmq之前,你需要了解Rocketmq带有的专业术语: github.com/apache/rock…

同时在本文的附录会有一份。

MQ 的演化历史

本质是队列

  1. 第一类队列,Java自带的:ConcurrentLinkedQueue,DelayQueue
  2. 第二类队列,依托于其它中间件提供的数据结构,来持久化、提升插入,读取速度:Mysql(B+Tree),Redis(Hash),RocksDB(LSM)。(实践中,也确实有使用Redis来实现消息队列的)
  3. 第三类队列,自己实现数据结构,针对高可用,高性能,高扩展做了自己定制化的实现:Kafka,Rocketmq

MessageQueue本质上还是一个队列,队列能做的事情,它都能做。

基础能力

  1. 异步-> 削峰
  2. 持久化+重试 -> 分布式事务最终一致
  3. 订阅关系 -> 消息分发,消息总线

归纳:可靠的最少一次的异步调用

RocketMq的一些Feature

当然,借助于RocketMq良好的设计,RocketMq最最基础的Feature是以下: github.com/apache/rock…

随着,时间的变化,RocketMq的Feature已经远远不止这些,借用官方文档的介绍:

  • Messaging patterns including publish/subscribe, request/reply and streaming
  • Financial grade transactional message
  • Built-in fault tolerance and high availability configuration options base on DLedger
  • A variety of cross language clients, such as Java, C/C++, Python, Go
  • Pluggable transport protocols, such as TCP, SSL, AIO
  • Built-in message tracing capability, also support opentracing
  • Versatile big-data and streaming ecosytem integration
  • Message retroactivity by time or offset
  • Reliable FIFO and strict ordered messaging in the same queue
  • Efficient pull and push consumption model
  • Million-level message accumulation capacity in a single queue
  • Multiple messaging protocols like JMS and OpenMessaging
  • Flexible distributed scale-out deployment architecture
  • Lightning-fast batch message exchange system
  • Various message filter mechanics such as SQL and Tag
  • Docker images for isolated testing and cloud isolated clusters
  • Feature-rich administrative dashboard for configuration, metrics and monitoring
  • Authentication and authorization
  • Free open source connectors, for both sources and sinks

从Broker说起

如何保证高性能写入?

总结:

  1. CommitLog顺序写,不区分Topic,所有的消息都是append到CommitLog的末尾
  2. 通过mmap内存映射的机制,省去了把文件读入内存的过程,在JVM直接可以定位到文件。(由于mmap有文件大小的限制,所以rocketmq很多文件都是固定长度)
  3. 写入是在Linux提供的PageCache上,即使JVM崩溃,PageCache还是保留。
  4. 为了高性能,rocketmq还对PageCache采用了预热策略,提前分配PageCache的内存。方式是在PageCache(4K为一页)写入1字节的数据,提前在内存中分配好了PageCache。
  5. 为了避免预热的PageCache被Linux给flush(),调用了Linux的mlock(),锁住了PageCache。

CommitLog 写入示意图(异步写入模式)

-w688 PS: 同步模式会调用flush,等flush成功才算写入成功。

ConsumerQueue

格式

ConsumerQueue是固定长度的,只包含了offset + size + tagsCode。size代表的是commitLog的长度。本质上,queue只是一个索引的作用。所以单个Queue可以做到百万级别。

this.byteBufferIndex.flip();
this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
this.byteBufferIndex.putLong(offset);
this.byteBufferIndex.putInt(size);
this.byteBufferIndex.putLong(tagsCode);

代码来源 ConsumeQueue#putMessagePositionInfo()....

-w373

放入过程

-w613 --- 来源 DefaultMessageStore.doReput()方法

这里用,伪代码表示一下:

  1. 会有一个异步现场,持续将写入成功的commitLog刷到queue中。
  2. 需要注意的是,这里还会触发 messageArrivingListener.arriving。(这里是为了触发长轮询中的socket channel,来回复来自Consumer的长轮询,后面也会讲到。)

落盘位置

-w1029

commitLog是单个文件,而consumerQueue是按照 "Topic + queueId + 固定长度的文件" 来落库的。

Consumer的Offset位置(消费进度)

-w1076

需要注意的是,到这里我们还是没有提到Consumer的offset存储在哪里。其实offset是放在一个单独的json文件中(见截图),也就是说数据的消费,本质上和queue是没有什么关系的。消费关注的只是一个offset的一个增长。

以上指的是,集群模式下的情况。如果是广播模式,还是每个Consumer自己维护了Offset。

多个queue的设计价值

Topic下会存在多个queue,为什么需要多个queue呢?

  1. 当有多个Broker组成集群的情况下,queue会均匀的分布在多个Broker上。Producer会有selector策略(一般是均衡策略,也就是每次都是queueId+1),能够使得的消息均匀的落在两个Master上面
  2. 在RocketMq的设计理念中,单个的queue永远只能被同个ConsumerGroup下的一个consumer消费。(consumer在rebalance的时候,会有概率消费多个queue)这就很好的避免了并发的问题。

不过,随之而来的存在的问题就是,当consumer的数量大于queue的时候,就会有部分consumer是无法消费的。

PS: 去哪儿mq,以及滴滴开源的DDMQ,都是依靠于中间增加Proxy层,解决consumer的数量大于queue这个问题。

PageCache

-w1132

buff/cache 部分就是PageCache,但随时都有可能会被Linux回收。

Page cache 也叫页缓冲或文件缓冲,是由好几个磁盘块构成,大小通常为4k,在64位系统上为8k,构成的几个磁盘块在物理磁盘上不一定连续,文件的组织单位为一页, 也就是一个page cache大小,文件读取是由外存上不连续的几个磁盘块,到buffer cache,然后组成page cache,然后供给应用程序。

Page cache在linux读写文件时,它用于缓存文件的逻辑内容,从而加快对磁盘上映像和数据的访问。具体说是加速对文件内容的访问,buffer cache缓存文件的具体内容——物理磁盘上的磁盘块,这是加速对磁盘的访问。

PageCache的写

普通的write调用只是将数据写到page cache中,并将其标记为dirty就返回了,磁盘I/O通常不会立即执行,这样做的好处是减少磁盘的回写次数,提供吞吐率,不足就是机器一旦意外挂掉,page cache中的数据就会丢失。一般安全性比较高的程序会在每次write之后,调用fsync立即将page cache中的内容回写到磁盘中。

PageCache的读

对于数据文件的读取 ,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取(ps:顺序读入紧随其后的少数几个页面)。这样,只要下次访问的文件已经被加载至PageCache时,读取操作的速度基本等于访问内存。

PageCache小结

  • commitLog的写入,充分利用的PageCache的特性。queue写入也是类似的。
  • 在commitLog和queue读取的场景中,由于单个Queue也是顺序的,Linux会预读一部分数据到PageCache中,所以Queue的读写速度写实很快的。
  • 而CommitLog是通过mmap映射,会先在PageCace中查找。不同的Topic数据在commitLog相距比较远的时候,无法利用PageCache,IOPS 会飙高。(可以把读往Slave上挪,有配置)

缺点:

  1. 当内存不存,pageCache会被Linux回收,RT会飙高,导致毛刺现象。
  2. 当机器整个崩溃的时候,如果是异步刷盘模式,pageCache的数据也是会丢失的。

删除策略

默认的保存时间是3天,但是会有触发机制,实际会比3天更长一些。

  1. 按照commitlog最后一个文件的写入时间,大于3天,则整个删除。
  2. queue类似。
  3. Index 按照 commitLog 删除的offset,遍历删除。Index是K-V形式,因为Value是按照顺序排列,也是顺序删除。Index的Value为什么是顺序的,下面有讲。
  4. 开启定时任务每10s扫描是否有文件需要删除 + 并且满足条件5
  5. 有三种情况会进入删除文件操作:到了deleteWhere指定的时间点(默认是凌晨4点)、磁盘不足、手动触发

Producer

关于Producer的发送消息的过程,我们可先看下一个Demo。

-w886

需要注意的点:

  1. IndexKey是messageKey,建议都是写入业务属性,方便查询,例如用 RENT_ORDER_10001
  2. 打印返回值,关注是否发送成功,以及返回值中会有messageId

Index

-w727 -- 此图片来源网络

message对于Index的索引,使用的是hash算法。

需要注意的是,由于整个文件长度的固定的,是先放value,在放key。value对于的是preIndexOffset。而这里的phyOffset是commitLog的Offset。

-w1198

代码来源IndexFile.putKey();

MessageId

对于MessageId而言,通过解码可以直接拿到commitLog的offset的位置。 所以更加的便捷,但是一般只能从日志中捞到它。而Index可以自己设置,所以更加便捷。

-w449 --代码来源MessageDecoder.java

Consumer

Pull模式

在前面的名字介绍中,我们就知道RocketMq支持Pull和Push模式,我们来着重介绍下Pull模式。Pull模式,是通过长轮询的方式来来实现消息的即时通知。

-w853

-- 代码来源PullRequestHoldService的run()

看到上面这个图,可能有小伙伴对这个代码有些不解。为什么是wait(5*1000),然后再去触发arriveMessage。其实这里wait的是处理线程,而此时socket channel是不响应状态。对于这块的知识点,可以看下Netty相关的内容。

-w1167 -- 代码来源 PullRequestHoldService.notifyMessageArriving()

-w1009 -- 代码来源PullMessageProcessor.executeRequestWhenWakeup()

通过这个代码可以看到,deReput的线程拿到了阻塞的channel,并且回写了结果。

重新投递

  1. 当消息消费失败,RocketMq 会将消息按照 2的幂次的时间间隔来进行重新投递,最多16次。2S,4S,8S,16S……
  2. RocketMq 支持level级别的定时任务

上述两者其实本质,都是使用相同的能力。(都会修改原始Topic投递到Topic:CHEDULE_TOPIC_XXX下)

-w657 图中的scheduleService对应的是RocketMq中的ScheduleMessageService

  1. Consumer消费消息失败,会犯失败的消息体给Broker(此时,如果Consumer挂了不响应,offset并没有变,所以还是会重新来)
  2. Broker接收到失败的确认,将Message的Topic改写,改为 SCHEDULE_TOPIC_XXX,将原来的Topic塞到Message的扩展字段里面。同时在Message字段中,保存重试的次数,已经重试的次数,将消息放到对于的Schedule队列中。
  3. 每个队列重新投递的时间都是按照 2 >> level 秒 轮询
  4. 消息到达时间之后,会将消息投递到 %RETRY% + ConsumerGroup 命名的 Topic 中,进行消费。(而定时任务,是回到原来的Topic中)
  5. 如果一直失败,会被放入死信 Topic 下,死信队列命名方式:%DLQ%+consumerGroup

Rebalance

我们都知道Consumer是会随时上线下线的。每当有新的Consumer加入到ConsumerGroup的时候,需要通过Rebalance来实现queue和Consumer的重新映射。

RocketMq提供的Rebalance策略有如下: -w484

每个Consumer在启动的时候,会有一个Rebalance的Schedule,每20秒触发一下。每次都是从Broker中,拿到最新的queue和consumer的数量。

这样就够了吗?这也是我们组的小伙伴在分享的时候问了一个问题,Rocketmq如何保证Consumer之间数据的同步呢?这不是会造成Consumer之间数据不一致吗?

Broker包下的 RebalanceLockManager.java

其实,Consumer在Rebalance的时候是会向Broker发起上锁的请求。而Lock保证了Queue永远是被一个Consumer消费的。但是在Rebalance的过程中,可能会发生当个Consumer消费多个queue的情况。

由于Consumer能从Broker拉倒数据,并不会发生网络阻断的情况,脑裂情况也被避免。

在20S就有一轮的频繁Rebalance下,最终Queue会按照策略分布。

顺序消费

  1. 单个queue上的消息顺序的
  2. Consumer的消息线程,默认是多线程启动
  3. Consumer会有Rebalance过程

顺序消费,就是需要解决上述三个问题。

  1. 针对的问题一使用Producer特定的selector策略,可以永远只选择第一个queue
  2. consumer需要单线程来消费
  3. 这个在Rebalance中有讲到,由于lock的存在,每个queue只会被一个consumer消费

tag的使用

在queue的结构体上,我们可以看到有个tagCode,这个就是tagHash之后的结果。RocketMq会在Broker端,先进行消息的初步过滤(由于存在code碰撞的情况),在client还会在过滤一些。

但是,大部分的数据都在Broker端过滤掉了,大大减少了无效的传输。

-w740

高可用部署

对于任何服务而言,高可用的部署方案一定是需要的,在有条件的情况下还需要思考跨机房部署做灾备的情况。

多 Master 模式

需要磁盘开启 RAID 10,通过磁盘备份来保证数据的可靠性

多Master多Slave模式-异步复制

HA通过异步复制的方式,将Master数据同步到Slave上。同时,Slave提供读的能力。默认是当Master内存超过40%之后,就会让Consumer往Slave上读取数据。

当高频率的读并且commitLog相距比较远,无法利用PageCache,IO较高的时候也会影响到Master的写入,此时也可以通过配置,修改为Slave读。

缺点:

  1. Master宕机,磁盘损坏情况下会丢失少量消息。
  2. 无法主从切换

多Master多Slave模式-同步双写

写入Master和Slave都成功之后,才返回写入成功。

缺点:

  1. 性能比异步复制模式略低(大约低10%左右)
  2. 发送单个消息的RT会略高
  3. 且目前版本在主节点宕机后,备机不能自动切换为主机

数据来源:github.com/apache/rock…

Dledger模式

单个Broker集群,需要 1 Leader,多个 Follower(大于2),总体数量大于3。本质上,Dledger 利用的是Raft协议来实现Broker集群之间commitLog的复制。

当有一个节点发生了故障,剩余节点会再次选举,选出新的 Leader 来提供写入能力。

关于Raft可以看下这个动画,很好的解释了Raft thesecretlivesofdata.com/raft/

需要说的是,由于 Leader只有一个节点,所以还是单服务器写入,同时需要同步到 n/2 + 1 节点才算,写入成功。性能应该会比同步双写略差一些。但是,多副本的 Follower 也能够提供读的能力。

缺点:故障发生选举的时候,由于数据不一致性存在重复消费的可能性,但不会丢数据。

Dledger模式 VS 多Master-Slave模式

  1. 多Master-Slave模式下,当Master挂了之后,NameSrv不能立刻感知,短时间还会有数据发给挂掉的服务
  2. Dledger在发生选举的时候,服务也是不可用
  3. 一个Dledger需要3台服务器,如果是两个集群来分配写入压力,则需要6台。而多Master-Slave模式可以配置成,3主3从,4主4从…… 成本上,多Master-Slave模式会比较省。

自己实现主从切换

目前,看到的滴滴开源的DDMQ实现了Master-Slave的切换,入口的话是 RoleChangeState。维护了Broker的状态机,同时改了很多queue分配的策略等。略复杂,就不细看了。有兴趣的小伙伴,可以自己看下。

比较疑惑的是,为什么RocketMq为什么不实现这个Feature?

  1. 或许这样做代码耦合的太厉害,Dledger确实对于整体的架构没有那么大的侵入性。
  2. 还是说多Master-Slave模式基本满足了99%的需求,剩下的没考虑。不知道阿里内部是主从切换是如何实现的。

跨机房的高可用

在大公司内部,如果单一机房出现网络以及断电问题,基础服务还是必须保证可用性的。所以,跨机房的部署,以及集群之间的数据同步是必不可少的。

目前,已知的是阿里、网易、滴滴的DDMQ,都是实现了跨机房的高可用。由于这块没有什么实践。粘贴一些来源于网络的图片。

-w688

图片来源自 zhuanlan.zhihu.com/p/108256762

-w761

图片来源自 zhuanlan.zhihu.com/p/108256762

目测,都是通过ZK做了一个服务注册,自己去实现了一个HA的Controller作为FailOver的管理者,维护着整体服务的状态机。集群之间,通过数据复制,来保证数据可靠性。

事务消息

-w924

  1. 投递半消息到Broker,此时Topic被改写,放入HALG_MESSAGE_TOPIC下,原始Topic被放入扩展字段 a. 需要注意的是,不要即使用事务消息,又设置延迟消息等级,Topic会被改写成面目全非,会出现问题
  2. Producer 在Listener中执行剩下的操作,详情见图片。需要将 TransactionId 缓存,这个很重要,记笔记。
  3. commit 或者 rollback
  4. broker 通过 msgId,定位到 offset,将Topic改为原来的,重新拿出投递。
  5. 如果步骤3,没收到。则会有定时消息反查。 a. 需要注意的时,这时会有新的消息放到HALG_MESSAGE_TOPIC下, msgId 是新的。所以,在步骤二中需要用 TransactionId 来判断回查的业务维度。

-w811

public LocalTransactionState checkLocalTransaction(MessageExt msg) {
    // 这个 msgId 不可以用
    String wrongMsgId = msg.getMsgId();
    Integer status = localTrans.get(msg.getTransactionId());
    return LocalTransactionState.ROLLBACK_MESSAGE;
}

opMessage

每次当生产者 commit 或者是 rollback 消息的时候,都需要“删除”half_message(其实只是更新到下一个offset),但是不巧的是,所有的消息都是无法被修改的,能被修改的只有offset。

因此,

  1. 每次删除消息,其实都是提交了一个opMessage,会放到 op_half_message 上。
  2. 同时,如果超过了最大的,回查check次数,也会被放入 MAX_TIME_TOPIC 下面。
  3. 每一次的HALF_MESSAGE的回查,都是重新放回HALF_MESSAGE中,然后在去查询ProducerGroup。
  4. 最初是从op_half_message读 32个消息,每次putBack()又会拿出32个消息,最终op_half_message的offset之后的所有需要删除的消息的op_message都会在内存removeMap中。
  5. doneList保存了已经操作过的op_half_message,并且小于half_message的offset的消息,最后会排序,更新op_half_message的offset。

具体的操作见时序图: -w681

其他一些优秀的MQ

滴滴DDMQ

开源地址:github.com/didi/DDMQ

-w1211

滴滴开源的DDMQ,更像是消息队列企业级运营所需要做的组件,比较重。但是功能就很完善,无论是监控策略,还是主从切换,以及内部实现的延迟消息。

优点:

  1. 实现了Broker的主从切换
  2. 实现了跨机房,高可用部署需要HA-Controller
  3. 延迟队列使用RocksDB来实现,到时间后重新投递回 RocketMq a. RocksDB使用了LSM树结构,适合于大量写入的业务场景 b. www.jianshu.com/p/e1f318921… LSM树介绍
  4. Proxy层解耦了Consumer和queue,解决了当Consumer大于queue的时候,会有Consumer无法消费的情况。同时,提供了多端(go, php...)接入能力。

去哪儿MQ

开源地址:github.com/qunarcorp/q…

这个一款设计于RocketMq未开源之前的消息队列,同样采用了顺序写的方案。不同之处,consumer 和 queue 之间,有了一层 pull log。类似于DDMQ的Proxy一层。

-w613

另外就是通过时间轮,来解决延迟消息插入和读取问题。使得,队列的插入和读取都是 O(1)的。

-w539

附录

  1. 消息模型(Message Model) RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器,每个 Broker 可以存储多个Topic的消息,每个Topic的消息也可以分片存储于不同的 Broker。Message Queue 用于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。ConsumerGroup 由多个Consumer 实例构成。

  2. 消息生产者(Producer) 负责生产消息,一般由业务系统负责生产消息。一个消息生产者会把业务应用系统里产生的消息发送到broker服务器。RocketMQ提供多种发送方式,同步发送、异步发送、顺序发送、单向发送。同步和异步方式均需要Broker返回确认信息,单向发送不需要。

  3. 消息消费者(Consumer) 负责消费消息,一般是后台系统负责异步消费。一个消息消费者会从Broker服务器拉取消息、并将其提供给应用程序。从用户应用的角度而言提供了两种消费形式:拉取式消费、推动式消费。

  4. 主题(Topic) 表示一类消息的集合,每个主题包含若干条消息,每条消息只能属于一个主题,是RocketMQ进行消息订阅的基本单位。

  5. 代理服务器(Broker Server) 消息中转角色,负责存储消息、转发消息。代理服务器在RocketMQ系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。

  6. 名字服务(Name Server) 名称服务充当路由消息的提供者。生产者或消费者能够通过名字服务查找各主题相应的Broker IP列表。多个Namesrv实例组成集群,但相互独立,没有信息交换。

  7. 拉取式消费(Pull Consumer) Consumer消费的一种类型,应用通常主动调用Consumer的拉消息方法从Broker服务器拉消息、主动权由应用控制。一旦获取了批量消息,应用就会启动消费过程。

  8. 推动式消费(Push Consumer) Consumer消费的一种类型,该模式下Broker收到数据后会主动推送给消费端,该消费模式一般实时性较高。

  9. 生产者组(Producer Group) 同一类Producer的集合,这类Producer发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,则Broker服务器会联系同一生产者组的其他生产者实例以提交或回溯消费。

  10. 消费者组(Consumer Group) 同一类Consumer的集合,这类Consumer通常消费同一类消息且消费逻辑一致。消费者组使得在消息消费方面,实现负载均衡和容错的目标变得非常容易。要注意的是,消费者组的消费者实例必须订阅完全相同的Topic。RocketMQ 支持两种消息模式:集群消费(Clustering)和广播消费(Broadcasting)。

  11. 集群消费(Clustering) 集群消费模式下,相同Consumer Group的每个Consumer实例平均分摊消息。

  12. 广播消费(Broadcasting) 广播消费模式下,相同Consumer Group的每个Consumer实例都接收全量的消息。

  13. 普通顺序消息(Normal Ordered Message) 普通顺序消费模式下,消费者通过同一个消费队列收到的消息是有顺序的,不同消息队列收到的消息则可能是无顺序的。

  14. 严格顺序消息(Strictly Ordered Message) 严格顺序消息模式下,消费者收到的所有消息均是有顺序的。

  15. 消息(Message) 消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题。RocketMQ中每个消息拥有唯一的Message ID,且可以携带具有业务标识的Key。系统提供了通过Message ID和Key查询消息的功能。

  16. 标签(Tag) 为消息设置的标志,用于同一主题下区分不同类型的消息。来自同一业务单元的消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效地保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同子主题的不同消费逻辑,实现更好的扩展性。