目录
消息队列功能
解耦
异步
流量削峰
消息分发
消息分发如下,各个子系统(消费者组)有各自的Offset(消费偏移量、消费进度),互不影响,这种方式比RPC要简单很多,因为消息只用发送一次即可,而RPC可能要发起多次调用,每个子系统一次。
不同类型的消费者
推模型DefaultMQPushConsumer
由系统控制读操作,收到消息后自动调用传入的MessageListener中的处理方法来处理,自动保存Offset,加入新的DefaultMQPush Consumer 后会自动做负载均衡
推模型实现原理
推模型是拉模型的包装,实际是通过“长轮询”方式达到Push 的效果。“长轮询”的核心是,Broker 端HOLD 住客户端过来的请求一小段时间,在这个时间内有新消息到达,就利用现有的连接立刻返回消息给Consumer。“长轮询”的主动权还是掌握在Consumer 手中,Broker 即使有大量消息积压,也不会主动推送给Consumer 。所以当消息积压时,可以采用pull的方式自主控制拉取频率和时长。
推模型流量控制
PushConsumer 有个线程池,消息处理逻辑在各个线程里同时执行
Pull 获得的消息,如果直接提交到线程池里执行,很难监控和控制,比如,如何得知当前消息堆积的数量?如何重复处理某些消息?如何延迟处理某些消息?RocketMQ 定义了一个快照类ProcessQueue 来解决这些问题,在PushConsumer 运行的时候,每个MessageQueue 都会有个对应的ProcessQueue(队列消费的快照)对象,保存了这个MessageQueue 消息处理状态的快照。ProcessQueue 对象里主要的内容是一个TreeMap 和一个读写锁。TreeMap里以MessageQueue 的Offset 作为Key ,以消息内容的引用为Value ,保存了所有从MessageQueue 获取到,但是还未被处理的消息;读写锁控制着多个线程对TreeMap 对象的并发访问。有了P rocess Queue 对象,流量控制就方便和灵活多了,客户端在每次Pull请求前会做下面三个判断来控制流量,代码如下
PushConsumer 会判断获取但还未处理的消息个数、消息总大小、Offset 的跨度,任何一个值超过设定的大小就隔一段时间再拉取消
息,从而达到流量控制的目的。
拉模型DefaultMQPullConsumer
拉模型的操作方式
读取操作中的大部分功能由使用者自主控制,例如:
- 根据topic获取Message Queue 并遍历
- 存储每个队列上的消费偏移量,下面代码用map保存
- 根据不同的消息状态做不同的处理
public class PullConsumer {
private static final Map<MessageQueue, Long> OFFSE_TABLE = new HashMap<MessageQueue, Long>();
public static void main(String[] args) throws MQClientException {
DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("please_rename_unique_group_name_5");
consumer.start();
Set<MessageQueue> mqs = consumer.fetchSubscribeMessageQueues("TopicTest1");
for (MessageQueue mq : mqs) {
System.out.printf("Consume from the queue: %s%n", mq);
SINGLE_MQ:
while (true) {
try {
PullResult pullResult =
consumer.pullBlockIfNotFound(mq, null, getMessageQueueOffset(mq), 32);
System.out.printf("%s%n", pullResult);
putMessageQueueOffset(mq, pullResult.getNextBeginOffset());
switch (pullResult.getPullStatus()) {
case FOUND:
break;
case NO_MATCHED_MSG:
break;
case NO_NEW_MSG:
break SINGLE_MQ;
case OFFSET_ILLEGAL:
break;
default:
break;
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
consumer.shutdown();
}
private static long getMessageQueueOffset(MessageQueue mq) {
Long offset = OFFSE_TABLE.get(mq);
if (offset != null)
return offset;
return 0;
}
private static void putMessageQueueOffset(MessageQueue mq, long offset) {
OFFSE_TABLE.put(mq, offset);
}
}
不同类型的生产者
DefaultMQProducer
生产者发送消息默认使用的是DefaultMQProducer 类,内部持有一个DefaultMQProducerImp,发送消息实际均通过这个类发送。
同步发送
超时时间默认3秒
异步发送
传入回调函数
单向发送
类似于UDP,不等待broker的确认,方法便直接返回。显然,方法可以提高吞吐量,但可能会消息丢失。
延迟消息
RocketMQ 支持发送延迟消息,Broker 收到这类消息后,延迟一段时间再处理。使用方法是在创建Message 对象时,调用setDelayTimeLevel(intlevel)方法设置延迟时间,然后再把这个消息发送出去。目前延迟的时间不支持任意设置,仅支持预设值的时间长度(1 s/5s/10s/30s/I m/2m/3m/4m/5m/6m/7m/8m/9m/10m/20m/30m/1 h /2h )。比如setDelayTimeLevel(3)表示延迟10s .
一个延时消息被发出到消费成功经历以下几个过程:
- 设置消息的延时级别delayLevel
- producer发送消息
- broker收到消息在准备将消息写入存储的时候,判断是延时消息则更改Message的topic为延时消息队列的topic(SCHEDULE_TOPIC_XXXX),也就是将消息投递到延时消息队列
- 每一个延时等级一个queue, 每个延时队列启动一个定时任务来处理该队列的延时消息,定时任务从延时队列中读取消息,拿到消息后判断是否达到延时时间,如果到了则修改topic为原始topic。并将消息投递到原始topic的队列
- consumer像消费其他消息一样从broker拉取消息进行消费
参考:https://blog.csdn.net/gesanghuakaisunshine/article/details/80261628
分布式消息队列的协调者
NameServer 的功能
NameServer 是整个消息队列中的状态服务器,集群的各个组件通过它来了解全局的信息。同时,各个角色的机器都要定期向NameServer 上报自己的状态,超时不上报的话,NameServer 会认为某个机器出故障不可用了,其他的组件会把这个机器从可用列表里移除。
集群状态的存储结构
在org.apache.rocketmq.namesrv.routeinfo 的RoutelnfoManager 类中,有五个变量,集群的状态就保存在这五个变量中。
QueueData里保存了broker的名称、读写队列数量等。List的长度等于这个Topic的Master Broker的数量
private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
相同名称的broker有多台机器,一个master,多个slave。
brokerData里存储着master和slave的地址信息、集群名称等
private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
一个集群包含多少个master broker
private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
BrokerLiveTable存储的内容是这台Broker 机器的实时状态,包括上次更新状态的时间戳,Broker向NameServr发送的心跳检测会更新时间戳。NameServer 会定期检查这个时间戳,超时没有更新就认为这个Broker 无效了,将其从Broker 列表里清除。
private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
为何不用ZooKeeper
- ZooKeeper 的功能很强大,包括自动Master 选举等,而RocketMQ不需要进行Master选举,用不到这些复杂的功能,所以只需要一个轻量级的元数据服务器就足够了。
- 中间件对稳定性要求很高,NameServr代码量少,容易维护,所以不需要再依赖另一个中间件。
消息存储结构
RocketMQ 消息的存储是由ConsumeQueue 和CommitLog 配合完成的,消息真正的物理存储文件是CommitLog, ConsumeQueue 是消息的逻辑队列,类似数据库的索引文件,存储的是指向物理存储的地址。
顺序写
通过ConsumeQueue可以保证消息顺序写,提高写入效率。
随机读
利用操作系统的pagecache 机制,可以批量地从磁盘读取,作为cache存到内存中,加速后续的读取速度。
零拷贝
说起来比较麻烦,简而言之,内存分为虚拟内存和物理内存,每个进程都有自己的虚拟地址空间(32位,0-4G),程序里操作的都是虚拟内存,虚拟内存映射着真实的物理内存。
再者,操作系统将内存划分为用户态和内核态两块,用户态和内核态的虚拟地址空间分别映射着不同的物理地址空间,一次系统调用,比如读,需要将磁盘页数据拷贝到内核态,再由内核态拷贝到用户态。
零拷贝就是说,用户态和内核态映射同一块物理地址空间,磁盘页数据加载到物理地址空间后,用户态就能直接使用了,不需要经由内核态到用户态的拷贝。
高可用
RocketMQ 分布式集群是通过Master 和Slave 的配合达到高可用性的。也就是消费端的主从机制+发送端的多master集群,消除单点故障。
Master 角色的Broker 支持读和写,Slave 角色的Broker 仅支持读,也就是Producer 只能和Master 角色的Broker 连接写人消息;Consumer 可以连接Master 角色的Broker ,也可以连接Slave 角色的Broker 来读取消息。
消费端的高可用
主从机制:
当Master 不可用或者繁忙的时候,Consumer 会被自动切换到从Slave 读。
发送端的高可用
多master集群:
在创建Topic 的时候,把Topic 的多个Message Queue 创建在多个Broker 组上(相同Broker 名称,不同brokerId 的机器组成一个Broker 组),这样当一个Broker 组的Master 不可用后,其他组的Master 仍然可用,Producer 仍然可以发送消息。
刷盘方式
异步刷盘
消息写入内核态页缓存(PAGECACHE) ,当页缓存消息量积累到一定程度,由操作系统统一刷盘。优点:写操作立即返回,吞吐量大;
同步刷盘
消息写入页缓存后,立即通知刷盘线程刷盘,刷盘完成再返回。
配置方式
刷盘方式通过Broker 配置文件里的flushDiskType 参数
设置,这个参数被配置成SYNC_FLUSH 、ASYNC_FLUSH 中的一个。
主从复制方式
如果一个Broker 组有Master 和Slave, 消息需要从Master 复制到Slave
上,有同步和异步两种复制方式。
同步复制
等Master 和Slave 均写成功后才反馈给客户端写成功状态
异步复制
只要Master 写成功即可反馈给客户端写成功状态。
配置方式
通过Broker 配置文件里的brokerRole 参数进行设置的,这个参数可以被设置成ASYNC_MASTER 、SYNC_MASTER 、SLAVE 三个值中的一个。
刷盘、主从复制方式小结
通常情况下,主从都要配置成异步刷盘,主从之间配置成同步复制。能提高吞吐量,且保证数据不丢。
顺序消息
顺序消息是指消息的消费顺序和产生顺序相同,在有些业务逻辑下,必须保证顺序。比如订单的生成、付款、发货,这3 个消息必须按顺序处理才行。顺序消息分为全局顺序消息和部分顺序消息,全局顺序消息指某个Topic 下的所有消息都要保证顺序;部分顺序消息只要保证每一组消息被顺序消费即可,比如上面订单消息的例子,只要保证同一个订单ID 的三个消息能按顺序消费即可。
全局顺序消息
消除所有的并发处理,一个topic只能有一个broker组,且读写队列设为1,Producer 和Consumer 的并发设置也要是1,Producer、MessageQueue、Consumer为一对一对一的关系
部分顺序消息
要保证部分消息有序,需要发送端和消费端配合处理。
发送端
发送端在发送消息时使用MessageQueueSelector,把同一业务ID 的消息发送到同一个Message Queue
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {`在这里插入代码片`
Integer id = (Integer) arg;
int index = id % mqs.size();
return mqs.get(index);
}
}, orderId);
消费端
消费端注册消息监听器时,使用MessageListenerOrderly 类
consumer.registerMessageListener(new MessageListenerOrderly() {
AtomicLong consumeTimes = new AtomicLong(0);
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
context.setAutoCommit(false);
System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
this.consumeTimes.incrementAndGet();
if ((this.consumeTimes.get() % 2) == 0) {
return ConsumeOrderlyStatus.SUCCESS;
} else if ((this.consumeTimes.get() % 3) == 0) {
return ConsumeOrderlyStatus.ROLLBACK;
} else if ((this.consumeTimes.get() % 4) == 0) {
return ConsumeOrderlyStatus.COMMIT;
} else if ((this.consumeTimes.get() % 5) == 0) {
context.setSuspendCurrentQueueTimeMillis(3000);
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
消息重复
解决消息重复有两种方法
- 保证消费逻辑的幕等性(多次调用和一次调用效果相同),可以用redis实现的分布式锁来实现
- 维护一个已消费消息的记录,消费前查询这个消息是否被消费过。
这两种方法都需要使用者自己实现。比如,消息来时,先用业务上的唯一标识,查询数据库表中是否存在这条消息,若存在则不消费消息直接返回,若不存在则将业务上的唯一标识插入redis中,作为分布式锁的key,同时设置过期时间,业务逻辑处理完后,从redis中删掉这个key,释放锁。(这里redis分布式锁的很多细节没有说到)
消息优先级
创建多个Topic
情况一:
如果当前Topic 里有多种相似类型的消息,比如类型AA 、AB 、AC ,当AB 、AC 的消息量很大,但是处理速度比较慢的时候,队列里会有很多AB 、AC 类型的消息在等候处理,这个时候如果有少量AA 类型的消息加人,就会排在AB 、AC 类型消息后面,需要等候很长时间才能被处理。
解决:
如果业务需要AA 类型的消息被及时处理,可以把这三种相似类型的消息分拆到两个Topic 里,比如AA 类型的消息在一个单独的Topic, AB 、AC 类型的消息在另外一个Topic 。把消息分到两个Topic 中以后,应用程序创建两个Consumer ,分别订阅不同的Topic ,这样消息AA 在单独的Topic 里,不会因为AB 、AC 类型的消息太多而被长时间延时处理。
创建多个MessageQueue
情况二:
第二种情况和第一种情况类似,但是不用创建大量的Topic 。举个实际应用场景:一个订单处理系统,接收从100 家快递门店过来的请求,把这些请求通过Producer 写人RocketMQ ;订单处理程序通过Consumer 从队列里读取消息并处理,每天最多处理1 万单。如果这100 个快递门店中某几个门店订单量大增,比如门店一接了个大客户,一个上午就发出2 万单消息请求,这样其他的99 家门店可能被迫等待门店一的2 万单处理完,也就是两天后订单才能被处理,显然很不公平。
解决:
这时可以创建一个Topic ,设置Topic 的MessageQueue 数量超过100 个,Producer 根据订单的门店号,把每个门店的订单写人一个MessageQueue 。DefaultMQPushConsumer 默认是采用循环的方式逐个读取一个Topic 的所有MessageQueue ,这样如果某家门店订单量大增,这家门店对应的MessageQueue 消息数增多,等待时间增长,但不会造成其他家门店等待时间增长。
另外为了公平,可以将DefaultMQPushConsumer的pullBatchSize参数设为1,参数默认值是32,也就是每次从某个MessageQueue 读取消息的时候,最多可以读32个。
吞吐量优先
在Broker端进行消息过滤
通过Tag进行过滤
发送消息设置了Tag 以后,消费方在订阅消息时,才可以利用Tag 在Broker 端做消息过滤。
Message msg = new Message("TopicTest", "TagA", ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
consumer.subscribe("TopicTest", "TagA || TagC || TagD");
对一个应用来说,尽可能只用一个Topic ,不同的消息子类型用Tag 来标识
通过SQL表达式进行过滤
略
通过FilterServer进行过滤
略
提高Consumer处理能力
增加Consumer实例数量,增加消费线程数量
- 加机器,或者在已有机器中启动多个Consumer进程都可以增加Consumer实例数量
- 修改consumeThreadMin 和consumeThreadMax 提高单个 Consumer 实例并行处理的线程数
以批量方式消费
一次update10条的时间会大大小于10次update 1条数据的时间,可以设置Consumer 的consumeMessageBatchMaxSize 这个参数,默认是1 ,如果设置为N,每次收到长度为N 的消息链表。
跳过非重要消息
Consumer 在消费的过程中,如果发现由于某种原因发生严重的消息堆积,短时间无法消除堆积,这个时候可以选择丢弃不重要的消息,使Consumer 尽快追上Producer 的进度
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
long Offset = msgs.get(0).getQueueOffset();
String maxOffset = msgs.get(0).getProperty(MessageConst.PROPERTY_MAX_OFFSET);
long diff = Long.parseLong(maxOffset) - Offset;
if (diff > 90000) {
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
//正常消费消息
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
如代码所示,当某个队列的消息数堆积到90000 条以上,就直接丢弃,以便快速追上发送消息的进度。
提高Producer发送速度
通过OneWay方式发送
在一些对速度要求高,但是可靠性要求不高的场景下,比如日志收集类应用,可以采用Oneway 方式发送。
多个Producer同时发送
增加Producer 的并发量,使用多个Producer同时发送
消息发送超时
- send方法默认超时时间为3秒
- 若3秒内发送失败,则:
- 重试2次(可通过参数retryTimesWhenSendFailed调整,默认值2)
- 重试另一个broker(可通过参数retryAnotherBrokerWhenNotStoreOK调整,默认false不重试)
- 若超过3秒,则直接抛出异常
所以最好先把消息存储到db,后台启线程定时重试,确保消息一定存储到broker。
消费失败
并发消费失败
在Consumer使用的时候需要注册MessageListener,对于PushConsumer来说需要注册MessageListenerConcurrently,其中消费消息的接口会返回处理状态,分别是,
- ConsumeConcurrentlyStatus.CONSUME_SUCCESS,消费成功
- ConsumeConcurrentlyStatus.RECONSUME_LATER,延时消费,首次延时10s
如果一条消息在消费端处理没有返回这2个状态,那么相当于这条消息没有达到消费者,势必会再次发送给消费者!也即是消息的处理必须有返回值,否则就进行重发。
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
返回ConsumeConcurrentlyStatus.RECONSUME_LATER状态之后的处理策略是将该组消息发送回Broker,等待后续消息。发送回的消息会设置重试Topic,重试Topic命名为:"%RETRY%" + ConsumerGroupName。原先实际的Topic会暂存到消息属性当中,以及设置delayLevel和reconsumeTimes。
重试消息的重新投递逻辑与延迟消息一致,等待delayLevel对应的延时一到,Broker会尝试重新进行投递处理。如下图:
1、消息重试和延迟消息的处理流程是一样的都需要创建一个延迟消息的主题队列(SCHEDULE_TOPIC_XXXX)。后台启动定时任务定时扫描需要的发送的消息将其发送到原有的主题和消息队列中供消费,只是其重试消息的主题是%RETRY_TOPIC%+ consumerGroup并且其队列只有一个queue0,延迟消息和普通消息一样发送到原主题的原队列中。
2、和普通消息不一样的是,consumer拉取消息的主题不是原本订阅的topic,而是%RETRY%+ConsumerGroupName。consumer发送重试消息给broker以后,当延时时间到,消息被转移至%RETRY_TOPIC%+ consumerGroup下,consume会拉取这个新的topic的消息。consumer拉取到这个retryTopic的消息之后再把topic换成原来的topic:org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService#resetRetryTopic,然后交给consume的listener处理。
3、根据业务的需要,定义消费的最大重试次数,每次消费的时候判断当前消费次数是否等于最大重试次数的阈值。如:重试3次就认为当前业务存在异常,继续重试下去也没有意义了,那么我们就可以将当前的这条消息进行提交,返回broker状态ConsumeConcurrentlyStatus.CONSUME_SUCCES,让消息不再重发将消息存入消费失败的数据库表,读取数据库表,展示在主页上。
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
MessageExt msg = msgs.get(0);
String msgbody = new String(msg.getBody(), "utf-8");
System.out.println(msgbody + " Receive New Messages: " + msgs);
if (msgbody.equals("HelloWorld - RocketMQ4")) {
System.out.println("======错误=======");
int a = 1 / 0;
}
} catch (Exception e) {
e.printStackTrace();
if (msgs.get(0).getReconsumeTimes() == 3) {
// 该条消息可以存储到DB或者LOG日志中,或其他处理方式
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;// 成功
} else {
return ConsumeConcurrentlyStatus.RECONSUME_LATER;// 重试
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
顺序消费失败
在消息失败处理上,顺序消息与非顺序消息是有明显差异的。对于顺序消息来说,如果消费失败后将其延迟消费,那么顺序性实际就被破坏掉了。
所以顺序消息消费失败的话,消息消费不会再推进,直到失败的消息消费成功为止。
死信队列
当重试次数超过所有延迟级别之后。消息会进入死信,死信Topic的命名为:%DLQ% + Consumer组名。
可以通过接口去查询当前RocketMQ中私信队列的消息,如有必要,可以将消息从死信队列中移出并重新投递。
来源:CSDN
作者:fengyq17290
链接:https://blog.csdn.net/fengyq17290/article/details/104082784