方案分析
在上一篇文档中,详细讲述了如何通过Canal+MQ实现对分库分表的数据库和数据表进行数据同步,而在这个方案中,还有一个关键点是需要注意的:首先,数据增删改的信息是保证写入binlog的,Canal解析出增删改的信息后写入MQ,同步程序从MQ中读取消息,如果MQ中的消息丢失了数据将无法进行同步。
因此就需要对MQ传递消息的可靠性进行研究
保证MQ消息的可靠性分为两个方面:保证生产消息的可靠性、保证消费消息的可靠性。
保证生产消息可靠性
RabbitMQ提供生产者确认机制保证生产消息的可靠性,技术方案如下:
1. 首先发送消息的方法如果执行失败会进行重试,重试次数耗尽记录消息失败
zhilian-framework包中的zhilian-rabbitmq工程下面的client包的消息处理类,用于发送消息
package com.zhilian.rabbitmq.client;import cn.hutool.core.exceptions.ExceptionUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.zhilian.common.expcetions.MqException;
import com.zhilian.common.utils.DateUtils;
import com.zhilian.common.utils.JsonUtils;
import com.zhilian.common.utils.NumberUtils;
import com.zhilian.rabbitmq.dao.FailMsgDao;
import com.zhilian.rabbitmq.plugins.DelayMessagePostProcessor;
import com.zhilian.rabbitmq.plugins.RabbitMqListenableFutureCallback;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageBuilder;
import org.springframework.amqp.core.MessageDeliveryMode;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;import javax.annotation.Resource;/*** 消息处理类**/
@Slf4j
@Service
public class RabbitClient {@Resourceprivate RabbitTemplate rabbitTemplate;@Autowired(required = false)private FailMsgDao failMsgDao;@Resourceprivate RabbitClient rabbitClient;public void sendMsg(String exchange, String routingKey, Object msg) {rabbitClient.sendMsg(exchange, routingKey, msg, null, null, false);}/*** 发送消息 重试3次** @param exchange 交换机* @param routingKey 路由key* @param msg 消息对象,会将对象序列化成json字符串发出* @param delay 延迟时间 秒* @param msgId 消息id* @param isFailMsg 是否是失败消息* @return 是否发送成功*/@Retryable(value = MqException.class, maxAttempts = 3, backoff = @Backoff(value = 3000, multiplier = 1.5), recover = "saveFailMag")public void sendMsg(String exchange, String routingKey, Object msg, Integer delay, Long msgId, boolean isFailMsg) {// 1.发送消息前准备// 1.1获取消息内容,如果非字符串将其序列化String jsonMsg = JsonUtils.toJsonStr(msg);// 1.2.全局唯一消息id,如果调用者设置了消息id,使用调用者消息id,如果为配置,默认雪花算法生成消息idmsgId = NumberUtils.null2Default(msgId, IdUtil.getSnowflakeNextId());// 1.3.设置默认延迟时间,默认立即发送delay = NumberUtils.null2Default(delay, -1);log.debug("消息发送!exchange = {}, routingKey = {}, msg = {}, msgId = {}", exchange, routingKey, jsonMsg, msgId);// 1.4.构建回调RabbitMqListenableFutureCallback futureCallback = RabbitMqListenableFutureCallback.builder().exchange(exchange).routingKey(routingKey).msg(jsonMsg).msgId(msgId).delay(delay).isFailMsg(isFailMsg).failMsgDao(failMsgDao).build();// 1.5.CorrelationData设置CorrelationData correlationData = new CorrelationData(msgId.toString());correlationData.getFuture().addCallback(futureCallback);// 1.6.构造消息对象Message message = MessageBuilder.withBody(StrUtil.bytes(jsonMsg, CharsetUtil.CHARSET_UTF_8))//持久化.setDeliveryMode(MessageDeliveryMode.PERSISTENT)//消息id.setMessageId(msgId.toString()).build();try {// 2.发送消息this.rabbitTemplate.convertAndSend(exchange, routingKey, message, new DelayMessagePostProcessor(delay), correlationData);} catch (Exception e) {log.error("send error:" + e);// 3.构建异常回调,并抛出异常MqException mqException = new MqException();mqException.setMsg(ExceptionUtil.getMessage(e));mqException.setMqId(msgId);throw mqException;}}/*** @param mqException mq异常消息* @param exchange 交换机* @param routingKey 路由key* @param msg mq消息* @param delay 延迟消息* @param msgId 消息id*/@Recoverpublic void saveFailMag(MqException mqException, String exchange, String routingKey, Object msg, Integer delay, String msgId) {//发送消息失败,需要将消息持久化到数据库,通过任务调度的方式处理失败的消息failMsgDao.save(mqException.getMqId(), exchange, routingKey, JsonUtils.toJsonStr(msg), delay, DateUtils.getCurrentTime() + 10, ExceptionUtil.getMessage(mqException));}}
@Retryable注解可实现方法执行失败进行重试,如下:
@Retryable(value = MqException.class, maxAttempts = 3, backoff = @Backoff(value = 3000, multiplier = 1.5), recover = "saveFailMag")
属性说明如下:
value:抛出制定异常才会重试
include:和value一样,默认为空,当exclude也为空时,默认所有异常
exclude:指定不处理的异常
maxAttempts:最大重试次数,默认3次
backoff:重试等待策略,默认使用@Backoff,@Backoff的value默认为1000L,我们设置为3000L;表示第一次失败后等待3秒后重试,multiplier(指定延迟倍数)默认为0,如果把multiplier设置为1.5表示每次等待重试时间是上一次的1.5倍,则第一次重试为3秒,第二次为4.5秒,第三次为6.75秒。
Recover: 设置回调方法名,当重试耗尽时,通过recover属性设置回调的方法名。通过@Recover注解定义重试失败后的处理方法,在Recover方法中记录失败消息到数据库。
2. 通过MQ提供的生产者确认机制保证生产消息的可靠性
使用生产者确认机制需要给每个信息指定一个唯一ID,生产者确认机制通过异步回调的方式进行,包括ConfirmCallback和Return回调。
ConfirmCallback:消息发送到Broker会有一个结果返回给发送者表示消息是否处理成功:
消息成功投递到交换机,返回ack
消息未投递到交换机,返回nack
发送消息时指定回调对象
// 1.4.构建回调RabbitMqListenableFutureCallback futureCallback = RabbitMqListenableFutureCallback.builder().exchange(exchange).routingKey(routingKey).msg(jsonMsg).msgId(msgId).delay(delay).isFailMsg(isFailMsg).failMsgDao(failMsgDao).build();// 1.5.CorrelationData设置CorrelationData correlationData = new CorrelationData(msgId.toString());correlationData.getFuture().addCallback(futureCallback);// 1.6.构造消息对象Message message = MessageBuilder.withBody(StrUtil.bytes(jsonMsg, CharsetUtil.CHARSET_UTF_8))//持久化.setDeliveryMode(MessageDeliveryMode.PERSISTENT)//消息id.setMessageId(msgId.toString()).build();
回调类:RabbitMqListenableFutureCallback
如果没有返回ack则将消息记录到失败消息表,如果经过重试后返回了ack说明消息发送成功,此时将消息从失败消息表删除。
package com.zhilian.rabbitmq.plugins;import cn.hutool.core.exceptions.ExceptionUtil;
import com.zhilian.common.utils.DateUtils;
import com.zhilian.rabbitmq.dao.FailMsgDao;
import lombok.Builder;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.util.concurrent.ListenableFutureCallback;@Builder
public class RabbitMqListenableFutureCallback implements ListenableFutureCallback<CorrelationData.Confirm> {//记录失败消息serviceprivate FailMsgDao failMsgDao;private String exchange;private String routingKey;private String msg;private Long msgId;private Integer delay;//是否是失败消息private boolean isFailMsg=false;@Overridepublic void onFailure(Throwable ex) {if(failMsgDao == null) {return;}failMsgDao.save(msgId, exchange, routingKey, msg, delay, DateUtils.getCurrentTime() + 10, ExceptionUtil.getMessage(ex));}@Overridepublic void onSuccess(CorrelationData.Confirm result) {if(failMsgDao == null){return;}if(!result.isAck()){// 执行失败保存失败信息,如果已经存在保存信息,如果不在信息信息failMsgDao.save(msgId, exchange, routingKey, msg, delay,DateUtils.getCurrentTime() + 10, "MQ回复nack");}else if(isFailMsg && msgId != null){// 如果发送的是失败消息,当收到ack需要从fail_msg删除该消息failMsgDao.removeById(msgId);}}
}
Return回调:如果消息发送到交换机成功了但是并没有到达队列,此时会调用ReturnCallback回调方法,在回调方法中我们可以收到失败的消息存入失败消息表以便进行补偿
要使用Return回调需要开启设置:(在shared-rabbitmq.yaml中配置rabbitMQ参数:
spring:rabbitmq:publisher-confirm-type: correlatedpublisher-returns: truetemplate:mandatory: true
说明:
publish-confirm-type:开启publisher-confirm,这里支持两种类型:
simple:同步等待confirm结果,直到超时
correlated:异步回调,定义ConfirmCallback,MQ返回结果时会回调这个ConfirmCallback
publish-returns:开启publish-return功能,同样是基于callback机制,不过是定义ReturnCallback
template.mandatory:定义消息路由失败时的策略。true,则调用ReturnCallback;false:则直接丢弃消息
对于发送消息失败之后将消息写入失败消息表的逻辑参考如下:
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {// 获取RabbitTemplateRabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);//定义returnCallback回调方法rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {@Overridepublic void returnedMessage(ReturnedMessage returnedMessage) {byte[] body = returnedMessage.getMessage().getBody();//消息idString messageId = returnedMessage.getMessage().getMessageProperties().getMessageId();String content = new String(body, Charset.defaultCharset());log.info("消息发送失败,应答码{},原因{},交换机{},路由键{},消息id{},消息内容{}",returnedMessage.getReplyCode(),returnedMessage.getReplyText(),returnedMessage.getExchange(),returnedMessage.getRoutingKey(),messageId,content);if (failMsgDao != null) {failMsgDao.save(NumberUtils.parseLong(messageId), returnedMessage.getExchange(), returnedMessage.getRoutingKey(), content, null, DateUtils.getCurrentTime(), "returnCallback");}}});
}
保证消费信息可靠性
保证消费消息可靠性方案首先保证发送消息设置为持久化,其次通过MQ的消费确认机制保证消费者消费成功消息后再将消息删除。
首先设置消息持久化,保证消息发送到MQ消息不丢失。具体需要设置交换机和队列支持持久化,发送消息设置deliveryMode=2。
RabbitMQ是通过消费者回执来确认消费者是否成功处理消息的:消费者获取消息后,向RabbitMQ发送ACK回执,表明自己已经处理完成消息,RabbitMQ收到ACK后删除消息。
消费消息失败重试3次,仍失败则将消费失败的消息放入失败消息数据库
通过任务调度扫描失败消息队列(错误消息队列)重新发送,达到一定的次数还未成功则由人工处理
核心代码实现:(从预定义的错误队列中取出之前处理失败的消息,重新发送到消息原本的目标地址
package com.zhilian.rabbitmq.plugins;import com.zhilian.common.utils.IoUtils;
import com.zhilian.common.utils.JsonUtils;
import com.zhilian.rabbitmq.domain.ErrorRabbitMqMessage;
import com.zhilian.rabbitmq.properties.RabbitmqProperties;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.GetResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageBuilder;
import org.springframework.amqp.rabbit.core.RabbitTemplate;import javax.annotation.PreDestroy;
import java.io.IOException;
import java.nio.charset.Charset;@Slf4j
public class RabbitMqResender {private RabbitTemplate rabbitTemplate;private RabbitmqProperties rabbitmqProperties;private Channel channel;public RabbitMqResender(RabbitTemplate rabbitTemplate, RabbitmqProperties rabbitmqProperties) {this.rabbitTemplate = rabbitTemplate;this.rabbitmqProperties = rabbitmqProperties;channel = rabbitTemplate.getConnectionFactory().createConnection().createChannel(false);}/*** 从队列中获取一条数据并处理,如果没有消息,返回false,有消息返回true*/public boolean getOneMessageAndProcess() {try {GetResponse response = channel.basicGet(rabbitmqProperties.getError().getQueue(), false);if(response == null) {return false;}ErrorRabbitMqMessage errorRabbitMqMessage = JsonUtils.toBean(new String(response.getBody()), ErrorRabbitMqMessage.class);Message message = MessageBuilder.withBody(errorRabbitMqMessage.getMessage().getBytes(Charset.defaultCharset())).build();rabbitTemplate.send(errorRabbitMqMessage.getOriginExchange(), errorRabbitMqMessage.getOriginRoutingKey(), message);channel.basicAck(response.getEnvelope().getDeliveryTag(), false);return true;}catch (IOException e) {log.error("消息重发失败,e:",e);return false;}}@PreDestroypublic void destory() {log.info("rabbitmq销毁...");IoUtils.close(channel);}
}
1. 核心功能//错误消息的捞取//从指定的错误队列(error.queue,由RabbitmqProperties配置)中拉取未被确认(unacknowledged)的消息。2. 消息解析与重发//将错误队列中的消息体解析为ErrorRabbitMqMessage对象(包含原始交换机、路由键、消息内容等元数据)。//使用RabbitTemplate将消息内容重新发送到原始的交换机(originExchange)和路由键(originRoutingKey)。
3. 异常处理与可靠性//解析失败:若消息无法解析为ErrorRabbitMqMessage,直接拒绝消息并丢弃(不重新入队),避免死循环。//发送失败:若重发过程中抛出异常(如网络问题),拒绝消息并允许重新入队,等待下次重试。//资源安全:通过@PreDestroy确保程序关闭时正确释放RabbitMQ的Channel和Connection,防止连接泄漏。
RabbitMQ提供三个确认模式:
•manual:手动ack,需要在业务代码结束后,调用api发送ack。
•auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack
•none:关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除
这三种确认模式各有各的特点:
- none模式下,消息投递是不可靠的,可能丢失
- auto模式类似事务机制,出现异常时返回nack,消息回滚到mq;没有异常,返回ack
- manual:自己根据业务情况,判断什么时候该ack
本项目的配置:
spring:rabbitmq:....listener:simple:acknowledge-mode: auto #,出现异常时返回nack,消息回滚到mq;没有异常,返回ackretry:enabled: true # 开启消费者失败重试initial-interval: 1000 # 初识的失败等待时长为1秒multiplier: 10 # 失败的等待时长倍数,下次等待时长 = multiplier * last-intervalmax-attempts: 3 # 最大重试次数stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
本项目使用自动ack模式,当消费消息失败会重试,重试3次如果还失败会将消息投递到失败消息队列,由定时任务程序定时读取队列的消息,达到一定的次数还未成功则由人工处理。
保证消息幂等性
消费者在消费消息时难免出现重复消费的情况,比如:消费者没有向MQ返回ack导致重复消费,所以消费者需要保证消费信息幂等性
幂等性是指不论执行多少次其结果是一致的
比如:收到消息需要向数据新增一条记录,如果重复消费则会出现重复添加记录的问题。
根据场景分析解决方案:
- 查询操作:本身具有幂等性
- 添加操作:如果主键是自增则可能重复添加记录。
解决:保证幂等性可以设置数据库的唯一约束,比如添加学生信息,将学号字段设置为唯一索引,即使重复添加相同学生,同一个学号只会添加一条记录
- 更新操作:如果更新一个固定,比如update users set status = 1 where id = ?,本身就具有幂等性;
但是如果只允许更新成功一次?
解决:可以使用token机制,发送消息前生成一个token写入redis,收到消息后解析出token,从redis查询token,如果成功则说明没有消费,此时更新成功,将token从redis删除,当重复消费相同的消息时,由于token已经从redis删除不会再执行更新操作
- 删除操作:与更新操作类似,如果是删除某个具体的记录,比如:delete from users where id = ?,本身就具有幂等性,如果只允许删除成功一次就可以采用更新操作相同的方法(操作缓存token机制)
根据以上分析:
为了保证消息幂等性,需要:
- 使用数据库的唯一约束去控制
- 使用token机制:
-
- 消息具有唯一ID
- 发送消息时将消息ID写入Redis
- 消费时根据消息ID查询Redis判断是否已经消费,如果已经消费则不再消费
能否百分百保证MQ消息可靠性?
当然不能!
保证消息可靠性分两个方面:保证生产消息可靠性和保证消费消息可靠性
保证生产消息可靠性:
生产消息可靠性是通过MQ是否发送ack回执来进行判断的。如果发nack表示发送消息失败,此时会进行重发或记录到失败消息表,通过定时任务进行补偿发送。
如果Java程序并没有收到回执(如jvm进程异常结束,或断电等因素),此时将无法保证生产消息的可靠性。
保证消费信息消息可靠性:
保证消费信息消息可靠性方案首先保证发送消息设置为持久化,其次通过MQ的消费确认机制保证消费者消费消息成功后再将消息删除。
虽然设置了消息持久化,消息进入MQ首先是在缓存存储,MQ会根据一定的规则进行刷盘,(比如每隔几毫秒进行刷盘,如果在消息还没有保存到磁盘时MQ进程终止,此时将会丢失消息)虽然可以使用镜像队列(用于在RabbitMQ集群中复制队列的消息,这样做的目的是提高队列的可用性和容错性,以防止在单个节点故障时导致消息的丢失)但是也不能百分百保证消息不丢失。
我们虽然加了很多的保证可靠性的机制,但是这只能去提高消息的可靠性,最终还是不能做到百分百的可靠,因此使用MQ的场景就必须要考虑消息可靠性问题的存在,做好补偿处理任务
如何保证Canal+MQ同步消息的顺序性
首先明确Canal解析binlog日志信息按顺序发到MQ的队列中,现在是要保证消费端如何按顺序消费队列中的消息。
生产中同一个服务会启动多个jvm进程,每个进程作为canal-mq-jzo2o-foundations的消费者,如下图:
现在对服务名称先修改为aa再修改为bb,在MQ中的有两个消息:
修改服务名称为aa
修改服务名称为bb
预期:最终将服务名称修改为bb
此时两条消息会被分发给两个jvm进程,假设“修改服务名称为aa”的消息发给jvm进程1,“修改服务名称为bb”的消息发给jvm进程2,两个进程分别去消费,此时无法控制两个消息的先后顺序,可能导致服务名称最终并非修改为bb。
解决方法:
多个jvm进程监听同一个队列保证只有消费者活跃,即只有一个消费者接收消息。
消费队列中的数据使用单线程。
队列需要增加x-single-active-consumer参数,表示否启用单一活动消费者模式。
配置完成查保证队列上存在SAC标识,如下图:
当有多个jvm进程都去监听该队列时,只有一个为活跃状态
如果使用x-single-active-consumer参数需要修改为如下代码:
在Queue中添加:arguments={@Argument(name="x-single-active-consumer", value = "true", type = "java.lang.Boolean") }
如下所示:
@RabbitListener(bindings = @QueueBinding(value = @Queue(name = "canal-mq-jzo2o-foundations",arguments={@Argument(name="x-single-active-consumer", value = "true", type = "java.lang.Boolean") }),exchange = @Exchange(name="exchange.canal-jzo2o",type = ExchangeTypes.TOPIC),key="canal-mq-jzo2o-foundations"),concurrency="1")
public void onMessage(Message message) throws Exception{parseMsg(message);
}
concurrency=”1“表示 指定消费线程为1。