【≅Redis】Redis如何实现延迟队列

延迟队列的应用

根据用户行为在特定的时间点向用户推送相应的提醒消息,比如以下业务场景:

  • 在用户点击充值项后,半小时内未充值,向用户推送充值未完成提醒。
  • 在用户最近一次阅读行为2小时后,向用户推送继续阅读提醒。
  • 在用户新注册或退出应用N分钟后,向用户推送合适的推荐消息。

上述场景的共同特征就是在某事件触发后延迟一定时间后再执行特定任务,若事件触发时间点可知,则上述逻辑也可等价于在指定时间点(事件触发时间点+延迟时间长度)执行特定任务。

实现这类需求一般采用延时队列,其中创建的延时消息中需要包含任务延迟时间或任务执行时间点等信息,当任务满足时间条件需要执行时,该消息便会被消费,也就是说可以指定队列中的消息在哪个时间点被消费。

延迟队列的实现

在单机环境中,JDK已经自带了很多能够实现延时队列功能的组件,比如DelayQueue, Timer, ScheduledExecutorService等组件,都可以较为简便地创建延时任务,但上述组件使用一般需要把任务存储在内存中,服务重启存在任务丢失风险,且任务规模体量受内存限制,同时也造成长时间内存占用,并不灵活,通常适用于单进程客服端程序中或对任务要求不高的项目中。

在分布式环境下,仅使用JDK自带组件并不能可靠高效地实现延时队列,通常需要引入第三方中间件或框架。

比如常见的经典任务调度框架Quartz或基于此框架的xxl-job等其它框架,这些框架的主要功能是实现定时任务或周期性任务,在Redis、RabbitMQ还未广泛应用时,譬如常见的超时未支付取消订单等功能都是由定时任务实现的,通过定时轮询来判断是否已到达触发执行的时间点。

但由于定时任务需要一定的周期性,周期扫描的间隔时间不好控制,太短会造成很多无意义的扫描,且增大系统压力,太长又会造成执行时间误差太大,且可能造成单次扫描所处理的堆积记录数量过大。

此外,利用MQ做延时队列也是一种常见的方式,比如通过RabbitMQ的TTL和死信队列实现消息的延迟投递,考虑到投递出去的MQ消息无法方便地实现删除或修改,即无法实现任务的取消或任务执行时间点的更改,同时也不能方便地对消息进行去重,因此在项目中并未选择使用MQ实现延时队列。

针对于Redis实现延时队列有两种实现方式:

使用zset实现实现的延时队列

Redis的数据结构zset,同样可以实现延迟队列的效果,且更加灵活,可以实现MQ无法做到的一些特性,因此项目最终采用Redis实现延时队列,并对其进行优化与封装。

实现原理是利用zset的score属性,redis会将zset集合中的元素按照score进行从小到大排序,通过zadd命令向zset中添加元素,如下述命令所示,其中value值为延时任务消息,可根据业务定义消息格式,score值为任务执行的时间点,比如13位毫秒时间戳。

zadd delayqueue 1614608094000 taskinfo

任务添加后,获取任务的逻辑只需从zset中筛选score值小于当前时间戳的元素,所得结果便是当前时间节点下需要执行的任务,通过zrangebyscore命令来获取,如下述命令所示,其中timestamp为当前时间戳,可用limit限制每次拉取的记录数,防止单次获取记录数过大。

#获取score介于0和timestamp之间的
zrangebyscore delayqueue 0 timestamp之间的 limit 0 1000

在实际实现过程中,从zset中获取到当前需要执行的任务后,需要先确保将任务对应的元素从zset中删除,删除成功后才允许执行任务逻辑,这样是为了在分布式环境下,当存在多个线程获取到同一任务后,利用redis删除操作的原子性,确保只有一个线程能够删除成功并执行任务,防止重复执行。

实际任务的执行通常会再将其发送至MQ异步处理,将“获取任务”与“执行任务”两者分离解耦,更加灵活,“获取任务”只负责拿到当前时间需要执行的任务,并不真正运行任务业务逻辑,因此只需相对少量的执行线程即可,而实际的任务执行逻辑则由MQ消费者承担,方便调控负载能力。

整体过程如下图所示:

采用zset做延时队列的另一个好处是可以实现任务的取消和任务执行时间点的更改,只需要将任务信息从zset中删除,便可取消任务,同时由于zset拥有集合去重的特性,只需再次写入同一个任务信息,但是value值设置为不同的执行时间点,便可更改任务执行时间,实现单个任务执行时间的动态调整。

@Component
public class RedisDelayDemo {

    @Autowired
    private RedisTemplate redisTemplate;

    private static final Long DELETE_SUCCESS = 1L;

    //放入数据
    public void push(String key, Object val, long delayTime) {
        String strVal = val instanceof String ? (String) val : JSON.toJSONString(val);
        redisTemplate.opsForZSet().add(key, strVal, System.currentTimeMillis() + delayTime);
    }

    //获取数据
    public String get(String key) {
        Set<String> sets = redisTemplate.opsForZSet().rangeByScore(key, 0, System.currentTimeMillis(), 0, 3);
        if (CollectionUtils.isEmpty(sets)) {
            return null;
        }
        for (String val : sets) {
            if (DELETE_SUCCESS.equals(redisTemplate.opsForZSet().remove(key, val))) {
                // 删除成功,表示抢占到
                return val;
            }
        }
        return null;
    }
}

基于Redis过期监听实现延时队列

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;


public class RedisExpiredListenter extends KeyExpirationEventMessageListener {

    private static final Logger LOGGER = LoggerFactory.getLogger(RedisExpiredListenter.class);

    public RedisExpiredListenter(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(message.getChannel(), StandardCharsets.UTF_8);
        //过期的key
        String key = new String(message.getBody(),StandardCharsets.UTF_8);
        LOGGER.info("redis key 过期:pattern={},channel={},key={}",new String(pattern),channel,key);
        /**
        *你可以将你的内容一起拼接到key中或者可以在redis中存储两个key (例如mykey和mykey_backup)
        *针对mykey设置过期时间,对于mykey_backup不设置过期时间,这样就可以通过mykey_backup获取到value了
        **/
    }
}

使用Redis过期监听实现延时队列方法较为便捷,但是该方法也存在一个很大的问题:因为当过期的key数量高于一个阈值的时候, Redis 不能确保 key 在指定时间被删除 , 也就造成了消息可能比你设置的延时时间更长
所以如果你的系统针对于延时队列这个时间要求十分严格,并且在同一时间内会有多个消息需要发生那我就不推荐使用Redis的延时队列,如果你的系统对于该业务并没有如此严格的要求,并且数量不多的情况下是可以使用的。

  • 惰性清除。当这个key过期之后,访问时,这个Key才会被清除。
  • 定时清除。后台会定期检查一部分key,如果有key过期了,就会被清除。

使用Redis 实现延时队列还有一个比较大的问题,他并不像消息队列一样保证送达。当订阅事件的客户端会丢失所有在断线期间所有分发给它的事件。 这个问题也是开发者需要考虑的,根据自己的业务场景去判断。

基于Redisson实现延迟队列

// redisson  延迟队列
// Redisson的延时队列是对另一个队列的再包装,使用时要先将延时消息添加到延时队列中,
// 当延时队列中的消息达到设定的延时时间后,该延时消息才会进行进入到被包装队列中,因此,我们只需要对被包装队列进行监听即可。
RBlockingQueue<OrderInfo> blockingFairQueue = redissonClient.getBlockingQueue("my-test");
//解决项目重新启动并不会消费之前队列里的消息的问题,
RDelayedQueue<OrderInfo> delayedQueue = redissonClient.getDelayedQueue(blockingFairQueue);

OrderInfo orderInfo = new OrderInfo();
// 订单生成时间
orderInfo.setCreateTime(LocalDateTime.now());
// 10秒钟以后将消息发送到指定队列
delayedQueue.offer(orderInfo, 10, TimeUnit.SECONDS);
RBlockingQueue<OrderInfo> outQueue = redissonClient.getBlockingQueue("my-test");

OrderInfo orderInfo2 = outQueue.take();
System.out.println("订单生成时间" + orderInfo2.getCreateTime());
System.out.println("订单关闭时间" + LocalDateTime.now());

// 在该对象不再需要的情况下,应该主动销毁。仅在相关的Redisson对象也需要关闭的时候可以不用主动销毁
delayedQueue.destroy();

 

posted @ 2023-05-20 14:45  残城碎梦  阅读(759)  评论(0编辑  收藏  举报