1:使用背景
在项目中有一个定时发送通知的需求,需要在指定的未来的某一个时间点发送消息,这就跟延时发送是一样的,所以第一时间想到的就是使用延时消息来处理
2:技术选型
-
2.1:定时任务
我们可以每隔一定的时间扫描通知表,查看有哪些是定时发送的通知,然后判断是否到了发送的时间,如果时间到了就发送通知
缺点:需要扫描表,增加数据库的压力,而且会有时间差的问题,比如定时任务是5分钟扫描一次,有可能通知会比预定的时间晚几分钟发送
优点:实现简单
-
2.2:jdk延时队列
该方案是利用JDK自带的java.util.concurrent包中的DelayQueue队列。 这是一个无界阻塞队列,该队列只有在延迟期满的时候才能从中获取原始,放入DelayQueue中的对象,必须实现Delayed接口
优点:效率高
缺点:
1.JVM重启,数据会全部丢失,但是可以做到不丢失
2.可扩展性难度高
3.可能出现内存溢出异常,因为是无界阻塞队列
4.内部很多东西可能需要开发人员手动编写,很多东西没有封装
-
2.3:消息队列(RabbitMq/RocketMq)
二者都可以实现延时队列,提供了发送消息确认机制,消息持久化机制,消息消费确认机制。但是需要引入第三方组件,增加了维护的成本。而且使用消息中间件需要考虑的问题都非常多。
-
2.4: Redis Sorted Set
Redis有序集合(Sorted Set)每个元素都会关联一个double类型的分数score。Redis可以通过分数来为集合中的成员进行从小到大的排序。 该方案可以将定时通知发送时间戳与通知编号分别设置为score和member。系统扫描第一个元素判断是否达到了发送消息,则进行业务处理。 然而,这一版本存在一个致命的硬伤,在高并发条件下,多个消费者会取到同一个通知编号,又需要编写Lua脚本保证原子性或使用分布式锁,用了分布式锁性能又下降了。
优点:
1.可靠性,基于Redis自身的持久化性实现消息持久化
2.高可用性,支持单价、主从、哨兵、集群多种模式
缺点:
1.单个有序集合无法支持太大的数量
2.需要额外进行Redis的维护
因为项目中本来就使用了Redis,而且通知这类消息不需要保证一定不丢失,也不是很重要的消息,只需要做好补偿操作即可。所以最终决定使用Redis
3:代码开发
/**
* 将通知放入redis延时队列中
* @param msgId 通知主键id
* @param timeStamp 定时通知消息发送的时间
*/
public Boolean intoDelayQueue(Integer msgId,Long timeStamp) {
log.info("msgId = {}",msgId);
return redisTemplate.opsForZSet().add(NOTICE_KEY, msgId, timeStamp);
}
/**
* 从redis延时队列拿出一条消息
* @param args
* @throws Exception
*/
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("{},开始消费消息",LocalDateTime.now());
new Thread(() -> {
while (true) {
Set set = redisTemplate.opsForZSet().rangeByScore(NOTICE_KEY, 0, System.currentTimeMillis(), 0, 1);
if(set == null || set.isEmpty()) {
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
log.error("消费消息失败:{}", JSONUtil.toJsonPrettyStr(e.getMessage()));
break;
}
}else{
List<Integer> result = new ArrayList<>(set);
//拿到这个元素
Integer noticeId = result.get(0);
log.info("{},消息id = {}",LocalDateTime.now(),noticeId);
//删除这个消息,在并发环境下,为了防止多个客户端拿到同一条消息,只有成功删除
//这条消息的客户端才能去处理这条消息,保证只有一个客户端能消费这条消息
if(redisTemplate.opsForZSet().remove(NOTICE_KEY, noticeId) > 0) {
handleNoticeMsg(noticeId);
}
}
}
}).start();
}
4:存在的问题
1:如何保证消息一定进入了Redis
我们无法保证消息一定能进入Redis,如果一定要保证的话需要使用Lua脚本,这样开发成本增加。而且在消息进入Redis之前,我会先让这条消息入库,只有消息成功入库了才能发送给Redis,这样即使消息没有进入到Redis,后续通过定时任务定时扫描
2:如果Redis消费失败了怎么办
消息失败了首先日志是一定要打印的,除此之外,由于Redis中的消息只能被消费一次,所以消息失败了还是通过定时任务去定时扫描。如果超出了发送时间但是还未发送的通知就直接发送
3:Redis挂了怎么办??
做好Redis的监控,使用prometheus+grafana做好监控,并且通过企业微信通知,如果Redis挂了,就将Redis队列清空,然后将未到达发送时间的通知重新发送到Redis中,这里最好是将那些发送时间比当前时间超过5分钟的记录才发送到Redis。到达发送时间的就直接处理
4:既然使用了定时任务为什么不直接使用定时任务来做定时发送
通知有多种类型,定时通知的消息只是少部分的,所以定时通知的消息会放到另外一张表中,这样定时任务扫描表的话记录不会很多,这里的定时任务只是作为一个补偿机制