文章目录
RabbitMq项目实战
实战一:使用延迟队列和备份队列实现考试信息定时存储
需求分析:
在线考试系统:
考生考试——随机生成试卷——考生规定时间内答卷——提交试卷——计算分数——考生每道题目入库
分析:
假如考试时间为2个小时,考生生成试卷后,如果遇到掉线或者其他情况未能在规定时间内交卷的,系统默认交卷并计算成绩和题目入库。并通过微信告诉学生,您的试卷已经自动提交。
实现方式一(不推荐):通过死信队列实现延迟队列
将过期消息入死信队列,消费死信队列。
创建交换机和队列
package marchsoft.modules.rabbitmq.examprocess;
import lombok.Getter;
@Getter
public enum ExamProcessEnum {
/**
* 计算考试成绩队列(死信队列)
*/
QUEUE_EXAM_PROCESS("exam.process.direct", "exam.process.computed", "exam.process.computed"),
/**
* 考级结果计算通知ttl队列
*/
QUEUE_TTL_PROCESS_COMPUTED("exam.process.direct.ttl", "exam.process.computed.ttl", "exam.process.computed.ttl"),
/**
* 计算考试成绩队列(备份队列)
*/
QUEUE_BACKUP_PROCESS("exam.process.direct.backup", "exam.process.computed.backup", ""),
/**
* 计算考试成绩队列(报警队列)
*/
QUEUE_WARN_PROCESS("exam.process.direct.backup", "exam.process.computed.warn", "");
/**
* 交换名称
*/
private String exchange;
/**
* 队列名称
*/
private String name;
/**
* 路由键
*/
private String routeKey;
ExamProcessEnum(String exchange, String name, String routeKey) {
this.exchange = exchange;
this.name = name;
this.routeKey = routeKey;
}
}
@Configuration
public class ExamProcessConfig {
//死信交换机
@Bean
public DirectExchange examProcessDirect() {
System.out.println("创建交换机");
return (DirectExchange) ExchangeBuilder
.directExchange(ExamProcessEnum.QUEUE_EXAM_PROCESS.getExchange())
.durable(true)
.build();
}
//死信队列
@Bean
public Queue examProcessQueue() {
return new Queue(ExamProcessEnum.QUEUE_EXAM_PROCESS.getName());
}
//死信队列与死信交换机进行绑定
@Bean
public Binding bindingExamProcessQueueToExchange(@Qualifier("examProcessQueue") Queue queue, @Qualifier("examProcessDirect") DirectExchange customExchange) {
return BindingBuilder
.bind(queue)
.to(customExchange)
.with(ExamProcessEnum.QUEUE_EXAM_PROCESS.getRouteKey());
}
//备份交换机
@Bean
public FanoutExchange examProcessBackUpFanout() {
return (FanoutExchange) ExchangeBuilder
.fanoutExchange(ExamProcessEnum.QUEUE_BACKUP_PROCESS.getExchange())
.durable(true)
.build();
}
//备份队列
@Bean
public Queue examBackUpProcessQueue() {
return new Queue(ExamProcessEnum.QUEUE_BACKUP_PROCESS.getName());
}
//备份队列与备份交换机进行绑定
@Bean
public Binding bindingExamProcessBackUpQueueToExchange(@Qualifier("examBackUpProcessQueue") Queue queue, @Qualifier("examProcessBackUpFanout") FanoutExchange fanoutExchange) {
return BindingBuilder
.bind(queue)
.to(fanoutExchange);
}
//警告队列
@Bean
public Queue examProcessWarnQueue() {
return new Queue(ExamProcessEnum.QUEUE_WARN_PROCESS.getName());
}
//警告队列与备份交换机进行绑定
@Bean
public Binding bindingExamProcessWarnQueueToExchange(@Qualifier("examProcessWarnQueue") Queue queue, @Qualifier("examProcessBackUpFanout") FanoutExchange fanoutExchange) {
return BindingBuilder
.bind(queue)
.to(fanoutExchange);
}
//普通队列与普通交换机进行绑定
@Bean
public Binding bindingExamProcessTtlQueueToExchange(@Qualifier("examProcessTtlQueue") Queue queue, @Qualifier("bindingExamProcessTtlExchangeToBackUpExchange") DirectExchange directExchange) {
return BindingBuilder
.bind(queue)
.to(directExchange)
.with(ExamProcessEnum.QUEUE_TTL_PROCESS_COMPUTED.getRouteKey());
}
//普通队列绑定死信交换机
@Bean
public Queue examProcessTtlQueue() {
Map<String, Object> args = new HashMap<>(2);
//声明当前队列绑定的死信交换机
args.put("x-dead-letter-exchange", ExamProcessEnum.QUEUE_EXAM_PROCESS.getExchange());
//声明当前队列的死信路由 key
args.put("x-dead-letter-routing-key", ExamProcessEnum.QUEUE_EXAM_PROCESS.getRouteKey());
//druable 持久化 后面输入队列的名称
return QueueBuilder.
durable(ExamProcessEnum.QUEUE_TTL_PROCESS_COMPUTED.getName())
.withArguments(args)
.build();
}
//普通交换机绑定备份交换机
@Bean
public DirectExchange bindingExamProcessTtlExchangeToBackUpExchange() {
//普通交换机绑定备份交换机
return (DirectExchange) ExchangeBuilder.directExchange(ExamProcessEnum.QUEUE_TTL_PROCESS_COMPUTED.getExchange())
.durable(true)
.withArgument("alternate-exchange", ExamProcessEnum.QUEUE_BACKUP_PROCESS.getExchange())
.build();
}
}
生产者
@Component
@Slf4j
public class ExamProcessSender {
@Autowired
private RabbitTemplate rabbitTemplate;
public void sendMessage(Long recordId, long delayTimes) {
//延长五分钟。
long waitMinute = 1000 * 60 * 5;
long delayTime = waitMinute + 1000 * delayTimes;
//给延迟队列发送消息
rabbitTemplate.convertAndSend(ExamProcessEnum.QUEUE_TTL_PROCESS_COMPUTED.getExchange(), ExamProcessEnum.QUEUE_TTL_PROCESS_COMPUTED.getRouteKey(), recordId, new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
//给消息设置延迟毫秒值
message.getMessageProperties().setExpiration(String.valueOf(delayTime));
return message;
}
}, new CorrelationData(UUID.randomUUID().toString()));
log.info("操作人:{},向消息队列中发送了一场考试记录,id为:{},消息过期时间为:{}", SecurityUtils.getCurrentUserId(), recordId, delayTime);
}
}
发送具体业务
@Override
@Transactional(rollbackFor = Exception.class)
public void sureBeginExam(Long recordId) {
//isSendQueue 如果确定考试,考试信息入队列。
Map<String, Long> stringLongMap = judgeOperateStatus(recordId);
Long examTimeLimit = stringLongMap.get("examTimeLimit");
EExamRecord examRecord = new EExamRecord();
examRecord.setId(recordId);
examRecord.setBeginTime(LocalDateTime.now());
examRecord.setExamStatus(ExamStatusEnum.IN_ANSWER.getCode());
//清除考试记录缓存
ExamCacheUtils.clearExamRecordCache(recordId);
if (eExamRecordMapper.updateById(examRecord) <= 0) {
BaseUtils.errorLog(ResultEnum.UPDATE_OPERATION_FAIL, "禁止考试,请登录重试", recordId);
}
//消息队列发送一条消息
examProcessSender.sendMessage(recordId, examTimeLimit);
}
消费者
@Component
@Slf4j
public class ExamProcessCustomer {
@Autowired
private ExamTaskService examTaskService;
//死信队列,存储过期的消息
@RabbitListener(queues = "exam.process.computed")
public void handleExamProcessComputed(Long recordId) {
log.info("进入消费队列, recordId:{}",recordId);
examTaskService.completeExamByMq(recordId);
log.info("exam process recordId:{}", recordId);
}
//备份队列
@RabbitListener(queues = "exam.process.computed.backup")
public void handleExamBackUpComputed(Long recordId) {
log.info("进入备份队列, recordId:{}",recordId);
//todo 问题:当我为消息设置过期时间时,如果消息进入备份队列,会忽略过期时间。
//examTaskService.completeExamByMq(recordId);
log.info("exam process recordId:{}", recordId);
}
//报警队列
@RabbitListener(queues = "exam.process.computed.warn")
public void handleExamWarnComputed(Long recordId) {
log.info("进入报警队列, recordId:{}",recordId);
}
}
消费具体业务
@Override
public void completeExamByMq(Long recordId) {
//1.判断考试是否进行算分
// 1.1 已算分
// 1.1.1判断考试是否入库 入库:不做任何处理 未入库:入库
// 1.2 未算分
// 1.2.1进行算分 并提示用户已进行算分。
log.info(StrUtil.format("【计算考试成绩(消息)】考试记录id:{}", recordId));
EExamRecord examRecord = eExamRecordMapper.selectById(recordId);
if (ObjectUtil.isNull(examRecord)) {
BaseUtils.errorLog(ResultEnum.UPDATE_OPERATION_FAIL, "数据不存在", recordId);
return;
}
if (examRecord.getExamStatus().equals(ExamStatusEnum.SOCER_ANSWER.getCode())) {
log.info(StrUtil.format("【成绩已计算,无需重复计算】考试记录id:{}", recordId));
if (!examRecord.getDetailEnter()) {
//考试信息入库
this.examDetailToDataBase(examRecord);
log.info(StrUtil.format("【考试详情入库(消息)】考试记录id:{}", recordId));
} else {
return;
}
} else if (!examRecord.getExamStatus().equals(ExamStatusEnum.DATA_LOSE)) {
//计算成绩
this.computedUserExamScore(examRecord);
//考试信息入库
this.examDetailToDataBase(examRecord);
}
}
消息/交换机未接收回调
注意:因为我在这里配置了备份交换机,当交换机向队列发送消息出现问题时直接走的备份交换机,并没有走回调。但是当消息向交换机传递出问题时,此时无法转备份交换机,会走回调。
@Component
@Slf4j
public class MyCallBack implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
@Autowired
private RabbitTemplate rabbitTemplate;
//将创建的消息接收的回调对象添加到rabbitTemplate中。
@PostConstruct
public void init() {
rabbitTemplate.setConfirmCallback(this);
rabbitTemplate.setMandatory(true);
rabbitTemplate.setReturnCallback(this);
}
/**
* 交换机确定是否收到消息的回调方法
* 1.发消息 交换机成功接受到了 回调
* 1.1CorrelationData保存回调消息的ID及相关信息
* 1.2交换机收到消息 ack:true
* 1.3cause 失败的原因 cause:null
* 2.发消息 交换机没有成功接收 回调
* 2.1CorrelationData保存回调消息的ID及相关信息
* 2.2交换机收到消息 ack:false
* 2.3 cause:失败的原因
*/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
System.out.println(correlationData);
String id = correlationData == null ? "" : correlationData.getId();
if (ack) {
log.info("交换机已经收到 id 为:{}的消息", id);
} else {
log.info("交换机还未收到 id 为:{}消息,由于原因:{}", id, cause);
}
}
//当消息无法路由的时候的回调方法
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String
exchange, String routingKey) {
log.error(" 消 息 {}, 被 交 换 机 {} 退 回 , 退 回 原 因 :{}, 路 由 key:{}", new
String(message.getBody()), exchange, replyText, routingKey);
}
}
回调
@Component
@Slf4j
public class MyCallBack implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
@Autowired
private RabbitTemplate rabbitTemplate;
//将创建的消息接收的回调对象添加到rabbitTemplate中。
@PostConstruct
public void init() {
rabbitTemplate.setConfirmCallback(this);
rabbitTemplate.setMandatory(true);
rabbitTemplate.setReturnCallback(this);
}
/**
* 交换机确定是否收到消息的回调方法
* 1.发消息 交换机成功接受到了 回调
* 1.1CorrelationData保存回调消息的ID及相关信息
* 1.2交换机收到消息 ack:true
* 1.3cause 失败的原因 cause:null
* 2.发消息 交换机没有成功接收 回调
* 2.1CorrelationData保存回调消息的ID及相关信息
* 2.2交换机收到消息 ack:false
* 2.3 cause:失败的原因
*/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
System.out.println(correlationData);
String id = correlationData == null ? "" : correlationData.getId();
if (ack) {
log.info("交换机已经收到 id 为:{}的消息", id);
} else {
log.info("交换机还未收到 id 为:{}消息,由于原因:{}", id, cause);
}
}
//当消息无法路由的时候的回调方法
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String
exchange, String routingKey) {
log.error(" 消 息 {}, 被 交 换 机 {} 退 回 , 退 回 原 因 :{}, 路 由 key:{}", new
String(message.getBody()), exchange, replyText, routingKey);
}
}
问题:
使用上述方式,我们实现了所需的需求,但是遇到了两个问题。
问题1:消息过期时间不同造成消息阻塞
我们将死信队列当作延迟队列使用,出现的问题是如果消息的过期时间不同时,假如第一条消息10分钟到期,第二条消息2分钟到期,此时会出现第一条消息没有过期而阻塞第二条消息消费的情况。
如果每一场考试的时间相同还好,但是如果时间不同,那么就会造成消息阻塞的情况,因此使用此种发似实现并不是最优解。
问题2:带有过期时间的消息入备份队列,过期时间不生效
rabbitmq为一个普通交换机设置了备份交换机,我向普通交换机发送了一条5分钟后进行消费的消息,但是普通交换机出现问题走了备份交换机,但是备份交换机的消费者直接把信息消费了,并没有等5分钟,导致我这场考试直接被提交了。
实现方式二(推荐):使用延迟插件
使用方式一实现时,出现最明显的一个问题就是消息阻塞的问题。因此使用延迟插件,可以很好地解决这个问题。
注意:延迟插件的使用需要安装。
创建交换机和队列
@Getter
public enum UnCommitExamEnum {
/**
* 计算考试成绩队列(死信队列)
*/
UNCOMMIT_EXAM_COMPUTED("uncommit.exam.computed.direct", "uncommit.exam.computed", "uncommit.exam.computed");
/**
* 交换名称
*/
private String exchange;
/**
* 队列名称
*/
private String name;
/**
* 路由键
*/
private String routeKey;
UnCommitExamEnum(String exchange,String name,String routeKey) {
this.exchange = exchange;
this.name = name;
this.routeKey = routeKey;
}
}
@Configuration
public class UnCommitExamConfig {
@Bean
public CustomExchange examComputedExchange() {
Map<String, Object> arguments = new HashMap<>();
//设置自定义交换机的类型。
arguments.put("x-delayed-type", "direct");
//1.交换机名称
//2.交换机的类型
//3.是否需要持久化
//4.是否需要自动删除
//5.其他参数
return new CustomExchange(UnCommitExamEnum.UNCOMMIT_EXAM_COMPUTED.getExchange(), "x-delayed-message", true, false, arguments);
}
@Bean
public Queue examComputedQueue() {
return QueueBuilder
.durable(UnCommitExamEnum.UNCOMMIT_EXAM_COMPUTED.getName())
.build();
}
@Bean
public Binding bindingExamComputedQueueToExchange() {
return BindingBuilder
.bind(examComputedQueue())
.to(examComputedExchange())
.with(UnCommitExamEnum.UNCOMMIT_EXAM_COMPUTED.getRouteKey())
.noargs();
}
}
生产者
@Component
@Slf4j
public class UnCommitExamSender {
@Autowired
private RabbitTemplate rabbitTemplate;
public void sendMessage(Long recordId, long examTime) {
int oneMinute = 1000 * 60;
int waitMinute =oneMinute * 5;
//过期时间 原有时间上推后五分钟。
long delayTime =(long) 1000 * examTime + waitMinute;
//延迟插件最大的时间限制,设置时间超过这个时间将没有延迟效果。
long maxMinute =(long) oneMinute * 60 * 24 * 45;
if (delayTime > maxMinute) {
log.info("考试:{},时间较长,不提供消息支持。考试时间:{}", recordId, delayTime);
return;
}
//过期时间
int examComputedTime = (int)delayTime;
//给延迟队列发送消息
rabbitTemplate.convertAndSend(UnCommitExamEnum.UNCOMMIT_EXAM_COMPUTED.getExchange(), UnCommitExamEnum.UNCOMMIT_EXAM_COMPUTED.getRouteKey(), recordId, new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
//给消息设置延迟毫秒值
message.getMessageProperties().setDelay(examComputedTime);
return message;
}
}, new CorrelationData(UUID.randomUUID().toString()));
log.info("操作人:{},向消息队列中发送了一场考试记录,id为:{},消息过期时间为:{}", SecurityUtils.getCurrentUserId(), recordId, examComputedTime);
}
}
消费者
@Component
@Slf4j
public class UnCommitExamCustomer {
@Autowired
private ExamTaskService examTaskService;
//死信队列,存储过期的消息
@RabbitListener(queues = "uncommit.exam.computed")
public void handleExamProcessComputed(Long recordId) {
log.info("进入考试消费队列, recordId:{}",recordId);
examTaskService.completeExamByMq(recordId);
log.info("exam process recordId:{}", recordId);
}
}
遗留问题:
问题1:延迟队列(插件)如何设置备份队列
问题2:备份队列中消息直接被消费,未在消息过期后消费
rabbitmq为一个普通交换机设置了备份交换机,我向普通交换机发送了一条5分钟后进行消费的消息,但是普通交换机出现问题走了备份交换机,但是备份交换机的消费者直接把信息消费了,并没有等5分钟
问题3:为延迟队列(插件)设置未入队回调,出现提示未找到路由key,实际消息到期队列中的消息还是被消费了。
发送消息时,通过日志发现出发了回调(此处回调和实现方式一的回调一摸一样,监听全局消息入队的回调)
到时间后,消息正常被消费。
思考:延迟插件之所以实现延迟效果,是因为消息由交换机入队列时,消息不直接进入队列,而是存储在mnesia
(一个分布式数据系统)表中,当消息到期时,则自动入队列进行消费。
ExamByMq(recordId);
log.info(“exam process recordId:{}”, recordId);
}
}
### 遗留问题:
#### 问题1:延迟队列(插件)如何设置备份队列
#### 问题2:备份队列中消息直接被消费,未在消息过期后消费
rabbitmq为一个普通交换机设置了备份交换机,我向普通交换机发送了一条5分钟后进行消费的消息,但是普通交换机出现问题走了备份交换机,但是备份交换机的消费者直接把信息消费了,并没有等5分钟
#### 问题3:为延迟队列(插件)设置未入队回调,出现提示未找到路由key,实际消息到期队列中的消息还是被消费了。
发送消息时,通过日志发现出发了回调(此处回调和实现方式一的回调一摸一样,监听全局消息入队的回调)
[外链图片转存中...(img-XlcNf06U-1632990667944)]
到时间后,消息正常被消费。
[外链图片转存中...(img-Jwn5ozQA-1632990667947)]
思考:延迟插件之所以实现延迟效果,是因为消息由交换机入队列时,消息不直接进入队列,而是存储在`mnesia`(一个分布式数据系统)表中,当消息到期时,则自动入队列进行消费。