一、前言
我们在上一章节已经实现了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 技术来实现高可用的效果。