RocketMq
一、RocketMQ架构与实战
RocketMQ是阿里巴巴中间件团队自研的一款高性能、高吞吐量、低延迟、高可用、高可靠(具备金融级稳定性)的分布式消息中间件,开源后并于2016年捐赠给Apache社区孵化,目前已经成为了Apache顶级项目。当前在国内被广泛的使用,包括互联网、电商、金融、企业服务等领域,包括:字节跳动、滴滴、微众银行等知名的互联网公司。
[github地址][]https://github.com/apache/rocketmq
1、 RocketMQ的前世今生
RocketMQ在阿里内部叫做Metaq(最早名为Metamorphosis,中文意思“变形记”,是作家卡夫卡的中篇小说代表作,可见是为了致敬Kafka)。RocketMQ是Metaq 3.0之后开源的版本。
Metaq在阿里巴巴集团内部、蚂蚁金服、菜鸟等各业务中被广泛使用,接入了上万个应用系统中。并平稳支撑了历年的双十一大促(万亿级的消息),在性能、稳定性、可靠性等方面表现出色,在整个阿里技术体系和大中台战略中发挥着举足轻重的作用。
Metaq最早源于Kafka,早期借鉴了Kafka很多优秀的设计。但是由于Kafka
是Scale语言编写而阿里系主要使用Java,且无法满足阿里的电商、金融业务场景,所以誓嘉(花名)团队用Java重新造轮子,并做了大量的改造和优化。在此之前,淘宝有一款消息中间件名为Notify,目前已经逐步被Metaq所取代。
第一代的Notify主要使用了推模型,解决了事务消息。这种就是队列里面一旦有了消息,就推给消费者,延迟低,但是要是消费能力差点就会处理不过来导致堆积;第二代的MetaQ主要使用了拉模型,解决了顺序消息和海量堆积的问题。消费者是主动去获取消息的,要是处理不过来就不拉取,不会说堆积。相比起Kafka使用的Scale语言编写,RabbitMQ 使用Erlang语言编写,基于Java的RocketMQ开源后更容易被广泛的研究,以及其他大厂定制开发。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uDA8aXi4-1663668378177)(rocketmq.assets/image-20220323214249304.png)]
2、 RocketMQ的使用场景
# 应用解耦
系统的耦合性越高,容错性就越低。以电商应用为例,用户创建订单后,如果耦合调用库存系统、物流系统、支付系统,任何一个子系统出了故障或者因为升级等原因暂时不可用,都会造成下单操作异常,影响用户使用体验。
# 流量削峰
应用系统如果遇到系统请求流量的瞬间猛增,有可能会将系统压垮。有了消息队列可以将大量请求缓存起来,分散到很长一段时间处理,这样可以大大提到系统的稳定性和用户体验。
举例:业务系统正常时段的QPS如果是1000,流量最高峰是10000,为了应对流量高峰配置高性能的服务器显然不划算,这时可以使用消息队列对峰值流量削峰。
# 数据分发
通过消息队列可以让数据在多个系统之间进行流通。数据的产生方不需要关心谁来使用数据,只需要将数据发送到消息队列,数据使用方直接在消息队列中直接获取数据即可。配置好订阅就可以了。订阅分发的一个设计。谁订阅了就谁处理,做到了分发。
3、 RocketMQ 部署架构
# RocketMQ的角色介绍
Producer:消息的发送者;举例:发信者
Consumer:消息接收者;举例:收信者
Broker:暂存和传输消息;举例:邮局,生产者和消费者的中间服务。
NameServer:管理Broker;举例:各个邮局的管理机构,统计broker的元数据记录,可以管理broker。
Topic:区分消息的种类;一个发送者可以发送消息给一个或者多个Topic;一个消息的接收者可以订阅一个或者多个Topic消息
Message Queue:相当于是Topic的分区;用于并行发送和接收消息,一个主题有很多分区,做横向扩展的。一个主题三个分区假如,你对一个主题发消息就能发到三个分区,三个分区可以放到三个机器。这样就提高了并发和吞吐。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5Jdmjl2R-1663668378179)(rocketmq.assets/image-20220323215039717.png)]
# NameServer是一个几乎无状态节点,可集群部署,节点之间无任何信息同步,不干活,就是一个管理。没有状态,你是集群,因为无状态,所以不同步数据,你有就有,没有我用集群另外的。只要有就行。这里就是存Broker的元数据,比如分区信息,主题信息之类的。因为无状态,所以可以随时随地加入撤出。
# Broker部署相对复杂,Broker分为Master与Slave,一个Master可以对应多个Slave,但是一个Slave只能对应一个Master,Master与Slave 的对应关系通过指定相同的BrokerName,不同的BrokerId来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。每个Broker与NameServer集群中的所有节点建立长连接,定时注册Topic信息到所有NameServer。 注意:当前RocketMQ版本在部署架构上支持一Master多Slave,但只有BrokerId=1的从服务器才会参与消息的读负载。主从可以做读写分离也可以做高可用。注意,你写数据只能往主写,但是你要读就可以从主读,也可以从BrokerId=1的从节点读,其余的从就是做备份。可靠性提供。
# Producer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic 服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状态,可集群部署,因为无状态,所以可以随时随地加入撤出。
# Consumer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。Consumer既可以从Master订阅消息,也可以从Slave订阅消息,消费者在向Master拉取消息时,Master服务器会根据拉取偏移量与最大偏移量的距离(判断从而避免是否读老消息,产生读I/O),以及从服务器是否可读等因素建议下一次是从Master还 是Slave拉取。他会计算你的偏移距离来判断到底从哪个读比较好。具体后面看。
# 执行流程:
1. 启动NameServer,NameServer起来后监听端口,等待Broker、Producer、Consumer连上来,相当于一个路由控制中心。
2. Broker启动,跟所有的NameServer保持长连接(都保持,所以数据注册的nameserver就多,就可靠了),定时发送心跳包。心跳包中包含当前Broker信息(IP+端口等)以及存储所有Topic信息。注册成功后,NameServer集群中就有Topic跟Broker的映射关系。
3. 收发消息前,先创建Topic,创建Topic时需要指定该Topic要存储在哪些Broker上,也可以在发送消息时自动创建Topic。
4. Producer发送消息,启动时先跟NameServer集群中的其中一台建立长连接,并从NameServer中获取当前发送的Topic存在哪些Broker上,轮询从对应的topic中的队列列表中选择一个队列,然后与队列所在的Broker建立长连接从而向Broker发消息。
5. Consumer跟Producer类似,跟其中一台NameServer建立长连接,获取当前订阅Topic存在哪些Broker上,然后直接跟Broker建立连接通道,开始消费消息,这个连接就是master和salve1,只有这两个才能消费读取信息。
无状态的可以随时退出,随时加入,没那些所谓的启动关闭状态。
nameserver定期给pro和consu发送心跳汇报broker的元数据信息,而broker需要定时给namserver发送心跳,汇报元数据信息。
4、 RocketMQ特性
4.1、订阅与发布
消息的发布是指某个生产者向某个topic发送消息;消息的订阅是指某个消费者关注了某个topic中带有某些tag的消息。tag概念是用来过滤的,就是说即使你订阅了某个主题,但是你不满足这个tag条件,依然不会消费这个消息。
4.2、消息顺序
消息有序指的是一类消息消费时,能按照发送的顺序来消费。例如:一个订单产生了三条消息分别是订单创建、订单付款、订单完成。消费时要按照这个顺序消费才能有意义,但是同时订单之间是可以并行消费的。RocketMQ可以严格的保证消息有序。你只有放到一个队列里面才能严格有序,全局是不能有序的。
4.3、消息过滤
RocketMQ的消费者可以根据Tag进行消息过滤,也支持自定义属性过滤。消息过滤目前是在Broker端实现的,优点是减少了对于Consumer无用消息的网络传输,缺点是增加了Broker的负担、而且实现相对复杂。在Broker端做了过滤器,没有把过滤交给消费者,这样发送的时候就过滤了,不要发那些没用的。依据tag的hash值,可能哈希碰撞,所以可以在消费者端做一个校验之类的。
4.4、消息可靠性
RocketMQ支持消息的高可靠,影响消息可靠性的几种情况:
1)Broker非正常关闭
2)Broker异常Crash ,broker宕机
3)OS Crash,操作系统宕机
4)机器掉电,但是能立即恢复供电情况
5)机器无法开机(可能是cpu、主板、内存等关键设备损坏)
6)磁盘设备损坏
1)、2)、3)、4) 四种情况都属于硬件资源可立即恢复情况,RocketMQ在这四种情况下能保证消息不丢,或者丢失少量数据(取决于依赖刷盘方式是同步还是异步)。
5)、6)属于单点故障,且无法恢复,一旦发生,在此单点上的消息全部丢失。
RocketMQ在这两种情况下,通过异步复制,可保证99%的消息不丢,但是仍然会有极少量的消息可能丢失,因为是异步的。
不仅有master-slave可以保证数据可靠,通过同步双写技术可以完全避免单点,同步双写势必会影响性能,适合对消息可靠性要求极高的场合,例如与Money相关的应用。注:RocketMQ从3.0版本开始支持同步双写。就是同时写完才返回,这就是同步双写。
4.5、 至少一次
至少一次(At least Once)指每个消息必须投递一次。Consumer先Pull消息到本地,消费完成后,才向服务器返回ack,如果没有消费一定不会ack消息,所以RocketMQ可以很好的支持此特性。没有ACK就可以重试,保证你至少投递一次。
4.6、回溯消费
回溯消费是指Consumer已经消费成功的消息,由于业务上需求需要重新消费,要支持此功能,Broker在向Consumer投递成功消息后,消息仍然需要保留。并且重新消费一般是按照时间维度,例如由于Consumer系统故障,恢复后需要重新消费1小时前的数据,那么Broker要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ支持按照时间回溯消费,时间维度精确到毫秒。
4.7、 事务消息
RocketMQ事务消息(Transactional Message)是指应用本地事务和发送消息操作可以被定义到全局事务中,要么同时成功,要么同时失败。
RocketMQ的事务消息提供类似 X/Open XA 的分布事务功能,通过事务消息能达到分布式事务的最终一致性。
举个例子,你在代码里面三句代码:
1、查询订单;
2、生成数据;
3、发送mq消息。
你1,2能依赖数据库的事务,在rocketmq中可以把3也加入全局事务里面,类似一个分布式事务(seata的角色)。当你发mq失败了,前面的也会回滚。
4.8、定时消息
定时消息(延迟队列)是指消息发送到broker后,不会立即被消费,等待特定时间投递给真正的topic。
broker有配置项messageDelayLevel,默认值为“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m9m 10m 20m 30m 1h 2h”,18个level。
messageDelayLevel是broker的属性,不属于某个topic。发消息时,消息上设置delayLevel等级即可(因为不是topic的,可以在消息上随时指定):msg.setDelayLevel(level)。level有以下三种情况:
- level == 0,消息为非延迟消息
- 1<=level<=maxLevel,消息延迟特定时间,例如level==1,表示延迟1s
- level > maxLevel,则level== maxLevel,例如level==20,延迟2h
定时消息会暂存在名为SCHEDULE_TOPIC_XXXX的topic中,并根据delayTimeLevel存入特定的queue,queueId = delayTimeLevel – 1,即一个queue只存相同延迟的消息,保证具有相同发送延迟的消息能够顺序消费。broker会调度地消费SCHEDULE_TOPIC_XXXX,将消息写入真实的topic。需要注意的是,定时消息会在第一次写入延迟队列和调度写入真实topic时都会计数,因此发送数量、tps都会变高。
就是每一个过期时间级别都有一个队列,总共18个从0开始,0-17,比如1s 5s 2h这三个就是三个队列,你设置的过期的2h的就都在这个队列里,先设置的在前面,后设置的在后面,他只需要检查队头的消息就行了,发现到期,直接发去对应的主题去消费。因为虽然都是2H,但是设置时间有前后,这就会按顺序处理就行了。不用挨个处理,只处理队头消息即可。而你写这种延迟消息虽然指定的目标主题,但是先进延迟队列,后面到时间了才发给目标队列消费。
4.9、消息重试
Consumer消费消息失败后,要提供一种重试机制,令消息再消费一次。Consumer消费消息失败通常可以认为有以下几种情况:
1)由于消息本身的原因,例如反序列化失败,消息数据本身无法处理(例如话费充值,当前消息的手机号被注销,无法充值)等。这种错误通常需要跳过这条消息,再消费其它消息,而这条失败的消息即使立刻重试消费,99%也不成功,所以最好提供一种定时重试机制,即过10秒后再重试。
2)由于依赖的下游应用服务不可用,例如db连接不可用,外系统网络不可达等。遇到这种错误,即使跳过当前失败的消息,消费其他消息同样也会报错。这种情况建议应用sleep 30s,再消费下一条消息,这样可以减轻Broker重试消息的压力。
4.10、消息重投
生产者在发送消息时:
- 同步消息失败会重投。
- 异步消息有重试。
- oneway没有任何保证,啥也不管,发了就完。
消息重投保证消息尽可能发送成功、不丢失,但可能会造成消息重复,消息重复在RocketMQ中是无法避免的问题。消息重复在一般情况下不会发生,当出现消息量大、网络抖动,消息重复就会是大概率事件。另外,生产者主动重发、consumer负载变化也会导致重复消息。
# 如下方法可以设置消息重试策略:
1)retryTimesWhenSendFailed:同步发送失败重投次数,默认为2,因此生产者会最多尝试发送retryTimesWhenSendFailed + 1次(这个1是你第一次发失败那次)。不会选择上次失败broker,尝试向其他broker发送,最大程度保证消息不丢失。超过重投次数,抛异常,由客户端保证消息不丢失。当出现RemotingException、MQClientException和部分MQBrokerException时会重投。
2)retryTimesWhenSendAsyncFailed:异步发送失败重试次数,异步重试不会选择其他broker,仅在同一个broker上做重试,不保证消息不丢。
3)retryAnotherBrokerWhenNotStoreOK:消息刷盘(主或备)超时或slave不可用(返回状态非SEND_OK),是否尝试发送到其他broker,默认false。十分重要消息可以开启。
4.11、流量控制
# 生产者流控,因为broker处理能力达到瓶颈;消费者流控,因为消费能力达到瓶颈。
1) 生产者流控:
commitLog文件被锁时间超过osPageCacheBusyTimeOutMills时,参数默认为1000ms,此时就会发生流控。commitLog文件为什么会被锁,因为你要写,写的时候上锁,时间长了说明老写不进去,这就是问题了,触发流控。
如果开启transientStorePoolEnable = true,且broker为异步刷盘的主机,且transientStorePool中资源不足,拒绝当前send请求,发生流控。broker每隔10ms检查send请求队列头部请求的等待时间,如果超过waitTimeMillsInSendQueue,默认200ms,拒绝当前send请求,发生流控。broker通过拒绝send 请求方式实现流量控制。注意,生产者流控,不会尝试消息重投,意思就是被拒绝了不算发送失败,不重新投递。就是去查看一下消息等待对列头部消息的等待时间,要是等的时间长了就认为一直发不出去,生产处理不过来了,就流控一下。
2) 消费者流控:
消费者本地缓存消息数超过pullThresholdForQueue时,默认1000。
消费者本地缓存消息大小超过pullThresholdSizeForQueue时,默认100MB。
消费者本地缓存消息跨度超过consumeConcurrentlyMaxSpan时,默认2000,默认2000个消息,超过2000个就控制。
消费者流控的结果是降低拉取频率。
4.12、死信队列
死信队列用于处理无法被正常消费的消息。
当一条消息初次消费失败,消息队列会自动进行消息重试;
达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,
此时,消息队列 不会立刻将消息丢弃,而是将其发送到该消费者对应的特殊队列中。这个队列需要你自己配置。
RocketMQ将这种正常情况下无法被消费的消息称为死信消息(Dead-Letter Message),
将存储死信消息的特殊队列称为死信队列(Dead-Letter Queue)。
在RocketMQ中,可以通过使用手动补偿类似console控制台对死信队列中的消息进行重发来使得消费者实例再次进行消费。他不会自己帮你处理,你得自己手动补偿一下。
5、消费模式Push or Pull
RocketMQ消息订阅有两种模式,一种是Push模式(MQPushConsumer),即MQServer主动向消费端推送;另外一种是Pull模式(MQPullConsumer),即消费端在需要时,主动到MQ Server拉取。但在具体实现时,Push和Pull模式本质都是采用消费端主动拉取的方式,即consumer轮询从broker拉取消息,看着像是推过来的其实是你不断去获取,等于个推过来的。
# Push模式特点:
好处就是实时性高。不好处在于消费端的处理能力有限,当瞬间推送很多消息给消费端时,容易造成消费端的消息积压,严重时会压垮客户端。
# Pull模式
好处就是主动权掌握在消费端自己手中,根据自己的处理能力量力而行。缺点就是如何控制Pull的频率。定时间隔太久担心影响时效性,间隔太短担心做太多“无用功”浪费资源。比较折中的办法就是长轮询。
# Push模式与Pull模式的区别:
Push方式里,consumer把长轮询的动作封装了,并注册MessageListener监听器,取到消息后,唤醒MessageListener的consumeMessage()来消费,对用户而言,感觉消息是被推送过来的。Pull方式里,取消息的过程需要用户自己主动调用,首先通过打算消费的Topic拿到MessageQueue的集合,遍历MessageQueue集合,然后针对每个MessageQueue批量取消息,一次取完后,记录该队列下一次要取的开始offset,直到取完了,再换另一个MessageQueue。
RocketMQ使用长轮询机制来模拟Push效果,算是兼顾了二者的优点。
6、 RocketMQ中的角色及相关术语
6.1、消息模型(Message Model)
RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器,每个Broker 可以存储多个Topic的消息,每个Topic的消息也可以分片存储于不同的 Broker。MessageQueue 用于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。ConsumerGroup 由多个Consumer 实例构成。
6.2、Producer
消息生产者,负责产生消息,一般由业务系统负责产生消息。
6.3、Consumer
消息消费者,负责消费消息,一般是后台系统负责异步消费。
6.4、PushConsumer
Consumer消费的一种类型,该模式下Broker收到数据后会主动推送给消费端。应用通常向Consumer对象注册一个Listener接口,一旦收到消息,Consumer对象立刻回调Listener接口方法。该消费模式一般实时性较高。
6.5、PullConsumer
Consumer消费的一种类型,应用通常主动调用Consumer的拉消息方法从Broker服务器拉消息、主动权由应用控制。一旦获取了批量消息,应用就会启动消费过程。消费完了再拉取下一批。
6.6、ProducerGroup
同一类Producer的集合,有相同的group-id,这类Producer发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,则Broker服务器会联系同一生产者组的其他生产者实例以提交或回溯消费。一来是高可用,二来是横向扩展。
6.7、ConsumerGroup
同一类Consumer的集合,有相同的groupId,这类Consumer通常消费同一类消息且消费逻辑一致。消费者组使得在消息消费方面,实现负载均衡和容错的目标变得非常容易。要注意的是,消费者组的消费者实例必须订阅完全相同的Topic(这和KAFKA不一样)。RocketMQ 支持两种消息模式:集群消费(Clustering)和广播消费(Broadcasting)。
6.8、Broker
消息中转角色,负责存储消息,转发消息,一般也称为 Server。在 JMS 规范中称为Provider。
6.9、广播消费
一条消息被多个 Consumer 消费,即使这些 Consumer 属于同一个 Consumer Group,消息也会被 Consumer Group 中的每个 Consumer 都消费一次,广播消费中的 Consumer Group 概念可以认为在消息划分方面无意义。
在 CORBA Notification 规范中,消费方式都属于广播消费。
在 JMS 规范中,相当于 JMS Topic( publish/subscribe )模型。
6.10、集群消费
一个 Consumer Group 中的 Consumer 实例平均分摊消费消息。例如某个 Topic 有 9 条消息,其中一个 Consumer Group 有 3 个实例(可能是 3 个进程,或者3台机器),那举每个实例只消费其中的 3条消息。
6.11、顺序消息
消费消息的顺序要同发送消息的顺序一致,在RocketMQ 中主要指的是局部顺序,即一类消息为满足顺序性,必须Producer单线程顺序发送,且发送到同一个队列,这样Consumer 就可以按照Producer发送的顺序去消费消息。
6.12、普通顺序消息
顺序消息的一种,正常情况下可以保证完全的顺序消息,但是一旦发生通信异常,Broker 重启,由于队列总数发生发化,哈希取模后定位的队列会发化,产生短暂的消息顺序不一致。 如果业务能容忍在集群异常情况(如某个Broker 宕机或者重启)下,消息短暂的乱序,使用普通顺序方式比较合适。
6.13、严格顺序消息
顺序消息的一种,无论正常异常情况都能保证顺序,但是牺牲了分布式 Failover特性,即Broker集群中只要有一台机器不可用,则整个集群都不可用,服务可用性大大降低。 如果服务器部署为同步双写模式,此缺陷可通过备机自动切换为主避免,不过仍然会存在几分钟的服务不可用。(依赖同步双写,主备自动切换,自动切换功能目前还未实现,等于没说)。
目前已知的应用只有数据库 binlog 同步强依赖严格顺序消息,其他应用绝大部分都可以容忍短暂乱序,推荐使用普通的顺序消息。
6.14、Message Queue
在 RocketMQ 中,所有消息队列都是持久化的,长度无限的数据结构,所谓长度无限是指队列中的每个存储单元都是定长,访问其中的存储单元使用Offset来访问,offset 为 java long 类型,64 位,理论上在 100 年内不会溢出,所以认为为是长度无限,另外队列中只保存最近几天的数据,之前的数据会按照过期时间来删除。也可以认为Message Queue是一个长度无限的数组,offset 就是下标。
6.15、标签(Tag)
为消息设置的标志,用于同一主题下区分不同类型的消息。来自同一业务单元的消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效地保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同子主题的不同消费逻辑,实现更好的扩展性。
主题本身就是区分业务消息类型的,现在tag还能在主题下面继续区分。
7、RocketMQ环境搭建
7.1、软件准备:
RocketMQ最新版本:4.5.1
[下载地址][Apache Download Mirrors]
7.2、环境要求
- JDK 11.0.5
- Linux64位系统(CentOS Linux release 7.7.1908)
- 源码安装需要安装Maven 3.2.x
- 4G+ free
7.3、安装及启动
1、下载rocketmq
# 下载
wget https://archive.apache.org/dist/rocketmq/4.5.1/rocketmq-all- 4.5.1-bin-release.zip
2、修改脚本
bin/runserver.sh
bin/runbroker.sh
bin/tools.sh
nameserver:
#!/bin/sh
# Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. # The ASF licenses this file to You under the Apache License, Version 2.0 # (the "License"); you may not use this file except in compliance with # the License. You may obtain a copy of the License at ## http://www.apache.org/licenses/LICENSE-2.0 ## Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. #========================================================================== ================= # Java Environment Setting #========================================================================== ================= error_exit () {
vim bin/runserver.sh
删除
UseCMSCompactAtFullCollection
UseParNewGC
UseConcMarkSweepGC
修改内存:
JAVA_OPT="${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m - XX:MetaspaceSize=64mm -XX:MaxMetaspaceSize=160mm"
-Xloggc修改为-Xlog:gc
broker:
启动NameServer
# 1.启动NameServer
mqnamesrv
# 2.查看启动日志 tail -f ~/logs/rocketmqlogs/namesrv.log
# 1.启动Broker
mqbroker -n localhost:9876
# 2.查看启动日志
tail -f ~/logs/rocketmqlogs/broker.log
8、RocketMQ环境测试
8.1、发送消息
# 1.设置环境变量
export NAMESRV_ADDR=localhost:9876
# 2.使用安装包的Demo发送消息
sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer
8.2、 接收消息
# 1.设置环境变量
export NAMESRV_ADDR=localhost:9876
# 2.接收消息
sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer
8.3、关闭RocketMQ
# 1.关闭NameServer
mqshutdown namesrv
# 2.关闭Broker
mqshutdown broker
9、RocketMQ相关API使用
DefaultMQProducer 生产者的默认实现
生产消息分同步发送和异步发送
DefaultMQConsumer:消费者的默认实现
消息的消费分为拉取和消息的推送
9.1、生产者
生产者同步发送消息:
package com.yx.demo1;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.common.RemotingHelper;
import org.apache.rocketmq.remoting.exception.RemotingException;
import java.io.UnsupportedEncodingException;
/**
* @author: YX
* @description: TODO
* @date: 2022-4-30 21:22
* @version: 1.0
*/
public class MyProducer {
public static void main(String[] args) throws UnsupportedEncodingException,
InterruptedException, RemotingException,MQClientException, MQBrokerException {
// 在实例化生产者的同时,指定了生产组名称,这里生产组可以实现高可用的一个实现
DefaultMQProducer producer = new DefaultMQProducer("myproducer_grp_01");
// 指定NameServer的地址,就是和服务端建立长连接(nameServer是重要角色前面说过)
producer.setNamesrvAddr("localhost:9876");
// 对生产者进行初始化,然后就可以使用了,其实就是做个连接,你上面都指定好信息了,这里就是启动的
producer.start();
// 创建消息,第一个参数是主题名称,第二个参数是消息内容
// 这里可以看出来一个东西,创建消息的时候指定了主题,但是没有messageQueue,因为这个队列是在服务端默认创建,个数为4,也可以指定
// 后面再看
Message message = new Message("topic_01", "hello rocket 01".getBytes(RemotingHelper.DEFAULT_CHARSET));
// 发送消息,有很多格式,发一条,发集合,发送指定queue,或者异步发送回调等等,这里演示一下单条的同步发送
SendResult sendResult = producer.send(message);
System.out.println(sendResult);
// 关闭生产者
producer.shutdown();
}
}
# 运行结果,可以看到同步发送之后会返回一些数据,其实就是发送的信息,类似元数据之类的,后面结合消费者对一下看看到底有什么玄机,这里不做卡顿,继续往下
SendResult [
sendStatus=SEND_OK,
msgId=C0A8010694EC18B4AAC299FCC5640000,
offsetMsgId=C0A8010600002A9F000000000002BEB2,
messageQueue=MessageQueue
[topic=topic_01, brokerName=DESKTOP-FT28NDI, queueId=3],
queueOffset=0
]
生产者异步发送消息:
package com.yx.demo1;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.exception.RemotingException;
import java.io.UnsupportedEncodingException;
/**
* @author: YX
* @description: TODO
* @date: 2022-4-30 21:47
* @version: 1.0
*/
public class MyAsyncProducer {
public static void main(String[] args) throws MQClientException, UnsupportedEncodingException,
RemotingException,InterruptedException{
// 在实例化生产者的同时,指定了生产组名称,这里生产组可以实现高可用的一个实现
DefaultMQProducer producer = new DefaultMQProducer("my-pro-group");
// 指定NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 初始化生产者
producer.start();
// 发送10个消息
for (int i = 0; i < 10; i++) {
Message message = new Message("topic-demo-02", ("hello rocet " + i).getBytes("utf-8"));
producer.send(message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println("发送成功触发的回调:" + sendResult);
}
@Override
public void onException(Throwable throwable) {
System.out.println("发送失败触发的回调:" + throwable.getMessage());
}
});
}
// 由于是异步发送消息,上面循环结束之后,消息可能还没收到broker的响应,如果不sleep一会儿,就失败
// 怕还没回来下面主线程结束了就走到了关闭生产者,看不到回调就发送失败了
Thread.sleep(10_000);
//关闭生产者
producer.shutdown();
}
}
# 运行结果
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CAF0001, offsetMsgId=C0A8010600002A9F000000000003025F, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=0], queueOffset=24]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CAF0005, offsetMsgId=C0A8010600002A9F00000000000303B1, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=0], queueOffset=25]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CAF0000, offsetMsgId=C0A8010600002A9F000000000003045A, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=3], queueOffset=25]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CAF0006, offsetMsgId=C0A8010600002A9F00000000000301B6, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=2], queueOffset=25]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CAF0003, offsetMsgId=C0A8010600002A9F0000000000030308, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=1], queueOffset=26]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CAF0002, offsetMsgId=C0A8010600002A9F0000000000030503, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=1], queueOffset=27]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CAF0007, offsetMsgId=C0A8010600002A9F00000000000305AC, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=0], queueOffset=26]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CAF0004, offsetMsgId=C0A8010600002A9F0000000000030655, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=0], queueOffset=27]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CB70008, offsetMsgId=C0A8010600002A9F00000000000306FE, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=3], queueOffset=26]
发送成功触发的回调:SendResult [sendStatus=SEND_OK, msgId=C0A80106C20018B4AAC29A119CB70009, offsetMsgId=C0A8010600002A9F00000000000307A7, messageQueue=MessageQueue [topic=topic-demo-02, brokerName=DESKTOP-FT28NDI, queueId=1], queueOffset=28]
9.2、消费者
消费者主动拉取:
package com.yx.demo1.consumer;
import org.apache.rocketmq.client.consumer.DefaultMQPullConsumer;
import org.apache.rocketmq.client.consumer.PullResult;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.exception.RemotingException;
import java.io.UnsupportedEncodingException;
import java.util.List;
import java.util.Set;
/**
* @author: YX
* @description: 拉取消息的消费者
* @date: 2022-5-1 17:25
* @version: 1.0
*/
public class MyPullConsumer {
public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException, UnsupportedEncodingException {
// 拉取消息的消费者实例化,同时指定消费组名称
DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("my_consumer_group_01");
// 设置nameserver的地址,与之建立长连接
consumer.setNamesrvAddr("localhost:9876");
// 对消费者进行初始化,然后就可以使用了
consumer.start();
// 获取指定主题的消费队列集合
Set<MessageQueue> messageQueues = consumer.fetchSubscribeMessageQueues("topic_01");
// 遍历该主题的各个消息队列,进行消费
for (MessageQueue messageQueue : messageQueues) {
// 第一个参数是MessageQueue对象,代表了当前主题的一个消息队列,就是你要消费来自哪个队列的,我这里是遍历全部的
// 第二个参数是一个表达式,对接收的消息按照tag进行过滤
// 支持"tag1 || tag2 || tag3"或者 "*"类型的写法;null或者"*"表示不对 消息进行tag过滤,但是一般不写null,因为null会产生
// 歧义,有两个重载方法都你传Null进去都可以,所以会模糊异常
// 第三个参数是消息的偏移量,从这里开始消费
// 第四个参数表示每次最多拉取多少条消息
final PullResult result = consumer.pull(messageQueue, "*", 0, 10);
// 打印消息队列的信息
System.out.println("message******queue******" + messageQueue);
// 获取从指定消息队列中拉取到的消息
final List<MessageExt> msgFoundList = result.getMsgFoundList();
// 要是该队列没消息直接跳出
if (msgFoundList == null) continue;
for (MessageExt messageExt : msgFoundList) {
// 输出消息,二进制的
System.out.println(messageExt);
// 输出编码之后的消息体
System.out.println(new String(messageExt.getBody(), "utf-8"));
}
}
// 关闭消费者
consumer.shutdown();
}
}
# 运行结果
message******queue******MessageQueue [topic=topic_01, brokerName=DESKTOP-FT28NDI, queueId=3]// 这是消费的队列
MessageExt [queueId=3, storeSize=166, queueOffset=0, sysFlag=0, bornTimestamp=1651325879653, bornHost=/192.168.1.6:53961, storeTimestamp=1651325879676, storeHost=/192.168.1.6:10911, msgId=C0A8010600002A9F000000000002BEB2, commitLogOffset=179890, bodyCRC=1808992191, reconsumeTimes=0, preparedTransactionOffset=0, toString()=Message{topic='topic_01', flag=0, properties={MIN_OFFSET=0, MAX_OFFSET=1, UNIQ_KEY=C0A8010694EC18B4AAC299FCC5640000, WAIT=true}, body=[104, 101, 108, 108, 111, 32, 114, 111, 99, 107, 101, 116, 32, 48, 49], transactionId='null'}]
hello rocket 01 --这是解码后的消息体
message******queue******MessageQueue [topic=topic_01, brokerName=DESKTOP-FT28NDI, queueId=0]--空队列
message******queue******MessageQueue [topic=topic_01, brokerName=DESKTOP-FT28NDI, queueId=2]--空队列
message******queue******MessageQueue [topic=topic_01, brokerName=DESKTOP-FT28NDI, queueId=1]--空队列
我在创建消息主题的时候没指定队列个数,这里就有四个,可见rocketmq默认是创建四个。
服务端主动推送消息:
推送其实也是使用的拉取机制,只不过底层是你客户端不断拉取,就显得像是服务端那边推的。这样只有拉取模式,是因为想把消费权利交给消费者,你想快就快。
package com.yx.demo1.consumer;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.common.message.MessageQueue;
import java.io.UnsupportedEncodingException;
import java.util.List;
/**
* @author: YX
* @description: 服务端主动推送消息
* @date: 2022-5-1 17:56
* @version: 1.0
*/
public class MyPushConsumer {
public static void main(String[] args) throws MQClientException {
// 实例化推送消息消费者的对象,同时指定消费组名称
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("my_consumer_group_02");
// 指定nameserver的地址
consumer.setNamesrvAddr("localhost:9876");
// 订阅主题
consumer.subscribe("topic_02", "*");
// 添加消息监听器,一旦有消息推送过来,就进行消费
consumer.setMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
// 从上下文获取消息队列信息
final MessageQueue messageQueue = context.getMessageQueue();
System.out.println(messageQueue);
for (MessageExt msg : msgs) {
try {
System.out.println(new String(msg.getBody(), "utf-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
// 消息消费成功
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
// 消息消费失败
// return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
});
// 初始化消费者,之后开始消费消息
consumer.start();
// 此处只是示例,生产中除非运维关掉,否则不应停掉,长服务,消费者这里是个长服务,一直等着消费呢。开发角度一般不会关掉
// Thread.sleep(30_000);
// 关闭消费者
// consumer.shutdown();
}
}
10、Spring整合RocketMQ
Maven依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.13</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yx</groupId>
<artifactId>spring-rocket</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-rocket</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<rocketmq-spring-boot-starter-version>2.0.3</rocketmq-spring-boot-starter-version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>${rocketmq-spring-boot-starter-version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.6</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.10</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
配置文件
# nameserver地址配置
rocketmq.name-server=localhost:9876
# 生产组名称配置
rocketmq.producer.group=my-group
生产者:
package com.yx.springrocket.controller;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author: YX
* @description: TODO
* @date: 2022-5-1 19:03
* @version: 1.0
*/
@RestController
public class SendMqController {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@GetMapping("/send/toRocketMq")
public String sendMessage(){
//第一个参数是主题名称,第二个是消息内容
rocketMQTemplate.convertAndSend("boot_topic_01","hello springboot rocketmq");
return "发送成功";
}
}
消费者
package com.yx.springrocket.consumer;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* @author: YX
* @description: 消费者监听指定主题,并且配置个消费组,此处没指定什么tag,其实都能指定
* @date: 2022-5-1 18:33
* @version: 1.0
*/
@Slf4j
@Component
@RocketMQMessageListener(topic = "boot_topic_01",consumerGroup = "springboot-mq-consumer-1")
public class Consumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
log.info("Receive message:"+message);
}
}
测试:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-w1YevgnB-1663668378180)(rocketmq.assets/image-20220501195846401.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-No2Bb89O-1663668378180)(rocketmq.assets/image-20220501195930946.png)]
可见基本实现了生产消费功能。
二、 RocketMQ高级特性及原理
1、消息发送
生产者向消息队列里写入消息,不同的业务场景需要生产者采用不同的写入策略。比如同步发送、异步发送、Oneway发送、延迟发送、发送事务消息等。 默认使用的是DefaultMQProducer类,发送消息要经过五个步骤:
1)设置Producer的GroupName。
2)设置InstanceName,实例名称,当一个Jvm需要启动多个Producer的时候,就是你项目里面起了多个生产者,需要通过设置不同的InstanceName来区分,不设置的话系统使用默认名称“DEFAULT”。
3)设置发送失败重试次数,当网络出现异常的时候,这个次数影响消息的重复投递次数。想保证不丢消息,可以设置多重试几次。同步异步之类的都可以设置。
4)设置NameServer地址
5)组装消息并发送。
消息发生返回状态(SendResult#SendStatus)有如下四种:
- FLUSH_DISK_TIMEOUT
- FLUSH_SLAVE_TIMEOUT
- SLAVE_NOT_AVAILABLE
- SEND_OK
不同状态在不同的刷盘策略和同步策略的配置下含义是不同的:
1. FLUSH_DISK_TIMEOUT:表示没有在规定时间内完成刷盘(需要Broker的刷盘策略被设置成SYNC_FLUSH才会报这个错误)。就是设置成同步刷盘的时候要是刷盘失败了就会报这个错。
2. FLUSH_SLAVE_TIMEOUT:在设置为同步刷盘的时候,在主备方式下,并且Broker被设置成SYNC_MASTER方式,没有在设定时间内完成主从同步。
3. SLAVE_NOT_AVAILABLE:同步刷盘下,这个状态产生的场景和FLUSH_SLAVE_TIMEOUT类似,表示在主备方式下,并且Broker被设置成SYNC_MASTER,但是没有找到被配置成Slave的Broker。就是没找到从节点。
4. SEND_OK:表示发送成功,发送成功的具体含义,比如消息是否已经被存储到磁盘?消息是否被同步到了Slave上?消息在Slave上是否被写入磁盘?需要结合所配置的刷盘策略、主从策略来定。这个状态还可以简单理解为,没有发生上面列出的三个问题状态就是SEND_OK。
写一个高质量的生产者程序,重点在于对发送结果的处理,要充分考虑各种异常,写清对应的处理逻辑。
提升写入的性能
发送一条消息出去要经过三步
-
客户端发送请求到服务器。
-
服务器处理该请求。
-
服务器向客户端返回应答
一次消息的发送耗时是上述三个步骤的总和。
在一些对速度要求高,但是可靠性要求不高的场景下,比如日志收集类应用, 可以采用Oneway方式发送。Oneway方式只发送请求不等待应答,即将数据写入客户端的Socket缓冲区就返回,不等待对方返回结果。用这种方式发送消息的耗时可以缩短到微秒级。
另一种提高发送速度的方法是增加Producer的并发量,使用多个Producer同时发送,我们不用担心多Producer同时写会降低消息写磁盘的效率,RocketMQ引入了一个并发窗口,在窗口内消息可以并发地写入DirectMem中,然后异步地将连续一段无空洞的数据刷入文件系统当中。
顺序写CommitLog可让RocketMQ无论在HDD还是SSD磁盘情况下都能保持较高的写入性能。
目前在阿里内部经过调优的服务器上,写入性能达到90万+的TPS,我们可以参考这个数据进行系统优化。
在Linux操作系统层级进行调优,推荐使用EXT4文件系统,IO调度算法使用deadline算法。
package com.yx.demo2;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.exception.RemotingException;
/**
* @author: YX
* @description: TODO
* @date: 2022-5-2 19:11
* @version: 1.0
*/
public class MyProducer2 {
public static void main(String[] args) throws InterruptedException, RemotingException, MQClientException, MQBrokerException {
// 实例化生产者实例,同时设置生产组名称,这个producer是线程安全的,所以你可以多个生产者发送,是安全的。具体怎么保证,再看看。
DefaultMQProducer producer = new DefaultMQProducer("producer_group_04");
// 设置实例名称,一个JVM中如果有多个生产者,可以通过实例名称区分。默认叫DEFAULT
producer.setInstanceName("producer_group_04_instance01");
// 设置同步发送重试的次数
producer.setRetryTimesWhenSendFailed(2);
// 设置异步发送的重试次数
producer.setRetryTimesWhenSendAsyncFailed(2);
// 设置nameserver的地址
producer.setNamesrvAddr("localhost:9876");
// 组装消息
Message message = new Message("topic_demo_04", "hello rocket 04".getBytes());
// 发送消息(同步模式发送的,源码注释里面有),如果失败会按照上面setRetryTimesWhenSendFailed设置的次数重试
SendResult sendResult = producer.send(message);
// 异步发送,重试次数会走上面设置的
producer.send(message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
// 发送成功的处理逻辑
System.out.println(sendResult);
}
@Override
public void onException(Throwable e) {
// 发送失败的处理逻辑,重试次数耗尽才会走到这个抛出异常,重试范围内是正常
// 流程,不会走到这里
System.err.println(sendResult);
}
});
// 将消息放到socket缓冲区就立刻返回,没有返回值,不会等待broker的响应。速度快,会丢小时
// 单向发送
producer.sendOneway(message);
SendStatus sendStatus = sendResult.getSendStatus();
}
}
2、消息消费
# 简单总结消费的几个要点:
1. 消息消费方式(Pull和Push)
2. 消息消费的模式(广播模式和集群模式),广播模式就是消息过来是广播形式的,每个消费者都会收到。集群模式是多个消费者,过来消息会负载均衡,每个消费者消费不同的消息,也就是走分区那种了。广播我觉得不常用。
3. 流量控制(可以结合sentinel来实现,后面单独讲)
4. 并发线程数设置,对于拉取这种其实无需设置,因为你是自己拉的,速度自己定。但是对于推送,你就得限流,不然可能会堆积。
5. 消息的过滤(Tag、Key) TagA||TagB||TagC 也可以是 * 还可以是null,不过Null有重载冲突,前面看到了。
当Consumer的处理速度跟不上消息的产生速度,会造成越来越多的消息积压,这个时候首先查看消费逻辑本身有没有优化空间,除此之外还有三种方法可以提高Consumer的处理能力。
1. 提高消费并行度
在同一个ConsumerGroup下(Clustering方式),可以通过增加Consumer实例的数量来提高并行度。
通过加机器,或者在已有机器中启动多个Consumer进程都可以增加Consumer实例数。
注意:总的Consumer数量不要超过Topic下Read Queue这个读取队列的数量,超过的Consumer实例接收不到消息。就是一个消费者读取一个队列,你多了就读取不到,没分配mq。因为一个主题,下面多个分区,你为了保证一个消费者不会消费别的队列的,最好是一对一,多了就消费不到了。
此外,通过提高单个Consumer实例中的并行处理的线程数,可以在同一个Consumer内增加并行度来提高吞吐量(设置方法是修改consumeThreadMin和consumeThreadMax)。
2. 以批量方式进行消费
某些业务场景下,多条消息同时处理的时间会大大小于逐个处理的时间总和,比如消费消息中涉及update某个数据库,一次update10条的时间会大大小于十次update1条数据的时间。
可以通过批量方式消费来提高消费的吞吐量。实现方法是设置Consumer的consumeMessageBatchMaxSize这个参数,默认是1,如果设置为N,在消息多的时候每次收到的是个长度为N的消息链表。
3. 检测延时情况,跳过非重要消息
Consumer在消费的过程中,如果发现由于某种原因发生严重的消息堆积,短时间无法消除堆积,这个时候可以选择丢弃不重要的消息,使Consumer尽快追上Producer的进度。
3、消息存储
3.1、存储介质
3.1.1、关系型数据库DB
Apache下开源的另外一款MQ—ActiveMQ(默认采用的KahaDB做消息存储)可选用JDBC的方式来做消息持久化,通过简单的xml配置信息即可实现JDBC消息存储。由于,普通关系型数据库(如Mysql)在单表数据量达到千万级别的情况下,其IO读写性能往往会出现瓶颈。在可靠性方面,该种方案非常依赖DB,如果一旦DB出现故障,则MQ的消息就无法落盘存储会导致线上故障。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MGVbcc9J-1663668378181)(rocketmq.assets/image-20220502114547863.png)]
3.1.2、文件系统
目前业界较为常用的几款产品(RocketMQ/Kafka/RabbitMQ)均采用的是消息刷盘至所部署虚拟机/物理机的文件系统来做持久化(刷盘一般可以分为异步刷盘和同步刷盘两种模式)。消息刷盘为消息存储提供了一种高效率、高可靠性和高性能的数据持久化方式。除非部署MQ机器本身或是本地磁盘挂了,否则一般是不会出现无法持久化的故障问题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZGQZLfa4-1663668378181)(rocketmq.assets/image-20220502114623016.png)]
3.1.3、性能对比
文件系统>关系型数据库DB
3.1.4、消息的存储和发送
1) 消息存储
目前的高性能磁盘,顺序写速度可以达到600MB/s, 超过了一般网卡的传输速度。但是磁盘随机写的速度只有大概100KB/s,和顺序写的性能相差6000倍!因为有如此巨大的速度差别,好的消息队列系统会比普通的消息队列系统速度快多个数量级。RocketMQ的消息用顺序写,保证了消息存储的速度。
2) 存储结构
RocketMQ消息的存储是由ConsumeQueue和CommitLog配合完成的,消息真正的物理存储文件是CommitLog,ConsumeQueue是消息的逻辑队列,类似数据库的索引文件,存储的是指向物理存储的地址。每 个Topic下的每个Message Queue都有一个对应的ConsumeQueue文件。
# 配合下面这个图做一下解释归纳:
1、火箭里面最重要的东西就是这个CommitLog,所有提交的消息都在这个里面存储。那么对于消费者来说消费的时候其实是在读取每个主题的分区,就是消费队列,也就是那个consumerQueue这个结构。我再多说一句,就是你每个topic会对应多个队列,消息会从commitlog进这些队列,也就是consumerQueue,从这里去读取。consumerQueue存储的是消息的元数据,比如消息的一些信息,key,偏移量这些。这里是没存真实的消息的,就是你可以理解为消息的索引,目录。消费者是在这里拿到消息的索引,数据信息。然后去commitlog去拿对应的消息处理。也正是这个索引结构的存在,导致他的消费其实多少是有效率的,即便是随机消费。消息真正都是存在commitlog里面的,queue里面只存一个索引映射这种。
2、下图里面commitlog和consumerQueue对应的那个线里面体现一些映射关系元素。commitLogOffset就是队列里面的消息在commitlog里面的偏移量,就是你对应到哪个位置之类TODO,这里不理解。msgSize表示消息的大小,tagscode就是标签你的消息到了这个队列里面一定要带上标签,不然后面消费者按照标签消费的时候会找不到标签。其实就是个哈希值。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uu30qBeh-1663668378182)(rocketmq.assets/image-20220502114756989.png)]
-
消息存储架构图中主要有下面三个跟消息存储相关的文件构成。
(1) CommitLog:消息主体以及元数据的存储主体,存储Producer端写入的消息主体内容,消息内容不是定长的。单个文件大小默认1G ,文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,偏移量是字节大小的。以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vi8TKVOP-1663668378183)(rocketmq.assets/image-20220502114824606.png)]
(2) ConsumeQueue:消息消费队列,引入的目的主要是提高消息消费的性能
RocketMQ是基于主题topic的订阅模式,消息消费是针对主题进行如果要遍历commitlog文件根据topic检索消息是非常低效。Consumer即可根据ConsumeQueue来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引:
1、保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset
2、消息大小size
3、 消息Tag的HashCode值。
consumequeue文件可以看成是基于topic的commitlog索引文件,故consumequeue文件夹的组织方式如下:
topic/queue/file三层组织结构。
具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。
下图里面的tp_demo_01就是一个主题的文件夹,主题下面有多个文件,表示013号队列,为啥没2呢?因为目前2还没数据,等有了就有2了,默认是四个。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X3YWRP8I-1663668378183)(rocketmq.assets/image-20220502114953654.png)]
consumequeue文件采取定长设计,**每个条目**共20个字节,分别为: 1. 8字节的commitlog物理偏移量,需要知道条目在commitlog里面的位置,偏移量是多少。 2. 4字节的消息长度 3. 8字节tag hashcode 单个文件由30W个条目组成,可以像数组一样随机访问每一个条目 每个ConsumeQueue**文件大小约5.72M**;这个不大,所以可以放到内存里面去作为索引直接使用,加快速度。这个就是上面的一个个队列,每个队列大小才5.72兆,本身就是存元数据的,所以不大。这个文件每个有30万个条目组成,类似把这个文件分成30万个部分,每部分就像数组里面的东西差不多。 (3) IndexFile:IndexFile(索引文件)提供了一种**可以通过key或时间区间来查询消息**的方法。这个其实是时间分类的索引,算是另一个维度的索引上面那个consumequeue文件采取定长设计是也是一个类型的索引。你可以用时间查的时候就用那个index目前先这么理解,后面看威哥的书再加深。 1. Index文件的存储位置是: $HOME/store/index/${fileName} 2. 文件名fileName是以创建时的时间戳命名的 3. 固定的单个IndexFile文件大小约为400M 4. 一个IndexFile可以保存 2000W个索引 5. IndexFile的底层存储设计为在文件系统中实现**HashMap结构**,故rocketmq的索引文件其**底层实现为hash索引**。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RX0lwnLZ-1663668378183)(rocketmq.assets/image-20220502115145069.png)]
4、过滤消息
RocketMQ分布式消息队列的消息过滤方式有别于其它MQ中间件,是在Consumer端订阅消息时再做消息过滤的。
RocketMQ这么做是在于其Producer端写入消息和Consumer端订阅消息采用分离存储的机制来实现的,Consumer端订阅消息是需要通过ConsumeQueue这个消息消费的逻辑队列拿到一个索引,然后再从CommitLog里面读取真正的消息实体内容,所以说到底也是还绕不开其存储结构。
# 这里我解释一下:
我们上面已经看到了,我们生产者每次写消息都是写入commitLog里面去,然后异步写每一个cosumerQueue,这个queue里面是写的消息在log里面的索引,这些前面都说了。然后呢这些索引里面写的就是消息的大小,在log里面的偏移,以及tag标签。当消费者读取的时候会去queue里面读取,然后消费者端是设置了读取tag的,所以你读到的时候就能判断对比。符合的就去log里面拿,要是不符合就放弃啥也不干。
其ConsumeQueue的存储结构如下,可以看到其中有8个字节存储的Message Tag的哈希值,基于Tag的消息过滤正式基于这个字段值的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-avi7ClgM-1663668378184)(rocketmq.assets/image-20220502115245195.png)]
# 主要支持如下2种的过滤方式
(1) Tag过滤方式:Consumer端在订阅消息时除了指定Topic还可以指定TAG,如果一个消息有多个TAG,可以用||分隔。
1. Consumer端会将这个订阅请求构建成一个 SubscriptionData,发送一个Pull消息的请求给Broker端。
2. Broker端从RocketMQ的文件存储层—Store读取数据之前,会用这些数据先构建一个MessageFilter,然后传给Store。
3. Store从 ConsumeQueue读取到一条记录后,会用它记录的消息tag hash值去做过滤。
4. 在服务端只是根据hashcode进行判断,无法精确对tag原始字符串进行过滤,因为hash值是不能唯一代表一个字符串的,因为存在哈希冲突。所以在消息消费端拉取到消息后,还需要对消息的原始tag字符串进行比对,如果不同,则丢弃该消息,不进行消息消费。但是第一次哈希比较已经能去掉大部分不一致的了,冲突也是少数。
(2) SQL92的过滤方式:
1. 仅对push的消费者起作用。拉取的不生效。
2. Tag方式虽然效率高,但是支持的过滤逻辑比较简单。只能是要什么不要什么。
3. SQL表达式可以更加灵活的支持复杂过滤逻辑,这种方式的大致做法和上面的Tag过滤方式一样,只是在Store层的具体过滤过程不太一样,真正的 SQL expression 的构建和执行由rocketmq-filter模块负责的。
4. 每次过滤都去执行SQL表达式会影响效率,所以RocketMQ使用了BloomFilter避免了每次都去执行。这是一个布隆过滤器,精度还可以,能筛掉很多没必要的。
5. SQL92的表达式上下文为消息的属性。这个消息可以用户自己设置,也可以使用系统自己带的。用户自己设置下面代码我演示,但是系统自己带的也可以。
生产者端:
package com.yx.sqlFilter;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.exception.RemotingException;
/**
* @author: YX
* @description: TODO
* @date: 2022-5-13 22:47
* @version: 1.0
*/
public class MyProducerSql1 {
public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException {
// 生产者组
DefaultMQProducer producer = new DefaultMQProducer("producer_grp_07");
producer.setNamesrvAddr("localhost:9876");
producer.start();
Message message = null;
for (int i = 0; i < 100; i++) {
message = new Message("topic_demo_07",
"tag-" + (i % 3),
("hello message - " + i).getBytes()
);
String value = null;
int temp = i % 3;
if(temp == 0){
value = "key0";
}else if(temp == 1){
value = "key1";
}else {
value = "key2";
}
// 给消息添加用户属性,这个属性是用户加的,会被放进消息里面发过去
message.putUserProperty("mykey",value);
producer.send(message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println(sendResult.getSendStatus());
}
@Override
public void onException(Throwable e) {
System.out.println(e.getMessage());
}
});
};
Thread.sleep(3000);
producer.shutdown();
}
}
消费者端:
package com.yx.sqlFilter;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.MessageSelector;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.common.message.MessageQueue;
import java.util.List;
/**
* @author: YX
* @description: TODO
* @date: 2022-5-13 23:13
* @version: 1.0
*/
public class MyConsumerSql1 {
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_grp_07");
consumer.setNamesrvAddr("localhost:9876");
// 消费者这里用sql去筛选,就用生产者端加的属性mykey
consumer.subscribe("topic_demo_07", MessageSelector.bySql("mykey in ('key0','key1')"));
consumer.setMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
MessageQueue messageQueue = context.getMessageQueue();
String brokerName = messageQueue.getBrokerName();
String topic = messageQueue.getTopic();
int queueId = messageQueue.getQueueId();
System.out.println(brokerName + "\t" + topic + "\t" + queueId);
for (MessageExt msg : msgs) {
System.out.println(msg);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
}
}
# 这个tag属性是自己加的,可以自己定义。但是也不是非加不可,系统自己发的消息里面本身也有很多属性,除了用tag你可以用别的字段去你也可以拿那个去过滤,但是实际开发一般都自己定义一个属性,然后自己过滤,有可控性。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QXuSbLty-1663668378184)(rocketmq.assets/image-20220502115631073.png)]
conf/broker.conf
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LAc87Qyt-1663668378185)(rocketmq.assets/image-20220502115650753.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LclWK9df-1663668378185)(rocketmq.assets/image-20220502115658830.png)]
**首先需要开启支持SQL92的特性,然后重启broker:**注意重启的时候需要指定配置文件重启,不然你的修改不会生效。
mqbroker -n localhost:9876 -c /opt/rocket/conf/broker.conf
RocketMQ仅定义了几种基本的语法,用户可以扩展:
-
数字比较: >, >=, <, <=, BETWEEN, =
-
字符串比较: =, <>, IN; IS NULL或者IS NOT NULL;
-
逻辑比较: AND, OR, NOT;
-
Constant types are: 数字如:123, 3.1415; 字符串如:‘abc’,必须是单引号引起来 NULL,特殊常量 布尔型如:TRUE or FALSE;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GMKwFOij-1663668378186)(rocketmq.assets/image-20220502115751398.png)]
(3) Filter Server方式。这是一种比SQL表达式更灵活的过滤方式,允许用户自定义Java函数,根据Java函数的逻辑对消息进行过滤。要使用Filter Server,首先要在启动Broker前在配置文件里加上 filterServer-Nums=3 这样的配置,Broker在启动的时候,就会在本机启动3个Filter Server进程。Filter Server类似一个RocketMQ的
Consumer进程,它从本机Broker获取消息,然后根据用户上传过来的Java函数进行过滤,过滤后的消息再传给远端的Consumer。
这种方式会占用很多Broker机器的CPU资源,要根据实际情况谨慎使用。上传的java代码也要经过检查,不能有申请大内存、创建线程等这样的操作,否则容易造成Broker服务器宕机。
5、零拷贝原理
其实以前在网上看到这个讲的还不错。[零拷贝][https://mp.weixin.qq.com/s/DSVWBXyNEajn-w9BPaZhqg]
5.1、PageCache
由内存中的物理page组成,其内容对应磁盘上的block。存在一个映射关系,换言之就是内存中有这么个page对应磁盘上的block。page组成了pagecache,因为是页,放在内存中,也叫pagecache。
page cache的大小是动态变化的。因为会加载进新的页。
backing store: cache缓存的存储设备。
一个page通常包含多个block, 而block不一定是连续的。因为磁盘文件未必连续。page对block是一对多的。
5.1.1、读Cache
- 当内核发起一个读请求时, 先会检查请求的数据是否缓存到了page cache中。
- 如果有,那么直接从内存中读取,不需要访问磁盘, 此即 cache hit(缓存命中)
- 如果没有, 就必须从磁盘中读取数据, 然后内核将读取的数据再缓存到cache中, 如此后续的读请求就可以命中缓存了。
- page可以只缓存一个文件的部分内容, 而不需要把整个文件都缓存进来。
5.1.2、写Cache
- 当内核发起一个写请求时, 也是直接往cache中写入, 后备存储中的内容不会直接更新。不会直接刷盘。是为了提高效率。周期性的刷,合并多次操作为一次。
- 内核会将被写入的page标记为dirty, 并将其加入到dirty list中。
- 内核会周期性地将dirty list中的page写回到磁盘上, 从而使磁盘上的数据和内存中缓存的数据一致。
5.1.3、 cache回收
- Page cache的另一个重要工作是释放page, 从而释放内存空间。
- cache回收的任务是选择合适的page释放。
- 如果page是dirty的, 需要将page写回到磁盘中再释放。就是持久化了脏页然后就可以换出这部分空间了。
5.2、cache和buffer的区别
1. Cache:缓存区,是高速缓存,是位于CPU和主内存之间的容量较小但速度很快的存储器,因为CPU的速度远远高于主内存的速度,CPU从内存中读取数据需等待很长的时间,而 Cache保存着CPU刚用过的数据或循环使用的部分数据,这时从Cache中读取数据会更快,减少了CPU等待的时间,提高了系统的性能。Cache并不是缓存文件的,而是缓存块的(块是I/O读写最小的单元,就是上面的block);Cache一般会用在I/O请求上,如果多个进程要访问某个文件,可以把此文件读入Cache中,这样下一个进程获取CPU控制权并访问此文件直接从Cache读取,提高系统性能。
2. Buffer:缓冲区,用于存储速度不同步的设备或优先级不同的设备之间传输数据;通过buffer可以减少进程间通信需要等待的时间,当存储速度快的设备与存储速度慢的设备进行通信时,存储慢的数据先把数据存放到buffer,达到一定程度存储快的设备再读取buffer的数据,在此期间存储快的设备CPU可以干其他的事情。等于吧多次操作的数据存到一起,最后统一在buffer里面处理一次。
Buffer:一般是用在写入磁盘的,例如:某个进程要求多个字段被读入,当所有要求的字段被读入之前已经读入的字段会先放到buffer中。避免你读一个写一次,堆在一起一次写。
5.3、HeapByteBuffer和DirectByteBuffer
java里面没这两个类,就是个叫法是个概念。
HeapByteBuffer,是在jvm堆上面一个buffer,底层的本质是一个数组,用类封装维护了很多的索引(limit/position/capacity等)。可以垃圾回收。
DirectByteBuffer,底层的数据是维护在操作系统的内存中,而不是jvm里,DirectByteBuffer里维护了一个引用address指向数据,进而操作数据。Buffer里面放着数据的引用,数据在内存里,是这么个结构。当然了DirectByteBuffer也在内存里。
HeapByteBuffer优点:内容维护在jvm里,把内容写进buffer里速度快;更容易回收。GC能处理到。
DirectByteBuffer优点:跟外设(IO设备)打交道时会快很多,因为外设读取jvm堆里的数据时,不是直接读取的,而是把jvm里的数据读到一个内存块里,再在这个块里读取的,如果使用DirectByteBuffer,数据本身就在内存里面,则可以省去这一步,实现zero copy(零拷贝)。
外设之所以要把jvm堆里的数据copy出来再操作,不是因为操作系统不能直接操作jvm内存,而是因为jvm在进行gc(垃圾回收)时,会对数据进行移动,一旦出现这种问题,他移动了,你这时候去读外设就会出现数据错乱的情况。意思就是万一你读的时候他GC了就会错乱。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Wl2ty5lt-1663668378186)(rocketmq.assets/image-20220502121747172.png)]
所有的通过allocate方法创建的buffer都是HeapByteBuffer.
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DHEKNcSV-1663668378186)(rocketmq.assets/image-20220502121819163.png)]
堆外内存实现零拷贝
1. 前者分配在JVM堆上(ByteBuffer.allocate()),后者分配在操作系统物理内存上(ByteBuffer.allocateDirect(),JVM使用C库中的malloc()方法分配堆外内存);
2. DirectByteBuffer可以减少JVM GC压力,当然,堆中依然保存对象引用,fullgc发生时也会回收直接内存,也可以通过system.gc主动通知JVM回收,或者通过 cleaner.clean主动清理。Cleaner.create()方法需要传入一个DirectByteBuffer对象和一个Deallocator(一个堆外内存回收线程)。GC发生时发现堆中的DirectByteBuffer对象没有强引用了,则调用Deallocator的run()方法回收直接内存,并释放堆中DirectByteBuffer的对象引用;
3. 底层I/O操作需要连续的内存(JVM堆内存容易发生GC和对象移动),所以在执行write操作时需要将HeapByteBuffer数据拷贝到一个临时的(操作系统用户态)内存空间中,会多一次额外拷贝。而DirectByteBuffer则可以省去这个拷贝动作,这是Java层面的 “零拷贝” 技术,在netty中广泛使用;
4. MappedByteBuffer底层使用了操作系统的mmap机制,FileChannel#map()方法就会返回MappedByteBuffer。DirectByteBuffer虽然实现了MappedByteBuffer,不过DirectByteBuffer默认并没有直接使用mmap机制。
5.4、缓冲IO和直接IO
5.4.1、缓存IO
缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。
读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。就是数据会缓存到系统缓存里面。
写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非用户那里显示地调用了sync同步命令。
# 缓存I/O的优点:
1. 在一定程度上分离了内核空间和用户空间,保护系统本身的运行安全;
2. 可以减少读盘的次数,从而提高性能。因为做了缓存。
# 缓存I/O的缺点:
1. 在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,因位要经过缓存这一步,没有这个就不是缓存IO了。数据在传输过程中就需要在应用程序地址空间(用户空间)和缓存(内核空间)之间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。
缓存IO适合在那种大块文件的移动比较好,小文件太多,操作这么多步骤就麻烦很多。
5.4.2、直接IO
# 直接IO就是应用程序直接访问磁盘数据,而不经过内核缓冲区,这样做的目的是减少一次从内核缓冲区到用户程序缓存的数据复制。比如说数据库管理系统这类应用,它们更倾向于选择它们自己的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。比如msyql就有自己的缓存机制。
直接IO的缺点:如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘加载,这种直接加载会非常缓慢。通常直接IO与异步IO结合使用,会得到比较好的性能。
注意这里的缓存是应用层的缓存,上面缓存IO是系统的内存页缓存。
直接IO+同步IO的意思是:我应用需要数据了,就去应用缓存拿一下,要是没有,也不过系统缓存什么的了,直接去磁盘读,读取期间,IO是同步的,应用一直在等。
异步IO就是读取的时候,应用可以去做别的事。
下图分析了写场景下的DirectIO和BufferIO:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VVkPkcqr-1663668378187)(rocketmq.assets/image-20220502124508728.png)]
5.5、内存映射文件(Mmap)
在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。虚拟化操作,就是这个玩意。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C7rI3Lsp-1663668378187)(rocketmq.assets/image-20220502124556957.png)]
# 映射关系可以分为两种
1. 文件映射 磁盘文件映射进程的虚拟地址空间,使用文件内容初始化物理内存。
2. 匿名映射 初始化全为0的内存空间。
# 而对于映射关系是否共享又分为
1. 私有映射(MAP_PRIVATE) 多进程间数据共享,修改不反应到磁盘实际文件,是一个copy-onwrite(写时复制)的映射方式。
2. 共享映射(MAP_SHARED) 多进程间数据共享,修改反应到磁盘实际文件中。
# 因此总结起来有4种组合
1. 私有文件映射 多个进程使用同样的物理内存页进行初始化,但是各个进程对内存文件的修改不会共享,也不会反应到物理文件中。
2. 私有匿名映射 mmap会创建一个新的映射,各个进程不共享,这种使用主要用于分配内存(malloc分配大内存会调用mmap)。 例如开辟新进程时,会为每个进程分配虚拟的地址空间,这些虚拟地址映射的物理内存空间各个进程间读的时候共享,写的时候会copy-on-write。
3. 共享文件映射 多个进程通过虚拟内存技术共享同样的物理内存空间,对内存文件 的修改会反应到实际物理文件中,他也是进程间通信(IPC)的一种机制。
4. 共享匿名映射 这种机制在进行fork的时候不会采用写时复制,父子进程完全共享同样的物理内存页,这也就实现了父子进程通信(IPC)。
mmap只是在虚拟内存分配了地址空间,只有在第一次访问虚拟内存的时候才分配物理内存。
在mmap之后,并没有在将文件内容加载到物理页上,只上在虚拟内存中分配了地址空间。当进程在访问这段地址时,通过查找页表,发现虚拟内存对应的页没有在物理内存中缓存,则产生"缺页",由内核的缺页异常处理程序处理,将文件对应内容,以页为单位(4096)加载到物理内存,注意是只加载缺页,但也会受操作系统一些调度策略影响,加载的比所需的多。
5.6、直接内存读取并发送文件的过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nnFq2z8B-1663668378188)(rocketmq.assets/image-20220502124724521.png)]
5.7、 Mmap读取并发送文件的过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YCvpOAWN-1663668378188)(rocketmq.assets/image-20220502124756519.png)]
5.8、 Sendfile零拷贝读取并发送文件的过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-o6j8vX2O-1663668378189)(./rocketmq/image-20220502124824016.png)]
# 零拷贝(zero copy)小结
1. 虽然叫零拷贝,实际上sendfile有2次数据拷贝的。第1次是从磁盘拷贝到内核缓冲区,第二次是从内核缓冲区拷贝到网卡(协议引擎)。如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术,就无需从PageCache拷贝至 Socket 缓冲区;
2. 之所以叫零拷贝,是从内存角度来看的,数据在内存中没有发生过拷贝,只是在内存和I/O设备之间传输。很多时候我们认为sendfile才是零拷贝,mmap严格来说不算;
3. Linux中的API为sendfile、mmap,Java中的API为FileChanel.transferTo()、FileChannel.map()等;
4. Netty、Kafka(sendfile)、Rocketmq(mmap)、Nginx等高性能中间件中,都有大量利用操作系统零拷贝特性。
6、同步复制和异步复制
如果一个Broker组有Master和Slave,消息需要从Master复制到Slave 上,有同步和异步两种复制方式。所以对象是broker之间的。
当然这些broker都需要像nameserver去同步元数据。
6.1、同步复制
同步复制方式是等Master和Slave均写 成功后才反馈给客户端写成功状态;
在同步复制方式下,如果Master出故障,Slave上有全部的备份数据,容易恢复,但是同步复制会增大数据写入延迟,因为要写所有的slave,降低系统吞吐量。
6.2、异步复制
异步复制方式是只要Master写成功 即可反馈给客户端写成功状态。
在异步复制方式下,系统拥有较低的延迟和较高的吞吐量,但是如果Master出了故障,有些数据因为没有被写 入Slave,有可能会丢失;
6.3、配置
同步复制和异步复制是通过Broker配置文件里的brokerRole参数进行设置的,这个参数可以被设置成ASYNC_MASTER、 SYNC_MASTER、SLAVE三个值中的一个。
/opt/rocket/conf/broker.conf 文件:Broker的配置文件
参数名 | 默认值 | 说明 |
---|---|---|
listenPort | 10911 | 接受客户端连接的监听端口 |
namesrvAddr | null | nameServer 地址,一般设置集群里的多个,避免一个不可用。 |
brokerIP1 | 网卡的 InetAddress | 当前 broker 监听的 IP |
brokerIP2 | 跟 brokerIP1 一样 | 存在主从 broker 时,如果在 broker 。主节点上配置了 brokerIP2 属性,broker 从节点会连接主节点配置的 brokerIP2 进行同步 |
brokerName | null | broker 的名称 |
brokerClusterName | DefaultCluster | 本 broker 所属的 Cluser 名称 |
brokerId | 0 | broker id, 0 表示 master, 其他的正整数表示 slave |
storePathCommitLog | $HOME/store/commitlog/ | 存储 commit log 的路径 |
storePathConsumerQueue | $HOME/store/consumequeue/ | 存储 consume queue 的路径 |
mapedFileSizeCommitLog | 1024 * 1024 * 1024(1G) | commit log 的映射文件大小 |
deleteWhen | 04 | 在每天的什么时间删除已经超过文件保留时间的 commit log |
fileReserverdTime | 72 | 以小时计算的文件保留时间 |
brokerRole | ASYNC_MASTER | broker角色,SYNC_MASTER或者ASYNC_MASTER或 者SLAVE。 SYNC_MASTER表示当前broker是一个 同步复制的Master。 ASYNC_MASTER表示当前broker是一 个异步复制的Master 。SLAVE表示当前borker是一个Slave。 |
flushDiskType | ASYNC_FLUSH | SYNC_FLUSH/ASYNC_FLUSH SYNC_FLUSH 模式下的 broker 保证在 收到确认生产者之前将消息刷盘。 ASYNC_FLUSH 模式下的 broker 则利 用刷盘一组消息的模式,可以取得更好 的性能。 |
6.4、总结
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4dyWcOz8-1663668378190)(rocketmq.assets/image-20220502135048120.png)]
实际应用中要结合业务场景,合理设置刷盘方式和主从复制方式, 尤其是SYNC_FLUSH方式,由于频繁地触发磁盘写动作,会明显降低性能。通常情况下,应该把Master和Slave配置成ASYNC_FLUSH的刷盘 方式,主从之间配置成SYNC_MASTER的复制方式,这样即使有一台机器出故障,仍然能保证数据不丢,是个不错的选择。一般是主从同步是同步,保证可靠,但是写磁盘配置成异步。保证性能。
7、 高可用机制
RocketMQ分布式集群是通过Master和Slave的配合达到高可用性的。
Master和Slave的区别:
1. 在Broker的配置文件中,参数brokerId的值为0表明这个Broker是Master;
2. 大于0表明这个Broker是Slave.
3. brokerRole参数也可以说明这个Broker是Master还是Slave。(SYNC_MASTER/ASYNC_MASTER/SALVE)
4. Master角色的Broker支持读和写,Slave角色的Broker仅支持读。
5. Consumer可以连接Master角色的Broker,也可以连接Slave角色的Broker来读取消息。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Q4tQPOFB-1663668378190)(rocketmq.assets/image-20220502135152292.png)]
7.1、消息消费高可用
在Consumer的配置文件中,并不需要设置是从Master读还是从Slave 读,当Master不可用或者繁忙的时候,Consumer会被自动切换到从Slave 读。
有了自动切换Consumer这种机制,当一个Master角色的机器出现故障后,Consumer仍然可以从Slave读取消息,不影响Consumer程序。
这就达到了消费端的高可用性。
7.2、消息发送高可用
如何达到发送端的高可用性呢?
在创建Topic的时候,把Topic的多个Message Queue创建在多个Broker组上(相同Broker名称,
不同brokerId的机器组成一个Broker组),这样既可以在性能方面具有扩展性,也可以降低主节点故障对整体上带来的影响,而且当一个Broker组的Master不可用后,其他组的Master仍然可用,Producer仍然可以发送消息的。
RocketMQ目前还不支持把Slave自动转成Master,如果机器资源不足,需要把Slave转成Master。 4.5之后支持了。
-
手动停止Slave角色的Broker。
-
更改配置文件。
-
用新的配置文件启动Broker。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-td5FEtPv-1663668378190)(rocketmq.assets/image-20220502135815531.png)]
这种早期方式在大多数场景下都可以很好的工作,但也面临一些问题。
比如,在需要保证消息严格顺序的场景下,由于在主题层面无法保证严格顺序,所以必须指定队列来发送消息,对于任何一个队列,它一定是落在一组特定的主从节点上,如果这个主节点宕机,其他的主节点是无法替代这个主节点的,否则就无法保证严格顺序。在这种复制模式下,严格顺序和高可用只能选择一个。
RocketMQ 在 2018 年底迎来了一次重大的更新,引入 Dledger,增加了一种全新的复制方式。
RocketMQ 引入 Dledger,使用新的复制方式,可以很好地解决这个问题。
Dledger 在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客户端返回写入成功,并且它是支持通过选举来动态切换主节点的。
# 举例:
假如有3个节点,当主节点宕机的时候,2 个从节点会通过投票选出一个新的主节点来继续提供服务,相比主从的复制模式,解决了可用性的问题。
由于消息要至少复制到 2 个节点上才会返回写入成功,即使主节点宕机了,也至少有一个节点上的消息是和主节点一样的。
Dledger在选举时,总会把数据和主节点一样的从节点选为新的主节点,这样就保证了数据的一致性,既不会丢消息,还可以保证严格顺序。复制进度可以实现这个。
# 存在问题:
当然,Dledger的复制方式也不是完美的,依然存在一些不足:
1. 比如,选举过程中不能提供服务。
2. 最少需要 3 个节点才能保证数据一致性,3 节点时,只能保证 1 个节点宕机时可用,如果 2个节点同时宕机,即使还有 1 个节点存活也无法提供服务,资源的利用率比较低。
3. 另外,由于至少要复制到半数以上的节点才返回写入成功,性能上也不如主从异步复制的方式快。
但是起码保证了数据不丢失,顺序可以得到保证。因为从节点里面一定有和主节点一样的数据,所以这个你再写的时候就还是原来的顺序。因为啥都一样。其实就是牺牲了性能保证了严格的有序机制。
8、刷盘机制
RocketMQ 的所有消息都是持久化的,先写入系统 PageCache,然后刷盘,可以保证内存与磁盘都有一份数据, 访问时,直接从内存读取。消息在通过Producer写入RocketMQ的时候,有两种写磁盘方式,分布式同步刷盘和异步刷盘。
也就是你写消息到commitlog里面是先进pagecache的。pagecache是内存里的。是先进内存的。然后由内存刷到commitlog里面去。
8.1、同步刷盘
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wGY6sn9W-1663668378191)(rocketmq.assets/image-20220502140003882.png)]
同步刷盘与异步刷盘的唯一区别是异步刷盘写完 PageCache直接返回,而同步刷盘需要等待刷盘完成才返回, 同步刷盘流程如下:
(1). 写入 PageCache后,线程等待,通知刷盘线程刷盘。
(2). 刷盘线程刷盘后,唤醒前端等待线程,可能是一批线程。
(3). 前端等待线程向用户返回成功。
8.2、异步刷盘
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VxfgqUyw-1663668378191)(rocketmq.assets/image-20220502140034540.png)]
在有 RAID 卡,SAS 15000 转磁盘测试顺序写文件,速度可以达到 300M 每秒左右,而线上的网卡
一般都为千兆 网卡,写磁盘速度明显快于数据网络入口速度,那么是否可以做到写完内存就向用户返回,由后台线程刷盘呢?
-
由于磁盘速度大于网卡速度,那么刷盘的进度肯定可以跟上消息的写入速度。
-
万一由于此时系统压力过大,可能堆积消息,除了写入 IO,还有读取 IO,万一出现磁盘读取落后情况, 会不会导致系统内存溢出,答案是否定的,原因如下:
- 写入消息到 PageCache时,如果内存不足,则尝试丢弃干净的 PAGE(就是数据和磁盘数据是一致的就是干净页),腾出内存供新消息使用,策略是LRU 方式。
- 如果干净页不足,此时写入 PageCache会被阻塞,系统尝试刷盘部分数据,大约每次尝试 32个 PAGE , 来找出更多干净 PAGE。
综上,内存溢出的情况不会出现。
9、负载均衡
rocket是可以支持动态扩展的,分区的扩展,这时候扩展了就有负载均衡这一说了,因为你要走集群模式,就是负载均衡的。广播模式当我没说。
RocketMQ中的负载均衡都在Client端完成,具体来说的话,主要可以分为Producer端发送消息时候的负载均衡和Consumer端订阅消息的负载均衡。
9.1、 Producer的负载均衡
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rcfp1lrr-1663668378192)(rocketmq.assets/image-20220503210421807.png)]
如图所示,5 个队列可以部署在一台机器上,也可以分别部署在 5 台不同的机器上,发送消息通过轮询队列的方式 发送,每个队列接收平均的消息量。通过增加机器,可以水平扩展队列容量。 另外也可以自定义方式选择发往哪个队列。默认是轮训发送的,下面我演示一下指定队列是怎么处理的。
# 创建主题 -w 6表示6个队列,也即是kafka里面的6个分区,你不指定默认就是4个
[root@node1 ~]# mqadmin updateTopic -n localhost:9876 -t tp_demo_02 -w 6 -b localhost:10911
DefaultMQProducer producer = new DefaultMQProducer("producer_grp_02");
producer.setNamesrvAddr("node1:9876");
producer.start();
Message message = new Message();
message.setTopic("tp_demo_02");
message.setBody("hello message".getBytes());
// 指定MQ 发送的时候指定消息的发送目的地,总共三个参数。
/**
1、主题名称
2、broker的名称,brokername -p可以查看名字
3、topic里面你要发去的mq的编号
下面这个意思就是发消息去主题为tp_demo_06的位于node1的broker上的5号mq队列,因为我就一个机器,所以6个队列都在一个机器上,要是集群部署需要找一下信息
*/
SendResult result = producer.send(message,
new MessageQueue("tp_demo_06", "node1", 5),
1_000 );// 发送消息等待1秒,1秒超时,也就是设置超时时间
System.out.println(result.getSendStatus());
producer.shutdown();
package com.yx.lb;
import org.apache.rocketmq.client.consumer.DefaultMQPullConsumer;
import org.apache.rocketmq.client.consumer.PullResult;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.exception.RemotingException;
/**
* @author: YX
* @description: TODO
* @date: 2022-5-15 13:01
* @version: 1.0
*/
public class MyConsumer {
public static void main(String[] args) throws InterruptedException, RemotingException, MQClientException, MQBrokerException {
DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("cosumer_group_06");
consumer.setNamesrvAddr("localhost:9876");
consumer.start();
// 消费者拉取node1的5号队列,你换成4号就无了
PullResult pullResult = consumer.pull(new MessageQueue("tp_demo_02", "node1", 5),
"*", 0L, 10);
pullResult.getMsgFoundList().forEach(messageExt -> {
System.out.println(messageExt.toString());
});
consumer.shutdown();
}
}
9.2、 Consumer的负载均衡
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KScvLICt-1663668378192)(rocketmq.assets/image-20220503210545309.png)]
如图所示,如果有 5 个队列,一个消费者组里面有2 个 consumer,那么第一个 Consumer 消费 3 个队列,第二consumer 消费 2 个队列。 这样即可达到平均消费的目的,可以水平扩展 Consumer 来提高消费能力。但是 Consumer 数量要小于等于队列数 量,如果 Consumer 超过队列数量,那么多余的Consumer 将不能消费消息 。 因为要保证一个mq只能被一个消费者组里面的一个消费者消费。不然就会混乱。出现重复消费。注意,不同消费者组是不影响的,上面的规则是在一个消费者组里面。
在RocketMQ中,Consumer端的两种消费模式(Push/Pull)底层都是基于拉模式来获取消息的,而在Push模式只是对pull模式的一种封装,其本质实现为消息拉取线程在从服务器拉取到一批消息后,然后提交到消息消费线程池后,又“马不停蹄”的继续向服务器再次尝试拉取消息。
如果未拉取到消息,则延迟一下又继续拉取。在两种基于拉模式的消费方式(Push/Pull)中,均需要Consumer端在知道从Broker端的哪一个消息队列中去获取消息。
因此,有必要在Consumer端来做负载均衡,即Broker端中多个MessageQueue分配给同一个ConsumerGroup中的哪些Consumer消费。
要做负载均衡,必须知道一些全局信息,也就是一个ConsumerGroup里到底有多少个Consumer。
知道了全局信息,才可以根据某种算法来分配,比如简单地平均分到各个Consumer。
在RocketMQ中,负载均衡或者消息分配是在Consumer端代码中完成的,Consumer从Broker处获得全局信息,然后自己做负载均衡,只处理分给自己的那部分消息。
Pull Consumer程序员可以看到所有的Message Queue,而且从哪个Message Queue读取消息,读消息时的Offset都由使用者控制,使用者可以实现任何特殊方式的负载均衡。这种方式看你怎么处理了。
DefaultMQPullConsumer有两个辅助方法可以帮助实现负载均衡,一个是registerMessageQueueListener函数,一个是MQPullConsumerScheduleService(使用这个Class类似使用DefaultMQPushConsumer,但是它把Pull消息的主动性留给了使用者)
DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("cosumer_group_06");
consumer.setNamesrvAddr("localhost:9876");
consumer.start();
// 消费者拉取node1的5号队列,这里可以pull的时候指定从哪里拉取,你要是不指定那就是轮询。
PullResult pullResult = consumer.pull(new MessageQueue("tp_demo_02", "node1", 5),
"*", 0L, 10);
pullResult.getMsgFoundList().forEach(messageExt -> {
System.out.println(messageExt.toString());
});
consumer.shutdown();
DefaultMQPushConsumer的负载均衡过程不需要使用者操心,客户端程序会自动处理,每个DefaultMQPushConsumer启动后,会马上会触发一个doRebalance动作;而且在同一个ConsumerGroup里加入新的DefaultMQPush-Consumer时,各个Consumer都会被触发doRebalance动作。
负载均衡的分配粒度只到Message Queue,把Topic下的所有Message Queue分配到不同的Consumer中
如下图所示,具体的负载均衡算法有几种,默认用的是AllocateMessageQueueAveragely。
# AllocateMessageQueueAveragely默认方式。我来解释一下具体是怎么分配的。
例1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SpcmmFao-1663668378192)(rocketmq.assets/image-20220515132604995.png)]
例2:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dzMgh7fo-1663668378193)(rocketmq.assets/image-20220515132940364.png)]
我们看到了具体的分配方法,是按顺序分的,以后你消费者消费也按照这个分配好的消费,你的消费者也得是有序的,照着顺序来份,不然一会一个顺序就乱了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uVkVGgoy-1663668378193)(rocketmq.assets/image-20220503210734770.png)]
我们可以设置负载均衡的算法:
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_push_grp_01");
consumer.setNamesrvAddr("node1:9876");
// 设置负载均衡算法
consumer.setAllocateMessageQueueStrategy(new AllocateMessageQueueAveragely());
consumer.setMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
// todo 处理接收到的消息
return null;
}
});
consumer.start();
以AllocateMessageQueueAveragely策略为例,如果创建Topic的时候,把Message Queue数设为3,当Consumer数量为2的时候,有一个Consumer需要处理Topic三分之二的消息,另一个处理三分之一的消息;当Consumer数量为4的时候,有一个Consumer无法收到消息,其他3个Consumer各处理Topic三分之一的消息。
可见Message Queue数量设置过小不利于做负载均衡,通常情况下,应把一个Topic的MessageQueue数设置为16。
1、Consumer端的心跳包发送
在Consumer启动后,它就会通过定时任务不断地向RocketMQ集群中的所有Broker实例发送心跳包(其中包含了消息消费分组名称、订阅关系集合、消息通信模式和客户端id的值等信息)。Broker端在收到Consumer的心跳消息后,会将它维护在ConsumerManager的本地缓存变量—consumerTable,同时并将封装后的客户端网络通道信息保存在本地缓存变量—channelInfoTable中,为之后做Consumer端的负载均衡提供可以依据的元数据信息。也就是消费者启动后会先做负载均衡的信息生成,然后定时任务不断发给所有broker通知他们知道,以后你消息过来了,做负载均衡就有元数据了。
2、Consumer端实现负载均衡的核心类—RebalanceImpl
在Consumer实例的启动流程中启动MQClientInstance实例的部分,会完成负载均衡服务线程—RebalanceService的启动(每隔20s执行一次)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LhjTJ7J3-1663668378194)(rocketmq.assets/image-20220503210845163.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f0ITm6si-1663668378194)(rocketmq.assets/image-20220503210855809.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uFdvZ0x6-1663668378194)(rocketmq.assets/image-20220503210906452.png)]
通过查看源码可以发现,RebalanceService线程的run()方法最终调用的是RebalanceImpl类的**rebalanceByTopic()**方法,该方法是实现Consumer端负载均衡的核心。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EKwYI1OD-1663668378195)(rocketmq.assets/image-20220503210928271.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nhOUHAEL-1663668378195)(rocketmq.assets/image-20220503210938547.png)]
这里,rebalanceByTopic()方法根据消费者通信类型为“广播模式”还是“集群模式”做不同的逻辑处理。广播模式就是直接全部发出去,集群模式需要负载均衡。就涉及到负载均衡算法。
对于集群模式:
case CLUSTERING: {
// 获取这个主题下面的所有的队列
Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
// 获取你订阅了主题的所有消费者集合
List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);
if (null == mqSet) {
if (!topic.startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
log.warn("doRebalance, {}, but the topic[{}] not exist.", consumerGroup, topic);
}
}
if (null == cidAll) {
log.warn("doRebalance, {} {}, get consumer id list failed", consumerGroup, topic);
}
if (mqSet != null && cidAll != null) {
List<MessageQueue> mqAll = new ArrayList<MessageQueue>();
mqAll.addAll(mqSet);
// 对MQ进行排序
Collections.sort(mqAll);
// 对消费者ID进行排序
Collections.sort(cidAll);
// 获取分配策略
AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;
List<MessageQueue> allocateResult = null;
try {
// 计算当前消费者应该分配的MQ集合
allocateResult = strategy.allocate(
// 当前消费者所属的消费组
this.consumerGroup,
// 当前消费者ID,拿当前消费者在所有mq集合里面去获取你要消费的mq集合
this.mQClientFactory.getClientId(),
// MQ集合,所有的队列都在这里
mqAll,
// 消费组中消费者ID集合
cidAll);
} catch (Throwable e) {
log.error("AllocateMessageQueueStrategy.allocate Exception. allocateMessageQueueStrategyName={}", strategy.getName(),e);
return;
}
Set<MessageQueue> allocateResultSet = new HashSet<MessageQueue>();
if (allocateResult != null) {
allocateResultSet.addAll(allocateResult);
}
boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);
if (changed) {
log.info(
"rebalanced result changed. allocateMessageQueueStrategyName={}, group={}, topic={}, clientId={}, mqAllSize={}, cidAllSize={}, rebalanceResultSize={}, rebalanceResultSet={}",
strategy.getName(), consumerGroup, topic, this.mQClientFactory.getClientId(), mqSet.size(), cidAll.size(),
allocateResultSet.size(), allocateResultSet);
this.messageQueueChanged(topic, mqSet, allocateResultSet);
}
}
break;
}
默认的负载均衡算法:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mjq6UwHQ-1663668378196)(rocketmq.assets/image-20220503211014956.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2MtlaeJP-1663668378196)(rocketmq.assets/image-20220503211027452.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jTtMhbDC-1663668378196)(rocketmq.assets/image-20220503211038096.png)]
AllocateMessageQueueAveragely是默认的MQ分配对象。算法:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZcGSpLB2-1663668378197)(rocketmq.assets/image-20220503211101079.png)]
/**
计算当前消费者应该消费哪些MQ队列的消息,所以要传一个消费者组,传一个当前消费者的队列ID,传进去mq队列的集合。
consumerGroup:当前消费者组。
currentCID:当前消费者ID。
mqAll:当前主题所包含的MQ队列集合。
cidAll:当前消费者组中包含的消费者ID,在启动消费者的时候doRebalance的时候根据策略做计算。
最后返回当前消费者应该消费的MQ集合。
*/
@Override
public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll,List<String> cidAll) {
if (currentCID == null || currentCID.length() < 1) {
throw new IllegalArgumentException("currentCID is empty");
}
if (mqAll == null || mqAll.isEmpty()) {
throw new IllegalArgumentException("mqAll is null or mqAll empty");
}
if (cidAll == null || cidAll.isEmpty()) {
throw new IllegalArgumentException("cidAll is null or cidAll empty");
}
List<MessageQueue> result = new ArrayList<MessageQueue>();
if (!cidAll.contains(currentCID)) {
log.info("[BUG] ConsumerGroup: {} The consumerId: {} not in cidAll: {}",
consumerGroup,
currentCID,
cidAll);
return result;
}
// 获取当前消费者在所有消费者集合里面的位置下标
int index = cidAll.indexOf(currentCID);
// mqAll的个数对cidAll的个数大小取模
int mod = mqAll.size() % cidAll.size();
/**
这里比较复杂,我们拆开看一下逻辑
1、判断队列长度是否小于消费者组长度。
2、如果小于等于,返回1,就是你的队列长度大于了消费者个数,就每个消费者一个
3、如果大于,进入新的三元组判断mod和index大小
3.1、如果余数大于0并且当前消费者下标小于余数,则当前消费者应该消费平均数个mq+1
3.2、如果余数大于0并且当前消费者下标大于等于余数,则当前消费者应该消费平均数个mq
*/
int averageSize =
mqAll.size() <= cidAll.size() ?
1 :
(mod > 0 && index < mod ? mqAll.size() / cidAll.size()
+ 1 : mqAll.size() / cidAll.size());
// 计算当前消费者消费mq的起始位置
int startIndex = (mod > 0 && index < mod) ? index * averageSize : index * averageSize + mod;
// 计算当前消费者消费mq的跨度,即当前消费者分几个MQ
int range = Math.min(averageSize, mqAll.size() - startIndex);
// 分配MQ,放到result集合中返回
for (int i = 0; i < range; i++) {
result.add(mqAll.get((startIndex + i) % mqAll.size()));
}
return result;
}
上述代码看上去晦涩其实也简单,我画个图来说明一下。其实就是下图的过程,他用了一堆三元组,笨办法直接写也不是不可以。要是你的消费者比队列大,那就每个消费者一个,有的消费者消费不到队列,我指的是一个消费组内的。其余消费者组他们再计算自己的分配。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FbP5Pwfz-1663668378197)(rocketmq.assets/image-20220515141436960.png)]
消息消费队列在同一消费组不同消费者之间的负载均衡,其核心设计理念是在一个消息消费队列在同一时间只允许被同一消费组内的一个消费者消费,一个消息消费者能同时消费多个消息队列。
10、消息重试
10.1、 顺序消息的重试
对于顺序消息,当消费者消费消息失败后,消息队列 RocketMQ 会自动不断进行消息重试(每次间隔时间为 1 秒),这时,应用会出现消息消费被阻塞的情况。因此,在使用顺序消息时,务必保证应用能够及时监控并处理消费失败的情况,避免阻塞现象的发生。
# 这里我解释一下这个东西:他是为了保证顺序消费的,比如你消息在消费端的顺序是0,1,2,3这样,当你消费0失败了,他不会再发给消费者去消费1,而是重试重拉取0,每秒一次,以此来保证严格顺序。但是后面的就停了。这种就这么刚。
这种重试主要是针对推送方式来说的,因为你要是拉取,你就能控制,失败了如何处理,要不要重来一次。推送就是让broker来处理了,你这个消费失败了,是不是要阻塞后面重发你这个。
代码:
package com.yx.reconsume;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;
import java.util.List;
/**
* @author: YX
* @description: 顺序消费
* @date: 2022-5-16 22:24
* @version: 1.0
*/
public class MyOrderConsumer {
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_group_04");
consumer.setNamesrvAddr("localhost:9876");
consumer.setConsumeMessageBatchMaxSize(1);// 设置每次消费最大一个,就是要看到那个失败一个就重试阻塞后面接收的效果
consumer.setConsumeThreadMin(1);
consumer.setConsumeThreadMax(1);
// 消息订阅
consumer.subscribe("topic_demo_04", "*");
// 顺序消费 MessageListenerOrderly这个实现类是顺序消费的,可以点进去看看
consumer.setMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
for (MessageExt msg : msgs) {
System.out.println(msg.getMsgId() + "\t" + msg.getQueueId()
+ "\t" + new String(msg.getBody()));
}
return null;// 返回null会触发重试
//return ConsumeOrderlyStatus.SUCCESS;// 成功消费,不会触发重试
//return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;// 触发重试
}
});
consumer.start();
}
}
看一下那个顺序消费的实现类:
/**
* A MessageListenerConcurrently object is used to receive asynchronously delivered messages orderly.one queue,one
* thread
*/
public interface MessageListenerOrderly extends MessageListener {
/**
* It is not recommend to throw exception,rather than returning ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT
* if consumption failure 我们看到注释说了,最好消费失败别抛出异常,而是返回ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT
*
* @param msgs msgs.size() >= 1<br> DefaultMQPushConsumer.consumeMessageBatchMaxSize=1,you can modify here
* @return The consume status
*/
ConsumeOrderlyStatus consumeMessage(final List<MessageExt> msgs,
final ConsumeOrderlyContext context);
}
# 另外我说个事情,就是假如我们生产者发一个主题有4个queue,你一次发十个消息,那就每个队列都有两个这样,然后你发四个过来,他这个失败重试是针对的所有队列,就是你消费者这里每次只能收到队列1234的一个消息,1234队列后面的都会被阻塞重试。说的是所有队列,不是说就重试阻塞1号队列或者2这样。
如果你不用保持顺序,那就用下面这个MessageListenerConcurrently
// 并发消费
consumer.setMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
return null;
}
});
10.2、无序消息的重试
对于无序消息(普通、定时、延时、事务消息),当消费者消费消息失败时,您可以通过设置返回状态达到消息重试的结果。
无序消息的重试只针对集群消费方式生效;广播方式不提供失败重试特性,即消费失败后,失败消息不再重试,继续消费新的消息。
10.2.1、重试次数
消息队列 RocketMQ 默认允许每条消息最多重试 16 次,每次重试的间隔时间如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W5ZA2KU5-1663668378198)(rocketmq.assets/image-20220503211258108.png)]
如果消息重试 16 次后仍然失败,消息将不再投递。如果严格按照上述重试时间间隔计算,某条消息在一直消费失败的前提下,将会在接下来的 4 小时 46 分钟之内进行 16 次重试,超过这个时间范围消息将不再重试投递。
注意: 一条消息无论重试多少次,这些重试消息的 Message ID 不会改变。
10.2.2、配置方式
消费失败后,重试配置方式
集群消费方式下,消息消费失败后期望消息重试,需要在消息监听器接口的实现中明确进行配置(三种方式任选一种):
- 返回 ConsumeConcurrentlyStatus.RECONSUME_LATER; (推荐)
- 返回 Null
- 抛出异常
public class MyConcurrentlyMessageListener implements MessageListenerConcurrently {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
//处理消息
doConsumeMessage(msgs);
//方式1:返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,消息将重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
//方式2:返回 null,消息将重试
return null;
//方式3:直接抛出异常, 消息将重试
throw new RuntimeException("Consumer Message exceotion");
}
}
消费失败后,不重试配置方式
集群消费方式下,消息失败后期望消息不重试,需要捕获消费逻辑中可能抛出的异常,最终返回ConsumeConcurrentlyStatus.CONSUME_SUCCESS,此后这条消息将不会再重试。把异常捕获返回正常就行了。
public class MyConcurrentlyMessageListener implements MessageListenerConcurrently {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
doConsumeMessage(msgs);
} catch (Throwable e) {
//捕获消费逻辑中的所有异常,并返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
//消息处理正常,直接返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
自定义消息最大重试次数
消息队列 RocketMQ 允许 Consumer 启动的时候设置最大重试次数,重试时间间隔将按照如下策略:
- 最大重试次数小于等于 16 次,则重试时间间隔同上表描述。
- 最大重试次数大于 16 次,超过 16 次的重试时间间隔均为每次 2 小时。
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_grp_04_01");
// 设置重新消费的次数为20次
// 共16个级别,大于16的一律按照2小时重试
consumer.setMaxReconsumeTimes(20);
注意:
- 消息最大重试次数的设置对相同 Group ID 下的所有 Consumer 实例有效。
- 如果只对相同 Group ID 下两个 Consumer 实例中的其中一个设置了MaxReconsumeTimes,那么该配置对两个 Consumer 实例均生效。
- 配置采用覆盖的方式生效,即最后启动的 Consumer 实例会覆盖之前的启动实例的配置,他是为了保证你的一致的,用的是覆盖操作。最后保证消费组里面所有的消费者的当前重试次数是一样的。
获取消息重试次数
消费者收到消息后,可按照如下方式获取消息的重试次数:
public class MyConcurrentlyMessageListener implements MessageListenerConcurrently {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for (MessageExt msg : msgs) {
System.out.println(msg.getReconsumeTimes());
}
doConsumeMessage(msgs);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
11、死信队列
RocketMQ中消息重试超过一定次数后(默认16次)就会被放到死信队列中,当然我们可以设置这个次数,但是在在消息队列RocketMQ 中,当你耗尽了重试次数,这种正常情况下无法被消费的消息称为死信消息(Dead-Letter Message),存储死信消息的特殊队列称为死信队列(Dead-Letter Queue)。可以在控制台Topic列表中看到“DLQ”相关的Topic,默认命名是:
- %RETRY%消费组名称(重试Topic)
- %DLQ%消费组名称(死信Topic)
- 死信队列也可以被订阅和消费,并且也会过期
可视化工具:rocketmq-console下载地址:https://github.com/apache/rocketmq-externals/archive/rocketmq-console-1.0.0.zip
也就是消耗尽了重试次数,就会进死信队列。
使用jdk8:
他是个maven项目,你下载下来用Idea打开,编译运行就行了。
# 编译打包
mvn clean package -DskipTests
# 运行工具
java -jar target/rocketmq-console-ng-1.0.0.jar
页面设置NameSrv地址即可。如果不生效,就直接修改项目的application.properties中的namesrv地址选项的值。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J3xM69oP-1663668378198)(rocketmq.assets/image-20220503211918885.png)]
11.1、死信特性
死信消息具有以下特性
- 不会再被消费者正常消费。
- 有效期与正常消息相同,均为 3 天,3 天后会被自动删除。因此,请在死信消息产生后的 3天内及时处理。
死信队列具有以下特性:
- 一个死信队列对应一个 Group ID, 而不是对应单个消费者实例。
- 如果一个 Group ID 未产生死信消息,消息队列 RocketMQ 不会为其创建相应的死信队列。
- 一个死信队列包含了对应 Group ID 产生的所有死信消息,不论该消息属于哪个 Topic。
11.2、查看死信信息
1.在控制台查询出现死信队列的主题信息
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XyubYTx0-1663668378199)(rocketmq.assets/image-20220503212053473.png)]
2、在消息界面根据主题查询死信消息
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wpxnWdzm-1663668378199)(rocketmq.assets/image-20220503212112075.png)]
3、选择重新发送消息
你设置个消费者的重试次数为1,容易看到死信消息。
consumer.setMaxReconsumeTimes(1);// 设置重试次数为1,也就是重试一次,失败就进死信队列
一条消息进入死信队列,意味着某些因素导致消费者无法正常消费该消息,因此,通常需要您对其进行特殊处理。排查可疑因素并解决问题后,可以在消息队列 RocketMQ 控制台重新发送该消息,让消费者重新消费一次。
或者你写个消费者订阅死信队列然后去做消费。
12、 延迟消息
定时消息(延迟队列)是指消息发送到broker后,不会立即被消费,等待特定时间投递给真正的topic。 broker有配置项messageDelayLevel,默认值为“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”,18个level。可以配置自定义messageDelayLevel。注意,messageDelayLevel是broker的属性,不属于某个topic。发消息时,设置delayLevel等级即可:msg.setDelayLevel(level)。level有以下三种情况:
- level == 0,消息为非延迟消息
- 1<=level<=maxLevel,消息延迟特定时间,例如level==1,延迟1s
- level > maxLevel,则level== maxLevel,例如level==20,延迟2h。也就是最多延迟两个小时。
定时消息会暂存在名为SCHEDULE_TOPIC_XXXX的topic中,并根据delayTimeLevel存入特定的queue,queueId = delayTimeLevel – 1(也就是0-17),即一个queue只存相同延迟的消息,保证具有相同发送延迟的消息能够顺序消费。broker会调度地消费SCHEDULE_TOPIC_XXXX,将消息写入真实的topic。
意思就是你有18个级别,对应18个队列,每个队列里面放的是相同延迟时间的消息,按照设置顺序进来的,这样也就是按照到期时间被消费的,因为每个队列里面都是相同级别的时间,按照顺序消费就是正常的。
需要注意的是,定时消息会在第一次写入和调度写入真实topic时都会计数,因此发送数量、tps都会变高。因为他其实中间中转了一次,多了一次调度,所以tps会多一些。以前是你每次写一个消息都直接进了消费者哪里的队列呗处理,现在是先进延迟队列,然后延迟队列到期了再进去正常队列让消费者消费,相当于倒手了一次。
**查看SCHEDULE_TOPIC_XXXX主题信息:**0-17总共十八个
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1mbN1pY7-1663668378199)(rocketmq.assets/image-20220503212513924.png)]
生产者:
package com.yx.delay;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.exception.RemotingException;
/**
* @author: YX
* @description: TODO
* @date: 2022-5-18 21:16
* @version: 1.0
*/
public class MyProducer {
public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException {
DefaultMQProducer producer = new DefaultMQProducer("producer_group_06");
producer.setNamesrvAddr("localhost:9876");
producer.start();
Message message = null;
for (int i = 0; i < 20; i++) {
// 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
message = new Message("topic_demo_06", ("hello meaasge - " + i).getBytes());
// 设置延迟级别,0表示不延迟,大于18的总是延迟2h
message.setDelayTimeLevel(i);
producer.send(message);
}
producer.shutdown();
}
}
消费者:
package com.yx.delay;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;
import java.util.List;
/**
* @author: YX
* @description: TODO
* @date: 2022-5-18 21:18
* @version: 1.0
*/
public class MyConsumer {
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_group_06");
consumer.setNamesrvAddr("localhost:9876");
consumer.subscribe("topic_demo_06", "*");
consumer.setMessageListener(new MessageListenerConcurrently(){
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.println(System.currentTimeMillis() / 1000);
for (MessageExt msg : msgs) {
System.out.println( msg.getTopic()
+ "\t" + msg.getQueueId()
+ "\t" + msg.getMsgId()
+ "\t" + msg.getDelayTimeLevel()
+ "\t" + new String(msg.getBody()) );
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
}
}
# 你能看到消费者的消费就是按照那个延迟时间输出的。
13、顺序消息(TODO)
顺序消息是指消息的消费顺序和产生顺序相同,在有些业务逻辑下,必须保证顺序。比如订单的生成、付款、发货,这3个消息必须按顺序处理才行。
顺序消息分为全局顺序消息和部分顺序消息:
-
全局顺序消息指某个Topic下的所有消息都要保证顺序;
-
部分顺序消息只要保证每一组消息被顺序消费即可,比如上面订单消息的例子,只要保证同一个订单ID的三个消息能按顺序消费即可。在多数的业务场景中实际上只需要局部有序就可以了。
RocketMQ在默认情况下不保证顺序,比如创建一个Topic,默认八个写队列,八个读队列。这时候一条消息可能被写入任意一个队列里(默认轮询写);在数据的读取过程中,可能有多个Consumer,每个Consumer也可能启动多个线程并行处理,所以消息被哪个Consumer消费,被消费的顺序和写入的顺序是否一致是不确定的。
要保证全局顺序消息,需要先把Topic的读写队列数设置为一,然后Producer和Consumer的并发设置也要是一。简单来说,为了保证整个Topic的全局消息有序,只能消除所有的并发处理,各部分都设置成单线程处理。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u65YEpF9-1663668378200)(rocketmq.assets/image-20220503212645399.png)]
原理如上图所示:
要保证部分消息有序,需要发送端和消费端配合处理。在发送端,要做到把同一业务ID的消息发送到同一个Message Queue;在消费过程中,要做到从同一个Message Queue读取的消息不被并发处理,这样才能达到部分有序。消费端通过使用MessageListenerOrderly类来解决单Message Queue的消息被并发处理的问题。
# Consumer使用MessageListenerOrderly的时候,下面四个Consumer的设置依旧可以使用:
1. setConsumeThreadMin:最小线程数,设置为1,单线程挨个处理
2. setConsumeThreadMax:最大也是1
3. setPullBatchSize:每次处理一个消息,可能你会问了,我就不能一次获取三个?反正你过来的也是有序的,下单支付这样,一般不要,因为下单和支付往往中间有时间间隔,不会挨着,总不能就等你后面的吧。但是我想想其实也可以,反正你发的有序,那我多拿几个挨着处理就行了。这个需要实验。
4. setConsumeMessageBatchMaxSize。
前两个参数设置Consumer的线程数;
PullBatchSize指的是一次从Broker的一个Message Queue获取消息的最大数量,默认值是32;
ConsumeMessageBatchMaxSize指的是这个Consumer的Executor(也就是调用MessageListener处理的地方)一次传入的消息数(Listmsgs这个链表的最大长度),默认值是1。
上述四个参数可以使用,说明MessageListenerOrderly并不是简单地禁止并发处理。在MessageListenerOrderly的实现中,为每个Consumer Queue加个锁,消费每个消息前,需要先获得这个消息对应的Consumer Queue所对应的锁,这样保证了同一时间,同一个Consumer Queue的消息不被并发消费,但不同Consumer Queue的消息可以并发处理。
部分有序:
顺序消息的生产和消费:
# 创建主题,8写8读
[root@node1 ~]# mqadmin updateTopic -b node1:10911 -n localhost:9876 -r 8 -t tp_demo_07 -w 8
# 删除主题的操作:
[root@node1 ~]# mqadmin deleteTopic -c DefaultCluster deleteTopic -n localhost:9876 -t tp_demo_07
# 主题描述
[root@node1 ~]# mqadmin topicStatus -n localhost:9876 -t tp_demo_07
OrderProducer.java
OrderConsumer.java
全局有序:
顺序消息的生产和消费:
# 创建主题,8写8读
[root@node1 ~]# mqadmin updateTopic -b node1:10911 -n localhost:9876 -r 1 -t tp_demo_07_01 -w 1
# 删除主题的操作:
[root@node1 ~]# mqadmin deleteTopic -c DefaultCluster deleteTopic -n localhost:9876 -t tp_demo_07_01
# 主题描述
[root@node1 ~]# mqadmin topicStatus -n localhost:9876 -t tp_demo_07_01
GlobalOrderProduer.java
GlobalOrderConsumer.java
14、事务消息
RocketMQ的事务消息,是指发送消息事件和其他事件需要同时成功或同时失败。比如银行转账,A银行的某账户要转一万元到B银行的某账户。A银行发送“B银行账户增加一万元”这个消息,要和“从A银行账户扣除一万元”这个操作同时成功或者同时失败。
RocketMQ采用两阶段提交的方式实现事务消息,TransactionMQProducer处理上面情况的流程是,先发一个“准备从B银行账户增加一万元”的消息,发送成功后做从A银行账户扣除一万元的操作,根据操作结果是否成功,确定之前的“准备从B银行账户增加一万元”的消息是做commit还是rollback,具体流程如下:
本地事务就是你的业务操作,你先做你的业务,然后发消息。
# 需求:转账之后先扣款,扣完了发一个mq消息,这里要保证事务
1)发送方向RocketMQ发送“待确认”消息。也就是准备阶段。
2)RocketMQ将收到的“待确认”消息持久化成功后,向发送方回复消息已经发送成功,此时第一阶段消息发送完成。
3)发送方开始执行本地事件逻辑。(这里是本地事务,也就是你的mysql扣款之类的)
4)发送方根据本地事务(mysql扣款哪里)执行结果向RocketMQ发送二次确认(Commit或是Rollback)消息,RocketMQ收到Commit状态则将第一阶段消息标记为可投递,订阅方将能够收到该消息;收到Rollback状态则删除第一阶段的消息,订阅方接收不到该消息。
5)如果出现异常情况,步骤4)提交的二次确认最终未到达RocketMQ,服务器在经过固定时间段后将对“待确认”消息发起回查请求。
6)发送方收到消息回查请求后(如果发送一阶段消息的Producer不能工作,回查请求将被发送到和Producer在同一个Group里的其他Producer),通过检查对应消息的本地事件执行结果返回Commit或Roolback状态。
7)RocketMQ收到回查请求后,按照步骤4)的逻辑处理。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-adrOH2jW-1663668378200)(rocketmq.assets/image-20220503213350335.png)]
上面的逻辑似乎很好地实现了事务消息功能,它也是RocketMQ之前的版本实现事务消息的逻辑。
但是因为RocketMQ依赖将数据顺序写到磁盘这个特征来提高性能,步骤4)却需要更改第一阶段消息的状态,这样会造成磁盘Catch的脏页过多,降低系统的性能。所以RocketMQ在4.x的版本中将这部分功能去除。系统中的一些上层Class都还在,用户可以根据实际需求实现自己的事务功能。
客户端有三个类来支持用户实现事务消息,第一个类是LocalTransaction-Executer,用来实例化步骤3)的逻辑,根据情况返回LocalTransactionState.ROLLBACK_MESSAGE或者LocalTransactionState.COMMIT_MESSAGE状态。第二个类是TransactionMQProducer,它的用法和
DefaultMQProducer类似,要通过它启动一个Producer并发消息,但是比DefaultMQProducer多设置本地事务处理函数和回查状态函数。第三个类是TransactionCheckListener,实现步骤5)中MQ服务器的回查请求,返回LocalTransactionState.ROLLBACK_MESSAGE或者或者LocalTransactionState.COMMIT_MESSAGE
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0pjT8AkP-1663668378201)(rocketmq.assets/image-20220503213432130.png)]
14.1、 RocketMQ事务消息流程概要
上图说明了事务消息的大致方案,其中分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程。
# 1.事务消息发送及提交:
(1) 发送消息(half消息)。
(2) 服务端响应消息写入结果。
(3) 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)。
(4) 根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)
# 2.补偿流程:
(1) 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
(2) Producer收到回查消息,检查回查消息对应的本地事务的状态
(3) 根据本地事务状态,重新Commit或者Rollback其中,补偿阶段用于解决消息Commit或者Rollback发生超时或者失败的情况。
14.2、 RocketMQ事务消息设计
1.事务消息在一阶段对用户不可见
在RocketMQ事务消息的主要流程中,一阶段的消息如何对用户不可见。其中,事务消息相对普通消息最大的特点就是一阶段发送的消息对用户是不可见的。那么,如何做到写入消息但是对用户不可见呢?RocketMQ事务消息的做法是:如果消息是half消息,将备份原消息的主题与消息消费队列,然后改变主题为RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费half类型的消息。然后二阶段会显示执行提交或者回滚half消息(逻辑删除)。当然,为了防止二阶段操作失败,RocketMQ会开启一个定时任务,从Topic为RMQ_SYS_TRANS_HALF_TOPIC中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。在RocketMQ中,消息在服务端的存储结构如下,每条消息都会有对应的索引信息,Consumer通 过ConsumeQueue这个二级索引来读取消息实体内容,其流程如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8skrVqc8-1663668378201)(rocketmq.assets/image-20220503213713668.png)]
RocketMQ的具体实现策略是:写入的如果事务消息,对消息的Topic和Queue等属性进行替换,同时将原来的Topic和Queue信息存储到消息的属性中,正因为消息主题被替换,故消息并不会转发到该原主题的消息消费队列,消费者无法感知消息的存在,不会消费。其实改变消息主题是RocketMQ的常用“套路”,回想一下延时消息的实现机制。RMQ_SYS_TRANS_HALF_TOPIC。
2、Commit和Rollback操作以及Op消息的引入
在完成一阶段写入一条对用户不可见的消息后,二阶段如果是Commit操作,则需要让消息对用户可见;如果是Rollback则需要撤销一阶段的消息。先说Rollback的情况。对于Rollback,本身一阶段的消息对用户是不可见的,其实不需要真正撤销消息(实际上RocketMQ也无法去真正的删除一条消息,因为是顺序写文件的)。但是区别于这条消息没有确定状态(Pending状态,事务悬而未决),需要一个操作来标识这条消息的最终状态。RocketMQ事务消息方案中引入了Op消息的概念,用Op消息标识事务消息已经确定的状态(Commit或者Rollback)。如果一条事务消息没有对应的Op消息,说明这个事务的状态还无法确定(可能是二阶段失败了)。引入Op消息后,事务消息无论是Commit或者Rollback都会记录一个Op操作。Commit相对于Rollback只是在写入Op消息前创建Half消息的索引。
3、Op消息的存储和对应关系
RocketMQ将Op消息写入到全局一个特定的Topic中通过源码中的方法—TransactionalMessageUtil.buildOpTopic();这个Topic是一个内部的Topic(像Half消息的Topic一样),不会被用户消费。Op消息的内容为对应的Half消息的存储的Offset,这样通过Op消息能索引到Half消息进行后续的回查操作。
4、Half消息的索引构建
在执行二阶段Commit操作时,需要构建出Half消息的索引。一阶段的Half消息由于是写到一个特殊的Topic,所以二阶段构建索引时需要读取出Half消息,并将Topic和Queue替换成真正的目标的Topic和Queue,之后通过一次普通消息的写入操作来生成一条对用户可见的消息。所以RocketMQ事务消息二阶段其实是利用了一阶段存储的消息的内容,在二阶段时恢复出一条完整的普通消息,然后走一遍消息写入流程。
5、如何处理二阶段失败的消息?
如果在RocketMQ事务消息的二阶段过程中失败了,例如在做Commit操作时,出现网络问题导致Commit失败,那么需要通过一定的策略使这条消息最终被Commit。RocketMQ采用了一种补偿机制,称为“回查”。Broker端对未确定状态的消息发起回查,将消息发送到对应的Producer端(同一个Group的Producer),由Producer根据消息来检查本地事务的状态,进而执行Commit或者Rollback。Broker端通过对比Half消息和Op消息进行事务消息的回查并且推进CheckPoint(记录那些事务消息的状态是确定的)。
值得注意的是,rocketmq并不会无休止的的信息事务状态回查,默认回查15次,如果15次回查还是无法得知事务状态,rocketmq默认回滚该消息。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kK3QDwVO-1663668378202)(rocketmq.assets/image-20220503213907420.png)]
事务消息:
TxProducer.java
TxConsumer.java
15、消息查询
区别于消息消费:类似于买东西,先尝后买,尝就是消息查询,买就是消息的消费
RocketMQ支持按照下面两种维度(“按照Message Id查询消息”、“按照Message Key查询消息”)进行消息查询。
15.1、按照MessageId查询消息
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SnL5a9zR-1663668378202)(rocketmq.assets/image-20220503214058964.png)]
MsgId 总共 16 字节,包含消息存储主机地址(ip/port),消息在提交日志里面的偏移量 Commit Log offset。从 MsgId 中解析出 Broker 的地址和 Commit Log 的偏移地址,然后按照存储格式所在位置将消息 buffer 解析成一个完整的消息。
在RocketMQ中具体做法是:Client端从MessageId中解析出Broker的地址(IP地址和端口)和Commit Log的偏移地址后封装成一个RPC请求后,通过Remoting通信层发送(业务请求码:VIEW_MESSAGE_BY_ID)。Broker使用QueryMessageProcessor,使用请求中的 commitLog offset和 size 去 commitLog 中找到真正的记录并解析成一个完整的消息返回。
15.2、按照Message Key查询消息
“按照Message Key查询消息”,主要是基于RocketMQ的IndexFile索引文件来实现的。RocketMQ的索引文件逻辑结构,类似JDK中HashMap的实现。索引文件的具体结构如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eAg2K8vL-1663668378202)(rocketmq.assets/image-20220503214205077.png)]
1.根据查询的 key 的 hashcode%slotNum 得到具体的槽的位置(slotNum 是一个索引文件里面包含的最大槽的数目, 例如图中所示 slotNum=5000000)。
2.根据 slotValue(slot 位置对应的值)查找到索引项列表的最后一项(倒序排列,slotValue 总是指向最新的一个索引项)。
3.遍历索引项列表返回查询时间范围内的结果集(默认一次最大返回的 32 条记录)
4.Hash 冲突;
第一种,key 的 hash 值不同但模数相同,此时查询的时候会再比较一次 key 的 hash 值(每个索引项保存了 key 的 hash 值),过滤掉 hash 值不相等的项。
第二种,hash 值相等但 key 不等, 出于性能的考虑冲突的检测放到客户端处理(key 的原始值是存储在消息文件中的,避免对数据文件的解析), 客户端比较一次消息体的 key 是否相同。
5.存储;为了节省空间索引项中存储的时间是时间差值(存储时间-开始时间,开始时间存储在索引文件头中), 整个索引文件是定长的,结构也是固定的。
API的使用:
16、消息优先级
有些场景,需要应用程序处理几种类型的消息,不同消息的优先级不同。RocketMQ是个先入先出的队列,不支持消息级别或者Topic级别的优先级。业务中简单的优先级需求,可以通过间接的方式解决,下面列举三种优先级相关需求的具体处理方法。
第一种
多个不同的消息类型使用同一个topic时,由于某一个种消息流量非常大,导致其他类型的消息无法及时消费,造成不公平,所以把流量大的类型消息在一个单独的 Topic,其他类型消息在另外一个Topic,应用程序创建两个 Consumer,分别订阅不同的 Topic,这样就可以了。
第二种
情况和第一种情况类似,但是不用创建大量的 Topic。举个实际应用场景: 一个订单处理系统,接收从 100家快递门店过来的请求,把这些请求通过 Producer 写入RocketMQ;订单处理程序通过Consumer 从队列里读取消 息并处理,每天最多处理 1 万单 。 如果这 100 个快递门店中某几个门店订单量 大增,比如门店一接了个大客户,一个上午就发出 2万单消息请求,这样其他 的 99 家门店可能被迫等待门店一的 2 万单处理完,也就是两天后订单才能被处 理,显然很不公平 。
这时可以创建 一 个 Topic, 设置 Topic 的 MessageQueue 数 量 超过 100 个,Producer根据订单的门店号,把每个门店的订单写人 一 个 MessageQueue。 DefaultMQPushConsumer默认是采用循环的方式逐个读取一个 Topic 的所有 MessageQueue,这样如果某家门店订单量大增,这家门店对应的 MessageQueue 消息数增多,等待时间增长,但不会造成其他家门店等待时间增长(因为还是轮流消费的)。DefaultMQPushConsumer 默认的 pullBatchSize 是 32,也就是每次从某个 MessageQueue 读取消息的时候,最多可以读 32 个 。 在上面的场景中,为了更 加公平,可以把 pullBatchSize 设置成1。
第三种
强制优先级TypeA、 TypeB、 TypeC 三类消息 。 TypeA 处于第一优先级,要确保只要有TypeA消息,必须优先处理; TypeB处于第二优先 级; TypeC 处于第三优先级 。 对这种要求,或者逻辑更复杂的要求,就要用 户自己编码实现优先级控制,如果上述的 三 类消息在一个 Topic 里,可以使 用 PullConsumer,自主控制 MessageQueue 的遍历,以及消息的读取;如果上述三类消息在三个 Topic下,需要启动三个Consumer, 实现逻辑控制三个 Consumer 的消费 。
17、底层网络通信 - Netty高性能之道
# RocketMQ底层通信的实现是在Remoting模块里,因为借助了Netty而没有重复造轮子,RocketMQ的通信部分没有很多的代码,就是用Netty实现了一个自定义协议的客户端/服务器程序。
1. 自定义ByteBuf可以从底层解决ByteBuffer的一些问题,并且通过“内存池”的设计来提升性能
2. Reactor主从多线程模型
3. 充分利用了零拷贝,CAS/volatite高效并发编程特性
4. 无锁串行化设计
5. 管道责任链的编程模型
6. 高性能序列化框架的支持
7. 灵活配置TCP协议参数
RocketMQ消息队列集群主要包括NameServer、Broker(Master/Slave)、Producer、Consumer4
个角色,基本通讯流程如下:
(1) Broker启动后需要完成一次将自己注册至NameServer的操作;随后每隔30s时间定时向NameServer上报Topic路由信息。
(2) 消息生产者Producer作为客户端发送消息时候,需要根据消息的Topic从本地缓存的TopicPublishInfoTable获取路由信息。如果没有则更新路由信息会从NameServer上重新拉取,同时Producer会默认每隔30s向NameServer拉取一次路由信息。
(3) 消息生产者Producer根据2)中获取的路由信息选择一个队列(MessageQueue)进行消息发送;Broker作为消息的接收者接收消息并落盘存储。
(4) 消息消费者Consumer根据2)中获取的路由信息,并再完成客户端的负载均衡后,选择其中的某一个或者某几个消息队列来拉取消息并进行消费。从上面(1~3)中可以看出在消息生产者, Broker和NameServer之间都会发生通信(这里只说了MQ的部分通信),因此如何设计一个良好的网络通信模块在MQ中至关重要,它将决定RocketMQ集群整体的消息传输能力与最终的性能。
rocketmq-remoting 模块是 RocketMQ消息队列中负责网络通信的模块,它几乎被其他所有需要网络通信的模块(诸如rocketmq-client、rocketmq-broker、rocketmq-namesrv)所依赖和引用。为了实现客户端与服务器之间高效的数据请求与接收,RocketMQ消息队列自定义了通信协议并在Netty的基础之上扩展了通信模块。
RocketMQ中惯用的套路:
请求报文和响应都使用RemotingCommand,然后在Processor处理器中根据RequestCode请求码来匹配对应的处理方法。
处理器通常继承至NettyRequestProcessor,使用前需要先注册才行,注册方式remotingServer.registerDefaultProcessor。
网络通信核心的东西无非是:
线程模型
私有协议定义
编解码器
序列化/反序列化
…
既然是基于Netty的网络通信,当然少不了一堆自定义实现的Handler,
例如继承至:SimpleChannelInboundHandler ChannelDuplexHandler
17.1、Remoting通信类结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-deoTmNDg-1663668378203)(rocketmq.assets/image-20220503214751198.png)]
17.1.1、协议设计与编解码
在Client和Server之间完成一次消息发送时,需要对发送的消息进行一个协议约定,因此就有必要自定义RocketMQ的消息协议。同时,为了高效地在网络中传输消息和对收到的消息读取,就需要对消息进行编解码。在RocketMQ中,RemotingCommand这个类在消息传输过程中对所有数据内容的封装,不但包含了所有的数据结构,还包含了编码解码操作。
Header字段 | 类型 | Request说明 | Response说明 |
---|---|---|---|
code | int | 请求操作码,应答方根据不同的请求码进行不同的业务处理 | 应答响应码。0表示成功,非0则表示各种错误 |
language | LanguageCode | 请求方实现的语言 | 应答方实现的语言 |
version | int | 请求方程序的版本 | 应答方程序的版本 |
opaque | int | 相当于requestId,在同一个连接上的不同请求标识码,与响应消息中 | |
的相对应 | 应答不做修改直接返回 | ||
flag | int | 区分是普通RPC还是onewayRPC得标志 | 区分是普通RPC还 是onewayRPC得标志 |
remark | String | 传输自定义文本信息 | 传输自定义文本信息 |
extFields | HashMap<String,String> | 请求自定义扩展信息 | 响应自定义扩展信息 |
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h9VDX4im-1663668378203)(rocketmq.assets/image-20220503215342883.png)]
# 可见传输内容主要可以分为以下4部分:
(1) 消息长度:总长度,四个字节存储,占用一个int类型;
(2) 序列化类型&消息头长度:同样占用一个int类型,第一个字节表示序列化类型,后面三个字节表示消息头长度;
(3) 消息头数据:经过序列化后的消息头数据;
(4) 消息主体数据:消息主体的二进制字节数据内容;
17.1.2、消息的通信方式和流程
在RocketMQ消息队列中支持通信的方式主要有同步(sync)、异步(async)、单向(oneway) 三种。其中“单向”通信模式相对简单,一般用在发送心跳包场景下,无需关注其Response。这里,主要介绍RocketMQ的异步通信流程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a5m8XyZm-1663668378204)(rocketmq.assets/image-20220503215431297.png)]
17.1.3、Reactor主从多线程模型
RocketMQ的RPC通信采用Netty组件作为底层通信库,同样也遵循了Reactor多线程模型,同时又在这之上做了一些扩展和优化。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K9nSpdT1-1663668378204)(rocketmq.assets/image-20220503215507232.png)]
上面的框图中可以大致了解RocketMQ中NettyRemotingServer的Reactor 多线程模型。
一个 Reactor 主线程(eventLoopGroupBoss,即为上面的1)负责监听 TCP网络连接请求,建立好连接,创建SocketChannel,并注册到selector上。
RocketMQ的源码中会自动根据OS的类型选择NIO和Epoll,也可以通过参数配置),然后监听真正的网络数据。
拿到网络数据后,再丢给Worker线程池(eventLoopGroupSelector,即为上面的“N”,源码中默认设置为3),在真正执行业务逻辑之前需要进行SSL验证、编解码、空闲检查、网络连接管理,这些工作交给defaultEventExecutorGroup(即为上面的“M1”,源码中默认设置为8)去做。
处理业务操作放在业务线程池中执行,根据 RomotingCommand 的业务请求码code去processorTable这个本地缓存变量中找到对应的 processor,然后封装成task任务后,提交给对应的业务processor处理线程池来执行(sendMessageExecutor,以发送消息为例,即为上面的 “M2”)。
从入口到业务逻辑的几个步骤中线程池一直再增加,这跟每一步逻辑复杂性相关,越复杂,需要的并发通道越宽。
线程数 | 线程名 | 线程具体说明 |
---|---|---|
1 | NettyBoss_%d | Reactor 主线程 |
N | NettyServerEPOLLSelector*%d*%d | Reactor 线程池 |
M1 | NettyServerCodecThread_%d | Worker线程池 |
M2 | RemotingExecutorThread_%d |
18、限流
RocketMQ消费端中我们可以:
-
设置最大消费线程数
-
每次拉取消息条数等
同时:
-
PushConsumer会判断获取但还未处理的消息个数、消息总大小、Offset的跨度,
-
任何一个值超过设定的大小就隔一段时间再拉取消息,从而达到流量控制的目的。
在 Apache RocketMQ 中,当消费者去消费消息的时候,无论是通过 pull 的方式还是 push 的方式,都可能会出现大批量的消息突刺。如果此时要处理所有消息,很可能会导致系统负载过高,影响稳定性。但其实可能后面几秒之内都没有消息投递,若直接把多余的消息丢掉则没有充分利用系统处理消息的能力。我们希望可以把消息突刺均摊到一段时间内,让系统负载保持在消息处理水位之下的同时尽可能地处理更多消息,从而起到“削峰填谷”的效果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D1j1zXBr-1663668378204)(rocketmq.assets/image-20220503215806212.png)]
上图中红色的部分代表超出消息处理能力的部分。我们可以看到消息突刺往往都是瞬时的、不规律的,其后一段时间系统往往都会有空闲资源。我们希望把红色的那部分消息平摊到后面空闲时去处理,这样既可以保证系统负载处在一个稳定的水位,又可以尽可能地处理更多消息。
18.1、Sentinel介绍
Sentinel 是阿里中间件团队开源的,面向分布式服务架构的轻量级流量控制产品,主要以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度来帮助用户保护服务的稳定性。
18.2、Sentinel原理
Sentinel 专门为这种场景提供了匀速器的特性,可以把突然到来的大量请求以匀速的形式均摊,以固定的间隔时间让请求通过,以稳定的速度逐步处理这些请求,起到“削峰填谷”的效果,从而避免流量突刺造成系统负载过高。同时堆积的请求将会排队,逐步进行处理;当请求排队预计超过最大超时时长的时候则直接拒绝,而不是拒绝全部请求。
比如在 RocketMQ 的场景下配置了匀速模式下请求 QPS 为 5,则会每 200 ms 处理一条消息,多余的处理任务将排队;同时设置了超时时间为 5 s,预计排队时长超过 5s 的处理任务将会直接被拒绝。示意图如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rzoZEO3P-1663668378205)(rocketmq.assets/image-20220503220005384.png)]
RocketMQ 用户可以根据不同的 group 和不同的 topic 分别设置限流规则,限流控制模式设置为匀速器模式(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER),比如:
参考:
Sentinel 为 RocketMQ 保驾护航 · alibaba/Sentinel Wiki · GitHub
三、高级特性的使用
1、生产者
1.1、tag的使用
一个应用尽可能用一个Topic,而消息子类型则可以用tags来标识。tags可以由应用自由设置,只有生产者在发送消息设置了tags,消费方在订阅消息时才可以利用tags通过broker做消息过滤:message.setTags(“TagA”)。
1.2、Keys的使用
每个消息在业务层面的唯一标识码要设置到keys字段,方便将来定位消息丢失问题。服务器会为每个消息创建索引(哈希索引),应用可以通过topic、key来查询这条消息内容,以及消息被谁消费。由于是哈希索引,请务必保证key尽可能唯一,这样可以避免潜在的哈希冲突。
// 订单Id
String orderId = "20034568923546";
message.setKeys(orderId);
1.3、日志的打印
消息发送成功或者失败要打印消息日志,务必要打印SendResult和key字段(后续好追踪消息)。send消息方法只要不抛异常,就代表发送成功。发送成功会有多个状态,在sendResult里定义。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IhHiyAGC-1663668378206)(rocketmq.assets/image-20220917153720372.png)]
以下对每个状态进行说明:
# SEND_OK
消息发送成功。要注意的是消息发送成功也不意味着它是可靠的。要确保不会丢失任何消息,还应启用同步Master服务器或同步刷盘,即SYNC_MASTER或SYNC_FLUSH。
# FLUSH_DISK_TIMEOUT
消息发送成功但是服务器刷盘超时。此时消息已经进入服务器队列(内存),只有服务器宕机,消息才会丢失。消息存储配置参数中可以设置刷盘方式和同步刷盘时间长度。如果Broker服务器设置了刷盘方式为同步刷盘,即FlushDiskType=SYNC_FLUSH(默认为异步刷盘方式),当Broker服务器未在同步刷盘时间内(默认为5s)完成刷盘,则将返回该状态——刷盘超时。
# FLUSH_SLAVE_TIMEOUT
消息发送成功,但是服务器同步到Slave时超时。此时消息已经进入服务器队列,只有服务器宕机,消息才会丢失。如果Broker服务器的角色是同步Master,即#SYNC_MASTER(默认是异步Master即ASYNC_MASTER),并且从Broker服务器未在同步刷盘时间(默认为5秒)内完成与主服务器的同步,则将返回该状态——数据同步到Slave服务器超时。
# SLAVE_NOT_AVAILABLE
消息发送成功,但是此时Slave不可用。如果Broker服务器的角色是同步Master,即SYNC_MASTER(默认是异步Master服务器即ASYNC_MASTER),但没有配置slave Broker服务器,则将返回该状态——无Slave服务器可用。
1.4、消息发送失败处理方式
Producer的send方法本身支持内部重试,重试逻辑如下:
至多重试2次(同步发送为2次,异步发送为0次)。也就是说异步是不重试的,因为不知道你啥时候回调,这个重试不好控制。
如果发送失败,则轮转到下一个Broker。这个方法的总耗时时间不超过sendMsgTimeout设置的值,默认10s。不会一直在一个broker上重试的。
如果本身向broker发送消息产生超时异常,就不会再重试。
以上策略也是在一定程度上保证了消息可以发送成功。如果业务对消息可靠性要求比较高,建议应用增加相应的重试逻辑:比如调用send同步方法发送失败时,则尝试将消息存储到db,然后由后台线程定时重试,确保消息一定到达Broker。
上述db重试方式为什么没有集成到MQ客户端内部做,而是要求应用自己去完成,主要基于以下几点考虑:
-
MQ的客户端设计为无状态模式,方便任意的水平扩展,且对机器资源的消耗仅仅是cpu、内存、网络。
-
如果MQ客户端内部集成一个KV存储模块,那么数据只有同步落盘才能较可靠,而同步落盘本身性能开销较大,所以通常会采用异步落盘,又由于应用关闭过程不受MQ运维人员控制,可能经常会发生 kill -9 这样暴力方式关闭,造成数据没有及时落盘而丢失。
-
Producer所在机器的可靠性较低,一般为虚拟机,不适合存储重要数据。综上,建议重试过程交由应用来控制。
1.5、选择oneway形式发送
通常消息的发送是这样一个过程:
- 客户端发送请求到服务器
- 服务器处理请求
- 服务器向客户端返回应答
所以,一次消息发送的耗时时间是上述三个步骤的总和,而某些场景要求耗时非常短,但是对可靠性要求并不高,例如日志收集类应用,此类应用可以采用oneway形式调用,oneway形式只发送请求不等待应答,而发送请求在客户端实现层面仅仅是一个操作系统系统调用的开销,即将数据写入客户端的socket缓冲区,此过程耗时通常在微秒级。
2、消费者
2.1、消费过程幂等
RocketMQ无法避免消息重复(Exactly-Once),所以如果业务对消费重复非常敏感,务必要在业务层面进行去重处理。
可以借助关系数据库进行去重。首先需要确定消息的唯一键,可以是msgId,也可以是消息内容中的唯一标识字段,例如订单Id等。
在消费之前判断唯一键是否在关系数据库中存在。如果不存在则插入,并消费,否则跳过。(实际过程要考虑原子性问题,判断是否存在可以尝试插入,如果报主键冲突,则插入失败,直接跳过)。
msgId一定是全局唯一标识符,但是实际使用中,可能会存在相同的消息有两个不同msgId的情况(消费者主动重发、因客户端重投机制导致的重复等),这种情况就需要使业务字段进行重复消费。
2.2、消费速度慢的处理方式
2.2.1、提高消费并行度
绝大部分消息消费行为都属于 IO 密集型,即可能是操作数据库,或者调用 RPC,这类消费行为的消费速度在于后端数据库或者外系统的吞吐量。
通过增加消费并行度,可以提高总的消费吞吐量,但是并行度增加到一定程度,反而会下降。
所以,应用必须要设置合理的并行度。 如下有几种修改消费并行度的方法:
- 同一个 ConsumerGroup 下,通过增加 Consumer 实例数量来提高并行度(需要注意的是超过订阅队列数的 Consumer 实例无效)。可以通过加机器,或者在已有机器启动多个进程的方式。
- 提高单个 Consumer 的消费并行线程,通过修改参数 consumeThreadMin、consumeThreadMax实现。
- 丢弃部分不重要的消息
2.2.2、批量方式消费
某些业务流程如果支持批量方式消费,则可以很大程度上提高消费吞吐量。
例如订单扣款类应用,一次处理一个订单耗时 1 s,一次处理 10 个订单可能也只耗时 2 s,这样即可大幅度提高消费的吞吐量,通过设置 consumer的 consumeMessageBatchMaxSize 返个参数,默认是 1,即一次只消费一条消息,例如设置为 N,那么每次消费的消息数小于等于 N。
2.2.3、跳过非重要消息
发生消息堆积时,如果消费速度一直追不上发送速度,如果业务对数据要求不高的话,可以选择丢弃不重要的消息。
例如,当某个队列的消息数堆积到100000条以上,则尝试丢弃部分或全部消息,这样就可以快速追上发送消息的速度。示例代码如下:
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
long offset = msgs.get(0).getQueueOffset();
String maxOffset = msgs.get(0).getProperty(Message.PROPERTY_MAX_OFFSET);
long diff = Long.parseLong(maxOffset) - offset;
if (diff > 100000) {
// TODO 消息堆积情况的特殊处理 ,这里直接返回,不做任何处理,等于丢弃该消息。
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// TODO 正常消费过程
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
2.3、优化每条消息消费过程
举例如下,某条消息的消费过程如下:
- 根据消息从 DB 查询【数据 1】
- 根据消息从 DB 查询【数据 2】
- 复杂的业务计算
- 向 DB 插入【数据 3】
- 向 DB 插入【数据 4】
这条消息的消费过程中有4次与 DB的 交互,如果按照每次 5ms 计算,那么总共耗时 20ms,假设业务计算耗时 5ms,那么总过耗时 25ms,所以如果能把 4 次 DB 交互优化为 2 次,那么总耗时就可以优化到 15ms,即总体性能提高了 40%。所以应用如果对时延敏感的话,可以把DB部署在SSD硬盘,相比于SCSI磁盘,前者的RT会小很多。
2.4、消费打印日志
如果消息量较少,建议在消费入口方法打印消息,消费耗时等,方便后续排查问题。
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { log.info("RECEIVE_MSG_BEGIN: " + msgs.toString());
// TODO 正常消费过程
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }
如果能打印每条消息消费耗时,那么在排查消费慢等线上问题时,会更方便。
2.5、其他消费建议
# 1 关于消费者和订阅
第一件需要注意的事情是,不同的消费组可以独立的消费一些 topic,并且每个消费组都有自己的消费偏移量。确保同一组内的每个消费者订阅信息保持一致。
# 2 关于有序消息
消费者将锁定每个消息队列,以确保他们被逐个消费,虽然这将会导致性能下降,但是当你关心消息顺序的时候会很有用。我们不建议抛出异常,你可以返回
ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT 作为替代。
# 3 关于并发消费
顾名思义,消费者将并发消费这些消息,建议你使用它来获得良好性能,我们不建议抛出异常,你可以返回 ConsumeConcurrentlyStatus.RECONSUME_LATER 作为替代。
# 4 关于消费状态Consume Status
对于并发的消费监听器,你可以返回 RECONSUME_LATER 来通知消费者现在不能消费这条消息,并且希望可以稍后重新消费它。然后,你可以继续消费其他消息。对于有序的消息监听器,因为你关心它的顺序,所以不能跳过消息,但是你可以返回SUSPEND_CURRENT_QUEUE_A_MOMENT 告诉消费者等待片刻。
# 5 关于Blocking
不建议阻塞监听器,因为它会阻塞线程池,并最终可能会终止消费进程
# 6 关于线程数设置
消费者使用 ThreadPoolExecutor 在内部对消息进行消费,所以你可以通过设置setConsumeThreadMin 或 setConsumeThreadMax 来改变它。
# 7 关于消费位点
当建立一个新的消费组时,需要决定是否需要消费已经存在于 Broker 中的历史消息。
CONSUME_FROM_LAST_OFFSET 将会忽略历史消息,并消费之后生成的任何消息。
CONSUME_FROM_FIRST_OFFSET 将会消费每个存在于 Broker 中的信息。
也可以使用 CONSUME_FROM_TIMESTAMP 来消费在指定时间戳后产生的消息。
consumeThreadMax实现。
- 丢弃部分不重要的消息
2.2.2、批量方式消费
某些业务流程如果支持批量方式消费,则可以很大程度上提高消费吞吐量。
例如订单扣款类应用,一次处理一个订单耗时 1 s,一次处理 10 个订单可能也只耗时 2 s,这样即可大幅度提高消费的吞吐量,通过设置 consumer的 consumeMessageBatchMaxSize 返个参数,默认是 1,即一次只消费一条消息,例如设置为 N,那么每次消费的消息数小于等于 N。
2.2.3、跳过非重要消息
发生消息堆积时,如果消费速度一直追不上发送速度,如果业务对数据要求不高的话,可以选择丢弃不重要的消息。
例如,当某个队列的消息数堆积到100000条以上,则尝试丢弃部分或全部消息,这样就可以快速追上发送消息的速度。示例代码如下:
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
long offset = msgs.get(0).getQueueOffset();
String maxOffset = msgs.get(0).getProperty(Message.PROPERTY_MAX_OFFSET);
long diff = Long.parseLong(maxOffset) - offset;
if (diff > 100000) {
// TODO 消息堆积情况的特殊处理 ,这里直接返回,不做任何处理,等于丢弃该消息。
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// TODO 正常消费过程
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
2.3、优化每条消息消费过程
举例如下,某条消息的消费过程如下:
- 根据消息从 DB 查询【数据 1】
- 根据消息从 DB 查询【数据 2】
- 复杂的业务计算
- 向 DB 插入【数据 3】
- 向 DB 插入【数据 4】
这条消息的消费过程中有4次与 DB的 交互,如果按照每次 5ms 计算,那么总共耗时 20ms,假设业务计算耗时 5ms,那么总过耗时 25ms,所以如果能把 4 次 DB 交互优化为 2 次,那么总耗时就可以优化到 15ms,即总体性能提高了 40%。所以应用如果对时延敏感的话,可以把DB部署在SSD硬盘,相比于SCSI磁盘,前者的RT会小很多。
2.4、消费打印日志
如果消息量较少,建议在消费入口方法打印消息,消费耗时等,方便后续排查问题。
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { log.info("RECEIVE_MSG_BEGIN: " + msgs.toString());
// TODO 正常消费过程
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }
如果能打印每条消息消费耗时,那么在排查消费慢等线上问题时,会更方便。
2.5、其他消费建议
# 1 关于消费者和订阅
第一件需要注意的事情是,不同的消费组可以独立的消费一些 topic,并且每个消费组都有自己的消费偏移量。确保同一组内的每个消费者订阅信息保持一致。
# 2 关于有序消息
消费者将锁定每个消息队列,以确保他们被逐个消费,虽然这将会导致性能下降,但是当你关心消息顺序的时候会很有用。我们不建议抛出异常,你可以返回
ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT 作为替代。
# 3 关于并发消费
顾名思义,消费者将并发消费这些消息,建议你使用它来获得良好性能,我们不建议抛出异常,你可以返回 ConsumeConcurrentlyStatus.RECONSUME_LATER 作为替代。
# 4 关于消费状态Consume Status
对于并发的消费监听器,你可以返回 RECONSUME_LATER 来通知消费者现在不能消费这条消息,并且希望可以稍后重新消费它。然后,你可以继续消费其他消息。对于有序的消息监听器,因为你关心它的顺序,所以不能跳过消息,但是你可以返回SUSPEND_CURRENT_QUEUE_A_MOMENT 告诉消费者等待片刻。
# 5 关于Blocking
不建议阻塞监听器,因为它会阻塞线程池,并最终可能会终止消费进程
# 6 关于线程数设置
消费者使用 ThreadPoolExecutor 在内部对消息进行消费,所以你可以通过设置setConsumeThreadMin 或 setConsumeThreadMax 来改变它。
# 7 关于消费位点
当建立一个新的消费组时,需要决定是否需要消费已经存在于 Broker 中的历史消息。
CONSUME_FROM_LAST_OFFSET 将会忽略历史消息,并消费之后生成的任何消息。
CONSUME_FROM_FIRST_OFFSET 将会消费每个存在于 Broker 中的信息。
也可以使用 CONSUME_FROM_TIMESTAMP 来消费在指定时间戳后产生的消息。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v8XR2O98-1663668378206)(rocketmq.assets/image-20220917160130854.png)]