幂等性

消费者在消费mq中的消息时,mq已把消息发送给消费者,消费者在给mq返回ack时网络中断,故mq未收到确认信息,该条消息会重新发给其他的消费者,或者在网络重连后再次发送给该消费者,但实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息;

解决办法

MQ消费者的幂等行的解决一般使用全局ID 或者写个唯一标识比如时间戳 或者UUID 或者订单

消费者消费mq中的消息:也可利用mq的该id来判断,或者可按自己的规则生成一个全局唯一id,每次消费消息时用该id先判断该消息是否已消费过。

幂等性,不仅对MQ有要求,对业务上下游也有要求。

生产者代码:

import java.util.UUID;

import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageBuilder;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.alibaba.fastjson.JSONObject;

@Component
public class FanoutProducer {
    @Autowired
    private AmqpTemplate amqpTemplate;

    public void send(String queueName) {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("email", "xx@163.com");
        jsonObject.put("timestamp", System.currentTimeMillis());
        String jsonString = jsonObject.toJSONString();
        System.out.println("jsonString:" + jsonString);
        // 设置消息唯一id 保证每次重试消息id唯一  
        Message message = MessageBuilder.withBody(jsonString.getBytes())
                .setContentType(MessageProperties.CONTENT_TYPE_JSON).setContentEncoding("utf-8")
                .setMessageId(UUID.randomUUID() + "").build(); //消息id设置在请求头里面 用UUID做全局ID 
        amqpTemplate.convertAndSend(queueName, message);
    }
}

消费者代码:

@RabbitListener(queues = "fanout_email_queue")
public void process(Message message) throws Exception {
     // 获取消息Id
     String messageId = message.getMessageProperties().getMessageId();  //id获取之
     if (messageId == null) {
            return;
     }
	 String redisMsgId = ...//根据messageId去redis中查找消息
	 if(未找到reidsMsgId) {
		 String msg = new String(message.getBody(), "UTF-8"); //消息内容获取之
	     System.out.println("-----邮件消费者获取生产者消息-----------------" + "messageId:" + messageId + ",消息内容:" + msg);
	     
	     JSONObject jsonObject = JSONObject.parseObject(msg);
	     // 获取email参数
	     String email = jsonObject.getString("email");
	     // 请求地址
	     String emailUrl = "http://127.0.0.1:8083/sendEmail?email=" + email;
	     JSONObject result = HttpClientUtils.httpGet(emailUrl);
	     if (result == null) {
	     	// 因为网络原因,造成无法访问,继续重试
	     	throw new Exception("调用接口失败!");
	     }
	     System.out.println("执行结束....");
	     //写入到redis中
	}
     
}

给消息分配一个全局id,只要消费过该消息,将 < id,message>以K-V形式写入redis。那消费者开始消费前,先去redis中查询有没消费记录即可。

利用Redis的原子性去实现

我们都知道redis是单线程的,并且性能也非常好,提供了很多原子性的命令。比如可以使用 setnx 命令。
在接收到消息后将消息ID作为key执行 setnx 命令,如果执行成功就表示没有处理过这条消息,可以进行消费了,执行失败表示消息已经被消费了。

rabbitMQ实现可靠消息投递RabbitMQ消息中间件技术

重试机制

默认情况下,如果消费者程序出现异常情况, Rabbitmq 会自动实现补偿机制 也就是重试机制

@RabbitListener底层使用AOP进行拦截,如果程序没有抛出异常,自动提交事务。 如果Aop使用异常通知拦截获取异常信息的话 , 自动实现补偿机制,该消息会一直缓存在Rabbitmq服务器端进行重放,一直重试到不抛出异常为准。

一般来说默认5s重试一次,可以修改重试策略,消费者配置:

listener:
      simple:
        retry:
        ####开启消费者重试
          enabled: true
         ####最大重试次数(默认无数次)
          max-attempts: 5
        ####重试间隔次数
          initial-interval: 3000

重试5次,不行就放弃

关于应答模式:

Spring boot 中进行 AOP拦截 自动帮助做重试

手动应答的话 ,如果不告诉服务器已经消费成功,则服务器不会删除消息。告诉消费成功了才会删除。

消费者的yml加入:acknowledge-mode: manual

rabbitmq可靠发送的自动重试机制

使用异步消息时如何保证数据的一致性

业务逻辑先更新数据库,然后把结果投递到消息队列 RocketMQ 中。如果业务逻辑更新完成,但是消息投递失败,这种情况应该怎么处理?

生产端要保障的是消息发出去,RabbitMQ的Borker收到并且返会确认收到,由于网络的原因消息发送Borker的时候可能失败,另外Borker返回给生产者确认的时候也可能发生闪断,所以为了保障100%投递我们还需要合理的补偿机制。

这种情况其实就是在使用异步消息的时候怎么保证数据的一致性,有一个比较简单的做法,就是借助数据库事务来保证。

使用异步消息怎么还能借助到数据库事务?这需要在数据库中创建一个本地消息表,这样可以通过一个事务来控制本地业务逻辑更新和本地消息表的写入在同一个事务中,一旦消息落库失败,则直接全部回滚。如果消息落库成功,后续就可以根据情况基于本地数据库中的消息数据对消息进行重投了。

关于本地消息表和消息队列中状态如何保持一致,可以采用 2PC 的方式。在发消息之前落库,然后发消息,在得到同步结果或者消息回调的时候更新本地数据库表中消息状态。然后只需要通过定时轮询的方式对状态未已记录但是未发送的消息重新投递就行了。但是这种方案有个前提,就是要求消息的消费者做好幂等控制,这个其实异步消息的消费者一般都需要考虑的。

由于消息投递失败的概率比较低,没有直接设置定时钟来轮询重投,而是配置了一个监控报警,有问题的时候人工处理下就行了。如以下是我们自己使用的本地消息表的服务,其中提供了很多方法。主要包含以下方法:

public interface TransactionMessageService {
    /**
     * 预存储消息.
     */
    TransactionMsgOperateResult saveMessageWaitingConfirm(TransactionMessageDTO transactionMessageDTO);

    /**
     * 确认消息.
     */
    TransactionMsgOperateResult confirmMessage(Long msgDoId);

    /**
     * 确认并且尽最大努力发送消息
     *
     * @param msgDoId
     * @return
     */
    TransactionMsgOperateResult confirmAndTrySendMessage(Long msgDoId);

    /**
     * 存储并确认消息.
     */
    TransactionMsgOperateResult saveAndConfirmMessage(TransactionMessageDTO transactionMessageDTO);

    /**
     * 发送并存储消息。先尝试发消息,然后在落库
     */
    TransactionMsgOperateResult saveAndSendMessage(TransactionMessageDTO transactionMessageDTO);

    /**
     * 存储并且尽最大努力发送消息
     */
    TransactionMsgOperateResult saveAndTrySendMessage(TransactionMessageDTO transactionMessageDTO);

    /**
     * 直接发送消息.
     */
    TransactionMsgOperateResult directSendMessage(TransactionMessageDTO transactionMessageDTO);

    /**
     * 重发消息.
     */
    TransactionMsgOperateResult reSendMessage(Long msgDoId);

    /**
     * 查询
     *
     * @param id
     * @return
     */
    TransactionMessageDO getTransactionMessageById(Long id);

    TransactionMessageDO getByMsgId(String messageId);

    List<TransactionMessageDO> queryByStatus(String status);

}

除了使用数据库以外,还可以使用 Redis 等缓存。这样就是无法利用关系型数据库自带的事务回滚了。