RocketMQ的可靠性
学习目标
- 了解 RocketMQ 可靠性原理实现
主要内容
一、消息队列简介
二、RocketMQ 的高可用
三、RocketMQ 实践
一、消息队列介绍
1.消息队列是什么?
消息队列是一种基于队列与消息传递技术,用于在分布式系统中进行异步通信的服务。不用知道具体的服务在哪,如何调用。你要做的只是将该发送的消息,向约定好的地址进行发送,任务就完成了。对应的服务能监听到你发送的消息,进行后续的操作。这就是消息队列最大的特点,将同步操作转为异步处理,将多服务共同操作转为职责单一的单服务操作,做到了服务间的解耦。
2.RocketMQ的出现和区别
RocketMQ是阿里开源的消息中间件,它是纯Java开发,具有高吞吐量、高可用性、适合大规模分布式系统应用的特点。RocketMQ实现思路起源于Kafka,但并不是Kafka的一个Copy,它对消息的可靠传输及事务性做了优化
二、RocketMQ的特性原理
1.消息可靠的含义
-
为什么会导致消息丢失
rockectMQ集群部署形式,在不可靠网络上通信,生成者发送消息,消费者拉取消息消费,消息持久化三个过程都有可能造成消息丢失。
-
消息丢失的情况会导致什么错误
-
事件消息,例如状态的更新,会导致操作未执行
-
事务消息,导致业务数据不一致,例如在金融系统中,丢失支付事务消息可能导致资金不一致
-
通知消息丢失,订单状态,新消息通知等。消息丢失可能导致用户在界面上看到的信息不准确或不及时
-
定时任务消息丢失,定时任务无法按预期执行
5.日志消息丢失,排查错误困难
-
rockectMQ发送怎么保证的消息可靠
1. 实现消息发送高可用的两种机制
1)消息发送重试机制
RocketMQ在消息发送时如果出现异常,捕获异常后重试。
2)故障规避机制
当消息第一次发送失败时,如果下一次消息还是发送到刚刚失败的Broker上,其消息发送大概率还是会失败,因此为了保证重试的可靠性,在重试时会尽量避开刚刚接收失败的Broker,而是选择其他Broker上的队列进行发送,从而提高消息发送的成功率。
2. 消息发送流程
消息发送流程主要的步骤为验证消息、查找路由、消息发送(包含异常处理机制)
查找路由目的是要知道消息要向那个broker投送,选择消息队列,这时候就有个问题,如果一个topic对应一个Broker(包含maser和slave)的话,每一个topic默认4个消息队列,现在有两个Broker,A和B,如果A失败后,怎么避免再次发送到A,防止重试也失败。
💡 为什么nameServer中存有故障的Broker的路由?
nameServer检查Broker可用有延迟 1. 10S一次的心跳间隔 2. 检测到也不会马上通知生产者(生产者每隔30s更新路由) 因此生产者最少30s才知道Broker出现故障(因此,重试间隔应该要大于30S)
首先消息发送端采用重试机制,同步发送方式由retryTimesWhenSendFailed指定重试次数,在 for 循环中 使用 try catch 将sendKernelImpl 发送方法包裹,就可以保证该方法抛出异常后能继续重试。异步发送的重试机制是在收到消息发送结果时,执行回调之前进行重试,由retryTimesWhenSendAsyncFailed指定异常重试次数。默认会重试两次。循环的执行选择消息队列、发送消息,发送成功则返回,收到异常则重试。
int timesTotal = communicationMode == CommunicationMode.SYNC ? 1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() : 1;
选择消息队列有两种方式。sendLatencyFaultEnable=false,默认不启用Broker故障延迟机制。sendLatencyFaultEnable=true,启用Broker故障延迟机制。
-
默认的选择流程:
public MessageQueue selectOneMessageQueue(final String lastBrokerName) { if (lastBrokerName == null) { return selectOneMessageQueue(); } else { for (int i = 0; i < this.messageQueueList.size(); i++) { int index = this.sendWhichQueue.incrementAndGet(); int pos = Math.abs(index) % this.messageQueueList.size(); if (pos < 0) pos = 0; MessageQueue mq = this.messageQueueList.get(pos); if (!mq.getBrokerName().equals(lastBrokerName)) { return mq; } } return selectOneMessageQueue(); } } public MessageQueue selectOneMessageQueue() { int index = this.sendWhichQueue.incrementAndGet(); int pos = Math.abs(index) % this.messageQueueList.size(); if (pos < 0) pos = 0; return this.messageQueueList.get(pos); }
第一次发送时,lastBrokerName(上一次选择发送的Broker)为null,此时直接用sendWhichQueue自增再与消息队列的总数取模(均衡)获取值
第二次发送(发送失败重试)时,遍历所有的消息队列,找到不是上一次的Broker(这里的index自增使用了ThreadLocal,让同一线程的index不被其他线程修改)
-
开启故障规避机制的选择流程:其中判断可用的方法是关键,方法中我们从faultItemTable中获取该Broker的条目,那么是什么时候放进去的呢?
遍历消息队列获取一个可用的,如果都不可用则从延时等待队列中获取一个,如果延时等待队列中的消息队列可用,则移除latencyFaultTolerance关于该topic的条目,表明该Broker故障已经修复。
public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName) { if (this.sendLatencyFaultEnable) { try { int index = tpInfo.getSendWhichQueue().incrementAndGet(); for (int i = 0; i < tpInfo.getMessageQueueList().size(); i++) { int pos = Math.abs(index++) % tpInfo.getMessageQueueList().size(); if (pos < 0) pos = 0; MessageQueue mq = tpInfo.getMessageQueueList().get(pos); if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) return mq; } final String notBestBroker = latencyFaultTolerance.pickOneAtLeast(); int writeQueueNums = tpInfo.getQueueIdByBroker(notBestBroker); if (writeQueueNums > 0) { final MessageQueue mq = tpInfo.selectOneMessageQueue(); if (notBestBroker != null) { mq.setBrokerName(notBestBroker); mq.setQueueId(tpInfo.getSendWhichQueue().incrementAndGet() % writeQueueNums); } return mq; } else { latencyFaultTolerance.remove(notBestBroker); } } catch (Exception e) { log.error("Error occurred when selecting message queue", e); } return tpInfo.selectOneMessageQueue(); } return tpInfo.selectOneMessageQueue(lastBrokerName); }
@Override public boolean isAvailable(final String name) { final FaultItem faultItem = this.faultItemTable.get(name); if (faultItem != null) { return faultItem.isAvailable(); } return true; }
在发送完和在发送过程中抛出了异常,会调用updateFaultItem方法,当isolation为true,则使用30s作为computeNotAvailableDuration方法的参数。如果isolation为false,则使用本次消息发送时延作为computeNotAvailableDuration方法的参数。
computeNotAvailableDuration的作用是计算因本次消息发送故障需要规避Broker的时长,也就是接下来多长的时间内,该Broker将不参与消息发送队列负载。具体算法是,从latencyMax数组尾部开始寻找,找到第一个比currentLatency小的下标,然后从notAvailableDuration数组中获取需要规避的时长
@Override public void updateFaultItem(final String name, final long currentLatency, final long notAvailableDuration) { FaultItem old = this.faultItemTable.get(name); if (null == old) { final FaultItem faultItem = new FaultItem(name); faultItem.setCurrentLatency(currentLatency); faultItem.setStartTimestamp(System.currentTimeMillis() + notAvailableDuration); old = this.faultItemTable.putIfAbsent(name, faultItem); if (old != null) { old.setCurrentLatency(currentLatency); old.setStartTimestamp(System.currentTimeMillis() + notAvailableDuration); } } else { old.setCurrentLatency(currentLatency); old.setStartTimestamp(System.currentTimeMillis() + notAvailableDuration); } } @Override public boolean isAvailable(final String name) { final FaultItem faultItem = this.faultItemTable.get(name); if (faultItem != null) { return faultItem.isAvailable(); } return true; }
public void updateFaultItem(final String brokerName, final long currentLatency, boolean isolation) { if (this.sendLatencyFaultEnable) { long duration = computeNotAvailableDuration(isolation ? 30000 : currentLatency); this.latencyFaultTolerance.updateFaultItem(brokerName, currentLatency, duration); } } private long computeNotAvailableDuration(final long currentLatency) { for (int i = latencyMax.length - 1; i >= 0; i--) { if (currentLatency >= latencyMax[i]) return this.notAvailableDuration[i]; } return 0; }
-
不可访问时间数组是按照时延以此递增,日常业务的降级限流也可按照此算法设计
private long[] latencyMax = {50L, 100L, 550L, 1000L, 2000L, 3000L, 15000L}; private long[] notAvailableDuration = {0L, 0L, 30000L, 60000L, 120000L, 180000L, 600000L};
-
-
开启规避机制,是悲观认为发送异常的原因是Broker不可用
-
同步发送什么异常时会重试
-
1.发送超时
-
2.Broker不存在
-
3.刷盘出现异常
-
4.发送返回的状态码
private final Set<Integer> retryResponseCodes = new CopyOnWriteArraySet<Integer>(Arrays.asList( ResponseCode.TOPIC_NOT_EXIST, ResponseCode.SERVICE_NOT_AVAILABLE, ResponseCode.SYSTEM_ERROR, ResponseCode.NO_PERMISSION, ResponseCode.NO_BUYER_ID, ResponseCode.NOT_IN_CURRENT_UNIT ));
-
-
异步发送在收到服务端(broker)的响应包时进行,因此出现网络问题不会重试
2. rockectMQ持久化的两种方式
RocketMQ基于内存映射文件机制提供了同步刷盘与异步刷盘两种机制
同步刷盘指的是在消息追加到内存映射文件的内存中后,立即将数据从内存写入磁盘文件
异步刷盘是指在消息存储时先追加到内存映射文件,然后启动专门的刷盘线程定时将内存中的数据写入磁盘。
3. rockectMQ怎么保证消息已成功消费
-
保证消息成功消费的机制—ACK采用ACK确认机制,消费者消费消息时需要给Broker反馈消息消费的情况,成功或失败,对于失败的消息会根据内部算法一段时间后重新消费。最大重新消费次数,默认16次,
每一个Broker上默认有一个重试队列。
-
什么时候重试1. 当消费状态返回为RECONSUME_LATER
消费重试,只有在消息模式为MessageModel.CLUSTERING集群模式时,Broker才会自动进行重试,
public enum ConsumeConcurrentlyStatus { /** * Success consumption */ CONSUME_SUCCESS, /** * Failure consumption,later try to consume */ RECONSUME_LATER; }
2.业务抛出异常
3.Broker接收ACK超时(网络问题)
-
消息确认和消息重试
当消息监听器返回RECONSUME_LATER时,消息消费进度也会向前推进(为符合队列先进先出的特性,RocketMQ会创建一条与原消息属性相同的消息,拥有一个唯一的新msgId,并存储原消息ID,该消息会存入CommitLog文件,与原消息没有任何关联,所以该消息也会进入ConsuemeQueue,并拥有一个全新的队列偏移量)
ACK消息是同步发送的,如果在发送过程中出现错误,将记录所有发送ACK消息失败的消息,然后再次封装成ConsumeRequest,延迟5s执行
三、实践
MQ保证消费至少能被消费一次At least once,如何保证不被重复消费?
幂等机制
消费过程幂等
RocketMQ无法避免消息重复(Exactly-Once),从对系统的影响结果来说: At least once+ 幂等消费 = Exactly once。
- 使用数据库的唯一约束实现
借助表中具有唯一约束的字段,同一字段只执行一次变更,当表中已存有这个字段,则不进行消费,msgId一定是全局唯一标识符,但是实际使用中为什么不能直接用,因为可能会存在相同的消息有两个不同msgId的情况(重试机制),这种情况就需要使业务字段进行重复消费。
在redis中借助SETNX命令。只有不存在的时候才进行变更
- 更新数据前设置前置信息
给数据变更设置一个前置条件,如果满足条件就更新数据,否则拒绝更新数据,在更新数据的时候,同时变更前置条件中需要判断的数据。这样,重复执行这个操作时,由于第一次更新数据的时候已经变更了前置条件中需要判断的数据,不满足前置条件,则不会重复执行更新数据操作。
例如:给数据加版本号,更新时比较版本号,不符合则不更新
- 记录并检查
以上两种需要根据具体的业务场景进行设计,有一种通用性最强,适用范围最广的实现幂等性方法: 记录并检查操作,也称为“Token 机制或者 GUID(全局唯一 D)机制",实现的思路特别简单: 发送消息时,生成一个全局唯一的ID,消费消息时,在执行数据更新操作之前,先检查一下是否执行过这个更新操作。
总结:想要性能比较好的实现消息可靠和消息幂等
构建一张本地消息表,消息落库与业务代码放入一个事务中,在事务外异步发起mq,开启同步刷盘
在事务外发起MQ的原因:在事务中进行消息发送时,当出现发送信息异常或超时会进行事务回滚,但是消息会进行重试后再次成功发出,导致数据不一致(解决方法:捕获异常)