生产环境中如何使用Redis作为延时队列

656 阅读4分钟

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:既然使用了定时任务为什么不直接使用定时任务来做定时发送

通知有多种类型,定时通知的消息只是少部分的,所以定时通知的消息会放到另外一张表中,这样定时任务扫描表的话记录不会很多,这里的定时任务只是作为一个补偿机制