一、前言

我们在上一章节已经实现了SpringBoot集成RabbitMQ实现邮件异步发送,可以保证发出的消息 100% 被消费,但是其实也有弊端。试想一下,按照上面的处理逻辑,假设其中有一条消息,因为某种原因一直发送失败,会出现什么样的情况?此时,这条消息会重新返回队列,然后一直重试,会导致其它的消息可能会无法被消费。

针对这种情况,最简单粗暴的办法就是,当重试失败之后将消息丢弃,不会阻碍其它的消息被正常处理,不过会丢失数据。那么如何正确的处理消息消费失败的问题呢?可以借助数据库来记录消费失败的数据,针对系统无法成功处理的消息,人工进行干预。

上一章链接:https://blog.51cto.com/u_13312531/11980248

二、项目实践

1.引入依赖并进行项目配置

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
	<groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>8.0.31</version>
</dependency>
 <!--joda time-->
<dependency>
	<groupId>joda-time</groupId>
	<artifactId>joda-time</artifactId>
	<version>2.10</version>
</dependency>
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/my_db?useUnicode=true&characterEncoding=UTF-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

2.创建数据库实体类

package com.example.dataproject.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.Date;

/**
 * @author qx
 * @date 2024/9/11
 * @des 实体类
 */
@Entity
@Table(name = "t_msg_log")
@Getter
@Setter
public class MsgLog {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * 唯一标识ID
     */
    private String msgId;
    
    /**
     * 交换器
     */
    private String exchange;
    /**
     * 路由键
     */
    private String routeKey;

    /**
     * 队列名称
     */
    private String queueName;
    /**
     * 消息体
     */
    private String msg;

    /**
     * 处理结果
     */
    private String result;
    /**
     * 状态,0:等待消费,1:消费成功,2:消费失败,3:重试失败
     */
    private Integer status;

    /**
     * 重试次数
     */
    private Integer tryCount;

    /**
     * 下一次重试时间
     */
    private Date nextTryTime;

    /**
     * 创建时间
     */
    private Date createTime;

    /**
     * 更新时间
     */
    private Date updateTime;

}

3.创建数据持久层和服务层

package com.example.dataproject.dao;

import com.example.dataproject.entity.MsgLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

/**
 * @author qx
 * @date 2024/9/11
 * @des
 */
public interface MsgLogDao extends JpaRepository<MsgLog, Long> {

    MsgLog findByMsgId(String msgId);

    @Query(nativeQuery = true, value = "select * from t_msg_log where status=?1 and next_try_time<=now()")
    List<MsgLog> findByStatus(Integer status);

}
package com.example.dataproject.service;

import com.example.dataproject.common.MsgConstant;
import com.example.dataproject.dao.MsgLogDao;
import com.example.dataproject.entity.Mail;
import com.example.dataproject.entity.MsgLog;
import com.example.dataproject.utils.JodaTimeUtil;
import com.example.dataproject.utils.JsonUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Calendar;
import java.util.Date;
import java.util.List;

/**
 * @author qx
 * @date 2024/9/11
 * @des
 */
@Service
public class MsgLogService {

    @Autowired
    private MsgLogDao msgLogDao;

    public void insert(String msgId, Mail mail) {
        MsgLog msgLog = new MsgLog();
        msgLog.setExchange("mail.exchange");
        msgLog.setRouteKey("route.mail.ack");
        msgLog.setQueueName("mq.mail.ack");
        msgLog.setMsgId(msgId);
        msgLog.setMsg(JsonUtil.objToStr(mail));
        Date now = new Date();
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(now);
        calendar.add(Calendar.MINUTE, 1);
        Date nextTime = calendar.getTime();
        msgLog.setNextTryTime(nextTime);
        msgLog.setCreateTime(now);
        msgLog.setStatus(MsgConstant.WAIT_CONSUMER);
        msgLog.setTryCount(0);
        msgLog.setUpdateTime(now);
        msgLogDao.save(msgLog);
    }

    public void updateStatus(MsgLog msgLog) {
        MsgLog log = msgLogDao.findByMsgId(msgLog.getMsgId());
        if (log != null) {
            log.setStatus(msgLog.getStatus());
            log.setResult(msgLog.getResult());
            log.setUpdateTime(new Date());
            msgLogDao.save(log);
        }
    }

    public void updateTryCount(MsgLog msgLog) {
        MsgLog log = msgLogDao.findByMsgId(msgLog.getMsgId());
        if (log != null) {
            msgLog.setTryCount(msgLog.getTryCount() + 1);
            msgLog.setNextTryTime(msgLog.getNextTryTime());
            msgLog.setUpdateTime(new Date());
            msgLogDao.save(msgLog);
        }

    }

    public void updateNextTryTime(String msgId, Integer currentTryCount) {
        MsgLog msgLog = new MsgLog();
        msgLog.setMsgId(msgId);
        msgLog.setNextTryTime(JodaTimeUtil.plusMinutes(new Date(), currentTryCount));
        updateTryCount(msgLog);
    }

    public List<MsgLog> selectFailMsg() {
        return msgLogDao.findByStatus(MsgConstant.FAIL);
    }

    public MsgLog selectByPrimaryKey(String msgId) {
        return msgLogDao.findByMsgId(msgId);
    }

}

4.改写生产者逻辑

在生产者服务类中,先将消息数据写入数据库,再向 rabbitMQ 服务中发消息,示例如下:

package com.example.dataproject.service;

import com.example.dataproject.entity.Mail;
import com.example.dataproject.entity.MsgLog;
import com.example.dataproject.utils.JsonUtil;
import com.example.dataproject.utils.MessageHelper;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageBuilder;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.UUID;

/**
 * @author qx
 * @date 2024/9/11
 * @des 生产者服务
 */
@Service
public class ProduceService {

    @Autowired
    private RabbitTemplate rabbitTemplate;
    @Autowired
    private MsgLogService msgLogService;

    public boolean sendData(Mail mail) {
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        mail.setMsgId(uuid);

        //存储到数据库
        msgLogService.insert(uuid, mail);

        CorrelationData correlationData = new CorrelationData(uuid);
        rabbitTemplate.convertAndSend("mail.exchange", "route.mail.ack", MessageHelper.objToMsg(mail), correlationData);
        return true;
    }

}

5.改写消费者逻辑

在消费者服务类中,收到消息之后,不管处理成功还是失败,都只会修改数据库中的消息状态,并且消息处理失败时,不再重新返回队列。

package com.example.dataproject.service;

import com.example.dataproject.common.MsgConstant;
import com.example.dataproject.entity.Mail;
import com.example.dataproject.entity.MsgLog;
import com.example.dataproject.utils.MessageHelper;
import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.Objects;

/**
 * @author qx
 * @date 2024/9/11
 * @des 消费者
 */
@Component
@Slf4j
public class ConsumerService {

    @Autowired
    private SendMailService sendMailService;
    @Autowired
    private MsgLogService msgLogService;

    @RabbitListener(queues = {"mq.mail.ack"})
    public void consume(Message message, Channel channel) throws IOException {
        log.info("收到消息:{}", message);
        Mail mail = MessageHelper.msgToObj(message, Mail.class);

        //消息幂等性处理,如果已经处理成功,无需重复消费
        MsgLog msgLog = msgLogService.selectByPrimaryKey(mail.getMsgId());
        if (Objects.nonNull(msgLog) && msgLog.getStatus().equals(MsgConstant.SUCCESS)) {
            return;
        }
        //发送邮件
        boolean falg = sendMailService.send(mail);
        MsgLog log;
        if (falg) {
            log = new MsgLog();
            log.setMsgId(mail.getMsgId());
            log.setStatus(MsgConstant.SUCCESS);
            log.setResult("邮件发送成功");
        } else {
            log = new MsgLog();
            log.setMsgId(mail.getMsgId());
            log.setStatus(MsgConstant.FAIL);
            log.setResult("邮件发送失败");
        }
        msgLogService.updateStatus(log);
        //手动确认模式
        /*long tag = message.getMessageProperties().getDeliveryTag();
        if (falg) {
            //发送成功 删除消息
            channel.basicAck(tag, falg);
        } else {
            //发送失败 重试返回消息队列
            channel.basicNack(tag, falg, true);
        }*/
    }

}

因为此处采用自动确认模式,因此还需要修改application.properties中的配置参数,内容如下:

spring.rabbitmq.listener.simple.acknowledge-mode=auto

6.编写定时任务对失败消息进行补偿投递

当消息消费失败时,会自动记录到数据库。

实际上,不可能每条数据都需要我们进行干预,有的可能重试一次就好了,因此可以编写一个定时任务,将消费失败的数据筛选出来,重新放入到消息队列中,只有当消费次数达到设置的最大值,此时进入人工干预阶段,可以节省不少的工作。

package com.example.dataproject.task;

import com.example.dataproject.common.MsgConstant;
import com.example.dataproject.entity.MsgLog;
import com.example.dataproject.service.MsgLogService;
import com.example.dataproject.utils.MessageHelper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * @author qx
 * @date 2024/9/11
 * @des
 */
@Component
@Slf4j
public class ScheduledTask {

    //最大投递次数
    private static final Integer MAX_TRY_COUNT = 3;

    @Autowired
    private MsgLogService msgLogService;

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 每30s拉取消费失败的消息,重新投递
     */
    @Scheduled(cron = "0/30 * * * * ?")
    public void retry() {
        log.info("开始执行重新投递消费失败的消息!");
        //查询需要重新投递的消息
        List<MsgLog> msgLogs = msgLogService.selectFailMsg();
        for (MsgLog msgLog : msgLogs) {
            if (msgLog.getTryCount() >= MAX_TRY_COUNT) {
                msgLog.setStatus(MsgConstant.RETRY_FAIL);
                msgLogService.updateStatus(msgLog);
                log.info("超过最大重试次数,msgId:{}", msgLog.getMsgId());
                break;
            }

            CorrelationData correlationData = new CorrelationData(msgLog.getMsgId());
            rabbitTemplate.convertAndSend(msgLog.getExchange(), msgLog.getRouteKey(), MessageHelper.objToMsg(msgLog.getMsg()), correlationData);
            //更新下次重试时间
            msgLogService.updateNextTryTime(msgLog.getMsgId(), msgLog.getTryCount());
        }
    }
}

相关工具类:

package com.example.dataproject.utils;

import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;

import java.util.Date;

/**
 * @author qx
 * @date 2024/9/11
 * @des
 */
public class JodaTimeUtil {
    private static final String STANDARD_FORMAT = "yyyy-MM-dd HH:mm:ss";

    /**
     * date类型 -> string类型
     *
     * @param date
     * @return
     */
    public static String dateToStr(Date date) {
        return dateToStr(date, STANDARD_FORMAT);
    }

    /**
     * date类型 -> string类型
     *
     * @param date
     * @param format 自定义日期格式
     * @return
     */
    public static String dateToStr(Date date, String format) {
        if (date == null) {
            return null;
        }

        format = StringUtils.isBlank(format) ? STANDARD_FORMAT : format;
        DateTime dateTime = new DateTime(date);
        return dateTime.toString(format);
    }

    /**
     * string类型 -> date类型
     *
     * @param timeStr
     * @return
     */
    public static Date strToDate(String timeStr) {
        return strToDate(timeStr, STANDARD_FORMAT);
    }

    /**
     * string类型 -> date类型
     *
     * @param timeStr
     * @param format  自定义日期格式
     * @return
     */
    public static Date strToDate(String timeStr, String format) {
        if (StringUtils.isBlank(timeStr)) {
            return null;
        }

        format = StringUtils.isBlank(format) ? STANDARD_FORMAT : format;

        DateTimeFormatter dateTimeFormatter = DateTimeFormat.forPattern(format);
        DateTime dateTime;
        try {
            dateTime = dateTimeFormatter.parseDateTime(timeStr);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }

        return dateTime.toDate();
    }

    /**
     * 判断date日期是否过期(与当前时刻比较)
     *
     * @param date
     * @return
     */
    public static Boolean isTimeExpired(Date date) {
        String timeStr = dateToStr(date);
        return isBeforeNow(timeStr);
    }

    /**
     * 判断date日期是否过期(与当前时刻比较)
     *
     * @param timeStr
     * @return
     */
    public static Boolean isTimeExpired(String timeStr) {
        if (StringUtils.isBlank(timeStr)) {
            return true;
        }

        return isBeforeNow(timeStr);
    }

    /**
     * 判断timeStr是否在当前时刻之前
     *
     * @param timeStr
     * @return
     */
    private static Boolean isBeforeNow(String timeStr) {
        DateTimeFormatter format = DateTimeFormat.forPattern(STANDARD_FORMAT);
        DateTime dateTime;
        try {
            dateTime = DateTime.parse(timeStr, format);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
        return dateTime.isBeforeNow();
    }

    /**
     * 日期加天数
     *
     * @param date
     * @param days
     * @return
     */
    public static Date plusDays(Date date, int days) {
        return plusOrMinusDays(date, days, 0);
    }

    /**
     * 日期减天数
     *
     * @param date
     * @param days
     * @return
     */
    public static Date minusDays(Date date, int days) {
        return plusOrMinusDays(date, days, 1);
    }

    /**
     * 加减天数
     *
     * @param date
     * @param days
     * @param type 0:加天数 1:减天数
     * @return
     */
    private static Date plusOrMinusDays(Date date, int days, Integer type) {
        if (null == date) {
            return null;
        }

        DateTime dateTime = new DateTime(date);
        if (type == 0) {
            dateTime = dateTime.plusDays(days);
        } else {
            dateTime = dateTime.minusDays(days);
        }

        return dateTime.toDate();
    }

    /**
     * 日期加分钟
     *
     * @param date
     * @param minutes
     * @return
     */
    public static Date plusMinutes(Date date, int minutes) {
        return plusOrMinusMinutes(date, minutes, 0);
    }

    /**
     * 日期减分钟
     *
     * @param date
     * @param minutes
     * @return
     */
    public static Date minusMinutes(Date date, int minutes) {
        return plusOrMinusMinutes(date, minutes, 1);
    }

    /**
     * 加减分钟
     *
     * @param date
     * @param minutes
     * @param type    0:加分钟 1:减分钟
     * @return
     */
    private static Date plusOrMinusMinutes(Date date, int minutes, Integer type) {
        if (null == date) {
            return null;
        }

        DateTime dateTime = new DateTime(date);
        if (type == 0) {
            dateTime = dateTime.plusMinutes(minutes);
        } else {
            dateTime = dateTime.minusMinutes(minutes);
        }

        return dateTime.toDate();
    }

    /**
     * 日期加月份
     *
     * @param date
     * @param months
     * @return
     */
    public static Date plusMonths(Date date, int months) {
        return plusOrMinusMonths(date, months, 0);
    }

    /**
     * 日期减月份
     *
     * @param date
     * @param months
     * @return
     */
    public static Date minusMonths(Date date, int months) {
        return plusOrMinusMonths(date, months, 1);
    }

    /**
     * 加减月份
     *
     * @param date
     * @param months
     * @param type   0:加月份 1:减月份
     * @return
     */
    private static Date plusOrMinusMonths(Date date, int months, Integer type) {
        if (null == date) {
            return null;
        }

        DateTime dateTime = new DateTime(date);
        if (type == 0) {
            dateTime = dateTime.plusMonths(months);
        } else {
            dateTime = dateTime.minusMonths(months);
        }

        return dateTime.toDate();
    }

    /**
     * 判断target是否在开始和结束时间之间
     *
     * @param target
     * @param startTime
     * @param endTime
     * @return
     */
    public static Boolean isBetweenStartAndEndTime(Date target, Date startTime, Date endTime) {
        if (null == target || null == startTime || null == endTime) {
            return false;
        }

        DateTime dateTime = new DateTime(target);
        return dateTime.isAfter(startTime.getTime()) && dateTime.isBefore(endTime.getTime());
    }
}

相关常量类

package com.example.dataproject.common;

/**
 * @author qx
 * @date 2024/9/11
 * @des
 */
public class MsgConstant {

    public static final Integer WAIT_CONSUMER = 0;

    public static final Integer SUCCESS = 1;

    public static final Integer FAIL = 2;

    public static final Integer RETRY_FAIL = 3;

}

最后别忘了,在Application类上添加@EnableScheduling,以便让定时调度生效,示例如下:

package com.example.dataproject;


import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;


@SpringBootApplication
@EnableAsync
@EnableScheduling
public class DataProjectApplication {

    public static void main(String[] args) {
        SpringApplication.run(DataProjectApplication.class, args);
    }

}

利用定时任务,对投递失败的消息进行补偿投递,基本可以保证消息 100% 消费成功!

7.测试

启动程序进行测试。

SpringBoot集成RabbitMQ实现邮件异步发送进阶_RabbitMQ

我们从控制台上看到已经成功发送了邮件。

SpringBoot集成RabbitMQ实现邮件异步发送进阶_SpringBoot_02

并且在数据表中成功更新了邮件发送的状态。

SpringBoot集成RabbitMQ实现邮件异步发送进阶_高可用_03

邮件详情:

SpringBoot集成RabbitMQ实现邮件异步发送进阶_RabbitMQ_04

三、小结

本文主要以实现邮件自动推送这个业务场景为例,通过 Springboot 整合 rabbitMQ 技术来实现高可用的效果。