RabbitMQ使用总结-数据可靠性

912 阅读25分钟

防止消息丢失

在我们的核心业务中,承载着整个上层应用平台数据传输,第一步就是要求保证数据不能有任何的丢失。

我们对数据不丢失的定义为:每次发送来的数据一定要产生已知结果,不能出现无缘无故没了的情况。

以下是RabbitMQ的工作流程:

2022-11-01-22-38-36-image.png

在图中,标黄色的就是我们无法把控的是有RabbitMQ自己内部传递消息,分别是Channel投递到Broker中的交换机,Broker的交换机投递到Queue中,这一块发生在RabbitMQ的内部,因此我们主要就是针对这一块进行处理。白色的箭头数据,属于网络环境问题,由运维监控实时监控网络环境,不是我们程序员考虑的。

保证生产者数据发送成功

2022-11-06-11-24-52-image.png 由上面可以看到要将数据发送成功,首先要保证在网络通的情况下,将消息先投递到交换机,然后由交换机路由到具体的队列里面。接下来就由这两段进行分析。

在我们系统里面,核心业务数据发送主要有两条通道来保证数据能够正确投递,一条使用中的主通道,还有一条备用通道,它们之间完全互不关联,是独立交换机绑定了独立的队列。

2022-11-01-22-43-22-image.png 接下来看它们的具体实现。


配置如下:

spring:
   #配置rabbitMq 服务器
  rabbitmq:
    host: 192.168.1.141
    port: 5672
    username: admin
    password: 123456
    publisher-returns: true
    publisher-confirm-type: correlated
    template:
      mandatory: true
@Configuration
public class MQConfiguration {

    @Bean
    public Queue DataSendQueue() {
        return QueueBuilder.durable(QueueEnum.DATA_SEND_QUEUE.getName()).build();
    }

    @Bean
    public DirectExchange dataSendExchange() {
        return ExchangeBuilder
                .directExchange(ExchangeEnum.DATA_SEND_EXCHANGE.getName())
                .durable(ExchangeEnum.DATA_SEND_EXCHANGE.getDurable())
                .build();
    }

    @Bean
    public Binding bindingSendDataExchangeQueue() {
        return BindingBuilder.bind(DataSendQueue())
                .to(dataSendExchange())
                .with(RoutingKey.DATA_SEND_KEY.getKey());
    }

    // 备份队列

    @Bean
    public Queue DataSendQueueBackup() {
        return QueueBuilder
                .durable(QueueEnum.DATA_SEND_QUEUE_BACKUP.getName())
                .build();
    }

    @Bean
    public DirectExchange dataSendExchangeBackup() {
        return ExchangeBuilder
                .directExchange(ExchangeEnum.DATA_SEND_EXCHANGE_BACKUP.getName())
                .durable(ExchangeEnum.DATA_SEND_EXCHANGE_BACKUP.getDurable())
                .build();
    }

    @Bean
    public Binding bindingSendDataExchangeQueueBackup() {
        return BindingBuilder.bind(DataSendQueueBackup())
                .to(dataSendExchangeBackup())
                .with(RoutingKey.DATA_SEND_KEY_BACKUP.getKey());
    }

}



// rabbitmqTemplate配置如下
@Configuration
public class MQConfirmAndReturnCallback implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnsCallback {


    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Autowired
    private ConnectorService connectorService;

    @PostConstruct
    public void init() {
         /*
            mandatory:交换器无法根据自身类型和路由键找到一个符合条件的队列时的处理方式
            true:RabbitMQ会调用Basic.Return命令将消息返回给生产者
            false:RabbitMQ会把消息直接丢弃
         */
        rabbitTemplate.setMandatory(true);
        // 设置确认模式:ConfirmCallback
        rabbitTemplate.setConfirmCallback(this);
        // 设置ReturnsCallback
        rabbitTemplate.setReturnsCallback(this);
    }

    // 交换机确认回调方法:成功和失败都要回调
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String id = correlationData != null ? correlationData.getId() : "";
        if (ack) {
            log.info("交换机已经收到 id 为:{}的消息", id);
        } else {
            log.error("消息投递失败,id:{},cause:{}", id, cause);
        }
        boolean isSendDataTaskMsg = isSendDataTaskMsg(correlationData.getReturned().getExchange());
        if(isSendDataTaskMsg){
            connectorService.sendDataConfirm(correlationData,ack,cause);
        }
    }


    // 回退方法,可以在当消息传递过程中 不可达目的地 时将消息返回给生产者
    @Override
    public void returnedMessage(ReturnedMessage message) {
        byte[] body = message.getMessage().getBody();
        String exchange = message.getExchange();
        int replyCode = message.getReplyCode();
        String replyText = message.getReplyText();
        String routingKey = message.getRoutingKey();
        log.error(" 消 息 {}, 被 交 换 机 {} 退 回 , 退 回 原 因 :{}, 路 由 key:{}",
                new String(body), exchange, replyText, routingKey);
        boolean isSendDataTaskMsg = isSendDataTaskMsg(message.getExchange());
        if(isSendDataTaskMsg){
            connectorService.sendDataReturnedMessage(message.getMessage().getMessageProperties().getMessageId());
        }
    }

    private static boolean isSendDataTaskMsg(String exchangeName) {
        return Objects.equals(exchangeName, MQEnums.ExchangeEnum.DATA_SEND_EXCHANGE.getName()) || Objects.equals(exchangeName, MQEnums.ExchangeEnum.DATA_SEND_EXCHANGE_BACKUP.getName());
    }
}

数据发送到交换机

RabbitMQ中核心的思想是生产的消息不会直接发送到队列中,而是先发送到交换机,内部由交换机决定发送到哪个队列中。

因此保证数据不丢失的第一步,就是保证消息成功发送到交换机,通常有两种方式来保证消息发送到交换机的可靠性:

  • 同步发送(单个确认,批量确认)等到RabbitMQ broker有返回发送成功再发送下一个;
  • 异步发送机制,等到发送数据后无论发送成功还是失败回调。

第一种方式性能太差,但是可靠性高,第二种性能较好,但是需要手动做补偿等机制,可靠性依赖于具体的补偿机制。

我们使用的是第二种异步方式,并制定了一系列的补偿机制。

接下来看具体实现:


在RabbitMQ和SpringBoot整合中,有个重要回调接口RabbitTemplate.ConfirmCallback它提供了一个回调方法confirm(CorrelationData correlationData, boolean ack, String cause)用于确认消息是否正确投递到了broker的交换机中。

使用方式如下:

# 使用自定义确认类型
spring.rabbitmq.publisher-confirm-type=correlated
 // 设置确认模式:ConfirmCallback
 rabbitTemplate.setConfirmCallback(this);
 // 设置回调函数
 rabbitTemplate.setConfirmCallback(((correlationData, ack, cause) -> {
     // correlationData 关联数据,可以在发送数据的时候构造该数据
     // ack(boolean),如果为true代表消息被正确投递到交换,如果为false则说明投递失败
     // cause,失败原因,当ack=true的时候,该参数为null
 }));

在我们应用中,保证消息正确发送到交换机中整体流程如下:

2022-11-06-11-28-54-image.png 步骤如下:

发送数据,如果数据发送到交换机失败,则调用confirm回调   

发送失败会根据数据类型选择对应的处理者,判断是否是核心业务的数据,如果是核心业务数据则使用备用通道发送

如果备用通道也发送失败,则发送到监控系统中,进行人工补偿

(ps:我们的补偿方式有两种,一种是手动重新投递,还有种就是形成工单发回给数据来源方进行人工干预审核)

具体代码如下:

// 具体处理confim代码    
@Component
public class ConnectorService {  
        // 主通道发送代码
    public void sendData(TransferData transferData) throws UnsupportedEncodingException {
        // 生成唯一的任务ID
        final String taskId = generateTaskId();
        transferData.setTaskId(taskId);
        sendDataToQueue(transferData, MQEnums.ExchangeEnum.DATA_SEND_EXCHANGE.getName(),
                MQEnums.RoutingKey.DATA_SEND_KEY.getKey());
        redisCache.setCacheMapValue(CacheConstants.SEND_DATA_TASK_KEY,
                taskId,
                new TransferDataMqMsgCache(transferData,false)
        );
    }


    // 具体发送数据逻辑
    private void sendDataToQueue(TransferData transferData,
                                 String exchange,
                                 String routingKey) {
        String taskId = transferData.getTaskId();
        byte[] dataBinary = JSONObject.toJSONString(transferData).getBytes(StandardCharsets.UTF_8);
        // 消息
        Message message = new Message(dataBinary);
        // 设置数据的唯一ID
        message.getMessageProperties().setMessageId(taskId);
        // 关联数据对象,常用于标识数据,这个就是confirm回调的CorrelationData参数
        CorrelationData correlationData = new CorrelationData(taskId);
        // returnedMessage当时数据无法发送到交换机的时候,回退信息
        ReturnedMessage returnedMessage = buildSendTaskReturnedMessage(message);
        correlationData.setReturned(returnedMessage);
        rabbitTemplate.convertAndSend(exchange, routingKey, message, correlationData);
    }


    // 备用通道补偿机制,也是具体的处理核心业务confirm逻辑
    public void sendDataConfirm(CorrelationData correlationData, boolean ack, String cause) {
        String taskId = correlationData.getId();
        if (ack) {
            // 处理发送成功
            processSendDataConfim(taskId);
            return;
        }

        // 没发送成功,发送一个警告消息给监控端
        sendWarnMsgToMonitor(taskId, cause);
        // 补偿,使用备用通道重试

        // 判断是否是从主通道失败的消息
        TransferDataMqMsgCache transferDataMqMsgCache = redisCache.getCacheMapValue(CacheConstants.SEND_DATA_TASK_KEY, taskId);
        // 是从主通道失败的消息,此时使用备用通道发送数据
        if (Objects.nonNull(transferDataMqMsgCache) && !transferDataMqMsgCache.isBackupMsg()) {
            sendDataUseBackupQueue(transferDataMqMsgCache.getTransferData());
            return;
        }

        // 判断是否是从备用通道失败的消息,如果是从备用通道来的消息,备用通道都发送不成功,此时就说明有问题了,需要告警了
        if (Objects.nonNull(transferDataMqMsgCache) && transferDataMqMsgCache.isBackupMsg()) {
            // 备用通道没发送成功,发送一个严重错误消息给监控端,自动告警通知运维人员
            sendErrorMsgToMonitor(taskId, "备用通道发送失败-" + cause);
            return;
        }

        // 消息未发送成功,并且从主通道缓存和备用通道都没有获取到该消息任务,说明出现了逻辑错误,也得发送警告信息,并且告警
        sendErrorMsgToMonitor(taskId, "未知的消息确认");
    }   
}

上面代码删除了一些不必要的逻辑,只贴出了备用通道自动补偿的代码,整体逻辑就是先使用主通道发送,发送后将数据存于缓存,发送成功则记录日志变更数据状态,发送失败则从缓存中取出来使用备用通道再发一次,如果备用通道也发送失败则需要告警,人工补偿,这样至少在发送到交换机这一层能够保证数据不会因为程序丢失。

实验例子:

我们在发送的时候,将主通道的交换机名称和队列名称进行一定修改,造成它们无法绑定最后结果就为:

# 主通道投递失败
ERROR o.s.a.r.c.CachingConnectionFactory - [log,748] - Shutdown Signal: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'data_send_exchangesss' in vhost '/', class-id=60, method-id=40)
ERROR c.r.c.MQConfirmAndReturnCallback - [confirm,47] - 消息投递失败,id:ZC_TX_6c92de7d78774dc3b99379810d80b02c,cause:channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'data_send_exchangesss' in vhost '/', class-id=60, method-id=40)
WARN  c.r.c.s.ConnectorService - [sendWarnMsgToMonitor,147] - 任务ZC_TX_6c92de7d78774dc3b99379810d80b02c,发送失败,原因:channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'data_send_exchangesss' in vhost '/', class-id=60, method-id=40)
# 使用备用通道发送
WARN  c.r.c.s.ConnectorService - [sendDataUseBackupQueue,122] - 使用备用通道发送数据任务:ZC_TX_6c92de7d78774dc3b99379810d80b02c
INFO  c.r.c.MQConfirmAndReturnCallback - [confirm,45] - 交换机已经收到 id 为:ZC_TX_6c92de7d78774dc3b99379810d80b02c的消息

但是这样也有了一个问题,就是数据顺序的问题,如,现在有数据1,2,3,4,5,6,7。如果始终发送到一个通道的话 ,那么消费的时候也是一个通道顺序消费不会出现数据顺序错乱。

当使用了备用通道则需要考虑顺序问题:

主通道发送了1,2,3;发送4的时候发现主通道故障使用备用通道发送,备用通道能够发送,但是并且主通道由于数据量过大造成堵塞,导致备用通道数据4先到达了消费者,造成了消费顺序错乱,因为消费者要消费两个通道的数据,所以接收的数据不一定是有序的。这种问题就是如何保证消费顺序的问题,下面会具体说明,不止这样会造成,在我们系统中,分布式部署应用也会造成。

要解决顺序问题,有两种方式:

一、备用交换机和主交换机绑定同一个队列,出现了数据顺序问题,根本在于出现了两个队列,导致消费顺序产生了不一致,我们只需要将两个交换机绑定同一个队列即可,而且RabbitMQ也提供了这种机制即绑定的时候使用参数withArgument("alternate-exchange,备用交换机名称)"

二、在消费端保证消费顺序问题。

因为在我们系统中本来就会存在分布式发送数据的问题,因此要在消费端保证消费顺序,所以为了更加可靠,让主通道和备用通道互不影响,我们选择了第二种方案。

数据发送到队列中

上面有说道,RabbitMQ中核心的思想是生产的消息不会直接发送到队列中,而是先发送到交换机,内部由交换机决定发送到哪个队列中。 因此在保证了数据正确发送到交换机之后还得保证数据正确投递到队列中,这一步SpringBoot也帮我们做好了,就是RabbitTemplate.ReturnsCallback接口中的returnedMessage(ReturnedMessage message)方法。该方法会在交换机无法将数据发送到指定队列的时候,将数据回退给生产者,注意,有时候延时队列也会造成该方法被调用,因此要做一定判断处理。

ps:注意该方法根confirm方法不一样,confirm方法无论发送成功或失败,都会被调用,但是returnedMessage只有在消息被退回的时候才会被调用

使用方式如下:

#可以确保消息在未被队列接收时返回
spring.rabbitmq.publisher-returns=true
 /*
    mandatory:交换器无法根据自身类型和路由键找到一个符合条件的队列时的处理方式
    true:RabbitMQ会调用Basic.Return命令将消息返回给生产者
    false:RabbitMQ会把消息直接丢弃
 */
rabbitTemplate.setMandatory(true);  
  // 设置ReturnsCallback
rabbitTemplate.setReturnsCallback((returnMessage)->{
     // 当消息被推柜的时候,回调方法
 });

我们系统对回退消息流程如下:

2022-11-06-11-29-14-image.png 我们对被退回数据的自动补偿策略和config机制一样,使用备用通道发送,这里也特显了我们上面说的为什么要用备用通道而不是备用交换机,因为备用交换机会用相同的策略绑定队列,如果主交换机数据被退回那么备用交换机肯定也会被退回,所以使用备用通道能够很好的满足数据到队列的可靠性保证。

具体代码实现如下:

    public void sendDataReturnedMessage(String taskId) {
        // 判断是否是从主通道被退回的消息
        TransferDataMqMsgCache transferDataMqMsgCache = redisCache.getCacheMapValue(CacheConstants.SEND_DATA_TASK_KEY, taskId);

        // 是从主通道退回的消息,此时使用备用通道发送数据
        if (Objects.nonNull(transferDataMqMsgCache) && !transferDataMqMsgCache.isBackupMsg()) {
            // 使用备用通道补偿
            sendDataUseBackupQueue(transferDataMqMsgCache.getTransferData());
            return;
        }

        // 判断是否是从备用通道退回的消息,如果是从备用通道来的消息,备用通道都发送不成功,此时就说明有问题了,需要告警了
        if (Objects.nonNull(transferDataMqMsgCache) && transferDataMqMsgCache.isBackupMsg()) {
            // 备用通道没发送成功,发送一个严重错误消息给监控端,自动告警通知运维人员
            sendErrorMsgToMonitor(taskId, "数据从交换机发送到队列失败");
            return ;
        }

        // 消息未发送成功,并且从主通道缓存和备用通道都没有获取到该消息任务,说明出现了逻辑错误,也得发送警告信息,并且告警
        sendErrorMsgToMonitor(taskId, "未知的消息被退回");
    }

实验例子:

当我们修改主通道路由,让其无法正确发送到队列

# 被主通道退回
ERROR c.r.c.MQConfirmAndReturnCallback - [returnedMessage,64] -  消 息 被 交 换 机 data_send_exchange 退 回,消息内容 {"content":"作总划则象更深争全再音所车风次干界天对研族二数易九任好又界来长干作革影被又话志地改水你美运断达状色报市还般小第速风向技十增家党细花变就而因红之内解来划接之先也为管确来意选再世周他么团织想院速转族现效表专属华始些值这车便阶适维位统或能给毛亲教称分农子近最多行斯员身热放下约形划按族火准发即酸道容现加指劳低也应际先养打科管真引个党改就及查主律已平元样相界容第增种平事我面见除常七划得类需值门工可团般其白教光集北便安造再高次决海什且新计向质美建龙他达总青共着多约向得么传律斗因代千样期性况其两存等中设外区反总调容马阶府林题列认办科基金实果从了们直此合它京历白点除习备收时部派难音交院色必且火育同除己运米系存产象级群回由收并价术平关什但分示分展单万组离而真给相出感线第影广与以七交统质龙再比管林布上义打别很你强压适也种而同领常价接实义表反指治金公行容问道况构我任展社置方从局么术少单该论重际关型别立月情最候根当其布色身队参调经级马业应员低得你年新即力真引程了机铁周美此联发少他是了万装院员其族已深音太深或两己必支列列备长十下除特便个教调前","taskId":"ZC_TX_dc7827d7252842f79ba4e8effe5bf6cd","waitAck":false,"waitAckTimeout":0},  , 退 回 原 因 :NO_ROUTE, 路 由 key:data_send_keyddd
# 使用备用通道
WARN  c.r.c.s.ConnectorService - [sendDataUseBackupQueue,124] - 使用备用通道发送数据任务:ZC_TX_dc7827d7252842f79ba4e8effe5bf6cd
# 第一条confirm,当数据被退回后,但是数据是到达了交换机的,所以confirm是成功的
INFO  c.r.c.MQConfirmAndReturnCallback - [confirm,45] - 交换机已经收到 id 为:ZC_TX_dc7827d7252842f79ba4e8effe5bf6cd的消息
# 第二条confirm,当数据被退回后,再次使用备用通道发送,数据发送成功,因此confirm也是成功的
INFO  c.r.c.MQConfirmAndReturnCallback - [confirm,45] - 交换机已经收到 id 为:ZC_TX_dc7827d7252842f79ba4e8effe5bf6cd的消息

防止broker故障,消息持久化

上面介绍了消息投递到RabbitMQ,如果投递失败的补偿机制,主要是针对数据在发送的时候,那么如果我们数据已经发送成功,在堆积等待消费的时候,如何保证消息可靠性呢。这时候就得引入消息持久化,顾名思义就是当RabbitMQ服务宕掉后还能重启消息还在不会造成丢失。

(引入个题外话,前两种情况的消息丢失最后导致数据丢失人工干预的情况还是非常少,但是因为没有配置持久化导致消息丢失的问题实实在在给了我们一棒槌。当时是因为一个状态上报的队列,非核心业务没有使用spring框架,使用的amqp-client-{version}.jar)同事直接copy的代码逻辑,没考虑到client默认没有持久化,消费者出现故障宕机了之后,导致queue消息堆积,最后运维同事直接全部重启了,导致queue消息全部丢失,最后人工一条一条对数据,血的教训。

所以综上情景来看,消息持久化是非常重要的,主要就是在消息堆积的时候,如果没有持久化会导致消息丢失。

在消息持久化中要有交换机持久化、队列持久化、消息持久化。在使用SpringBoot+RabbitMQ整合使用中,默认就是持久化的。

在生命交换机、队列等都是可以直接声明是否需要持久化的:

    @Bean
    public Queue DataSendQueue() { 
        // durable持久化,nonDurable非持久化
        return QueueBuilder.durable(QueueEnum.DATA_SEND_QUEUE.getName()).build();
    }

    @Bean
    public DirectExchange dataSendExchange() {
        return ExchangeBuilder
                .directExchange(ExchangeEnum.DATA_SEND_EXCHANGE.getName())
                // durable=true持久化,false非持久化
                .durable(ExchangeEnum.DATA_SEND_EXCHANGE.getDurable())
                .build();
    }

我们知道在RabbitmqTemplate发送数据的时候一般调用其convertAndSend方法,将数据包装成了一个Message对象。

byte[] dataBinary = JSONObject.toJSONString(transferData).getBytes(StandardCharsets.UTF_8);
// 消息包装
Message message = new Message(dataBinary);
MessageProperties messageProperties = message.getMessageProperties();
messageProperties.setMessageId(taskId);

而Message有个重要内部属性就是MessageProperties,它封装了对发送到MQ消息的属性配置,可以通过MessageProperties的源码证明默认持久化。

public MessageProperties() {
    this.deliveryMode = DEFAULT_DELIVERY_MODE; // 默认投递模式为持久化
    this.priority = DEFAULT_PRIORITY; // 默认优先级
}

在持久化消息中还有一层问题就是,当broker接收到了数据的时候,不会立马持久化,考虑到每次消息都持久化会影响处理消息的速度,因此和大多数磁盘操作组件一样,引入了缓存机制在具体写入磁盘前会先写入到缓存中,缓存大小默认为1M,如果缓存满了或者一定时间周期才将缓存的数据刷盘,因此在数据收到和刷盘期间还有个短暂的时间数据在内存中。

为了防止在这个短暂期间因为broker宕机,导致缓存数据丢失通常会引入mirrored-queue镜像队列,做集群在消息到达broker马上同步到其它broker,即使master broker宕掉后,消息还在slave中,能够正常工作。

保证数据消费成功

ACK机制

消费端数据丢失主要发生在,接收到了MQ的消息后,处理失败后没有任何处理,随后MQ就将数据给删除了,给生产者造成了数据发送成功的假象,最后实际未发送成功,但是MQ里面已经没有数据,导致数据丢失。

防止数据消费丢失的最有效方法就是签收机制(ACK),在RabbitMQ中提供了两种签收分别是自动ACK和手动ACK。

自动ACK: 消费者配置中如果是自动ack机制,MQ将消息发送给消费者后直接就将消息给删除了,这个的前提条件是消费者程序没有出现异常,如果消费者接收消息后处理时出现异常,那么MQ将会尝试重发消息给消费者直至达到了消费者服务中配置的最大重试次数后将会直接抛出异常不再重试。

手动ACK:消费者设置了手动ACK机制后,可以显式的提交/拒绝消息(这一步骤叫做发送ACK),如果消息被消费后正常被提交了ack,那么此消息可以说是流程走完了,然后MQ将此消息从队列中删除。而如果消息被消费后被拒绝了,消费者可选择让MQ重发此消息或者让MQ直接移除此消息。后面可以使用死信队列来进行接收这些被消费者拒绝的消息,再进行后续的业务处理。

一般我们建议在消费端,使用手动ACK,让开发者明确每一条数据的处理状态和异常处理。

2022-11-06-12-55-31-image.png 手动签收的使用步骤:

修改配置默认rabbitmqlistener为手动签收:spring.rabbitmq.listener.simple.acknowledge-mode=manual

手动签收方法

    确认签收:channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);// 参数1:数据的标识;参数2:是否批量签收

    拒绝签收:channel.basicNack(deliveryTag, false, true);// 参数1:数据的标识;参数2:是否批量签收;数据3:是否重新丢回丢列,true重入队列,false丢失数据。

自动重试补偿机制

上面介绍了消费者ACK,在一般的业务中使用ACK机制已经能够解决大部分问题,核心业务一般都是要自定义一些异常处理机制,补偿等让消费数据更加可靠,接下来看一下在我们签收的流程:

2022-11-06-12-51-49-image.png 上面可以看到,最后我们数据失败后会放入到一个补偿队列,这个补偿队列主要做数据校验等一系列对数据消费失败的原因分析,为什么要在这里做呢?就是因为如果每次我们消费的时候就执行这一套数据校验是非常耗时的,影响了正常正确数据的消费速度。


主要代码实现:

    // 处理主通道和备用通道发送来的数据
    @RabbitListener(queues = {"data_send_queue", "data_send_backup_queue"})
    public void transferDataConsumer(Message message, Channel channel) {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        String content = new String(message.getBody(), StandardCharsets.UTF_8);
        TransferData transferData = JSONObject.parseObject(content, TransferData.class);
        try {
            log.info("收到了正文:" + content);
            // .... 执行一系列逻辑

            // 投递成功后清除数据
            cleanAtReceiveOk(transferData);
        } catch (Exception e) {
            // 出现错误:
            // 清理执行数据造成的脏数据
            rollbackTransferData(transferData);
            // 尝试重新投递
            tryAgainSend(transferData);
             putErrorLog(e);
        } finally {
            try {
                channel.basicAck(deliveryTag, false);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }



    private void tryAgainSend(TransferData transferData) {
        // 失败次数
        Integer failCount = redisCache.getCacheMapValue(CacheConstants.RECEIVE_DATA_TASK_FAIL_KEY, transferData.getTaskId());
        if (Objects.nonNull(failCount) && failCount == TRANSFER_DATA_RECEIVE_MAX_FAIL_COUNT) {
            log.error("任务:{}  已经达到了最大失败次数", transferData.getTaskId());
            // 删除缓存
            redisCache.deleteCacheMapValue(CacheConstants.RECEIVE_DATA_TASK_FAIL_KEY, transferData.getTaskId());
            // 发送到错误队列中
            sendToErrorQueue(transferData);
            return;
        }
        // 失败次数+1
        failCount = ObjectUtils.defaultIfNull(failCount, 0);
        redisCache.setCacheMapValue(CacheConstants.RECEIVE_DATA_TASK_FAIL_KEY, transferData.getTaskId(), ++failCount);
        log.info("即将重新投递,投递次数 {} 次数 {}", transferData.getTaskId(), failCount);
        // 异步延时投递
        ansyncDelayedAgainSend(transferData);
    }

上面贴出了我们主要的处理逻辑代码,其中对发生了异常的处理主要有两个地方:

  • 异步延时重新投递到队尾

    异步延时投递主要防止因为网络波动或者某些随机原因造成的偶然异常。

  • 发送到错误队列中

    发送到错误队列是为了尽可能的减少人工补偿,尽可能的对发生错误的数据进行程序校对验证,如果能够用程序解决的就重新投递到队列中让其正常处理,实在处理不了的再交由人工处理。很多时候这一部分也交给死信队列去处理。

有的人可能有疑惑,这条数据都已经执行错误了,为什么还要多次尝试重新投递到队尾中去;做这一点主要是防止因为rpc调用网络波动或者某些随机原因导致的数据处理失败。(ps:说个题外话,我们曾经就出现过这种情况,当时处理一个数据任务的时候,查询某个元数据不存在,导致数据处理失败,而我们的元数据是按照策略同步到本地的,当时正好在执行同步,将原来的元数据给删了,然后重试的时候也同步完成了,就处理了这条数据。我们的策略是15s后重新投递,最大失败次数是5次,就75s,一分多钟完全可以排除掉大部分随机失败原因。)

保证数据一定产生业务结果

在上面我们很好的介绍了数据生产到消费的整个过程,并利用一系列补偿机制从而保证了数据的可靠性,但是在很多唯一性业务中,光保证了数据传递过程中的可靠性还不行,还得保证数据的正确性,比如我们发送了一条数据出去,那么一定就得产生一个结果,无论是好的还是坏的,比如我们下了个订单,最后我们无论是收货成功,还是退货,最终都得产生一个结果告知给发送者。这种情况发生在生产者无法相信消费者的情况下。

比如我们生产者数据发送出去后,消费者没有做好数据可靠性处理,从而导致数据丢失这种情况,作为生产者他并不知道消费者是否能够处理好这条数据,那么这时候就需要消费者将这条数据消费的结果发送给生产者,生产者来做判断是否要调整数据,如果在一定时间内未收到消费者反馈视为消费者丢失了数据需要重发。

这种情况发生在生产者和消费者不信任的情况下,我们一般有两种方式来处理,一种是同步的,必须等待消费者将任务处理完后再签收,然后生产者根据发送的数据id去公共的存储地查询消费后的状态。一种是异步的就是异步扫描该任务状态是否被更新,如果更新了则视为任务处理过了。

我们通常采用的机制就是异步扫描机制,如果我们的任务在一定期限内没有更新状态就视为了丢失,进行重发,重发多次还是没有状态就得人工补偿。

大致流程如下,在任务顺利的情况下,会经历created(已创建)->processing(处理中)->accepted(已受理)->finished(处理完成)|failed(处理失败)

2022-11-10-21-31-40-image.png 为什么修改状态要用状态队列,而不是直接修改缓存或者数据库?

这也是为了解耦削峰的目的,在一个大型任务中会存在非常多的状态,如果在业务流程中修改难免会造成一定的业务资源消耗(阻塞),而且修改后的状态不能及时通知到监控端,因此单独使用了一个状态队列和消费者来处理。并且这样处理还可以提高内聚性,让修改任务状态控制在指定几个入口,也方便排查错误,否则在每个业务节点都去修改会造成发散性修改,不利于问题排查。

注意:这种场景适用于业务消费者消费数据一定是发生在状态被消费之后的情况下,否则会导致状态还未修改,就直接消费了任务,没有获取到状态导致消费失败。

任务监控中心的作用?任务监控作用是为了扫描任务,如每天中午或者凌晨在业务冷淡期进行扫描,并获取出异常任务进行报警或通知到人工等操作。

总结

上面围绕MQ中间件大致介绍了如何在使用MQ的情况下从数据发送到数据消费处理这一过程的数据可靠性保障,通常在整个业务领域中,使用MQ主要是在业务流量瓶颈处进行异步削峰的作用,但是他并不是贯穿了整个业务线,在整个业务线它也只是其中的一部分,因此要保证数据的可靠性,不丢失还得在业务的上下游进行相关备份、状态记录等操作。如上面介绍的监控系统,它就是其中一种手段,用于记录错误数据和处理不了的数据。

如我们常见的商城订单,加入我们创建一个订单丢入MQ中,然后让消费者处理这个订单,处理完后并不代表这个订单已经结束了,它在订单执行过程中可能经历退货,换货等很多操作,在这些操作中我们不能将数据完全信任的交由业务流程。

比较好的实践就是建立一个统一的数据中心,每一节点的业务处理都是从数据中心获取副本进行处理,最后更新数据中心,如果出现了问题由监控中心统一处理,通过程序补偿或者人工补偿的机制。但是得注意在某些业务中能够让多个业务方处理中,又得考虑分布式事务、数据中心高可用等相关问题,继续延伸下去还有更多问题,后面有空再说。

下面贴一张我们业务的整体架构

2022-11-08-21-04-24-image.png