【Java笔记】实现延时队列2:Redis


Redis实现延时队列主要有两种方式:

  • 通过SETEX 与发布订阅机制过期监听
  • 通过 ZSET 实现

过期监听

Redis通过set key and expire + RedisExpirationListener 过期监听实现延时队列,主要还是基于Redis的发布/订阅模式实现。

准备工作

找到redis安装目录的redis.windows.conf与redis.windows-server.conf中的“notify-keyspace-events”,取消注释

notify-keyspace-events Ex

然后重启下Redis,windows中会直接使用redis-server.exe,不会加载redis.windows.conf这个配置文件,需要用进入安装目录,命令行启动:

redis-server.exe redis.windows.conf

如果这时命令行报错:

nvalid argument during startup: unknown conf file parameter :

那么要注意了,Redis配置里要顶格!要顶格!要顶格!

稍微复习下Jedis与JedisPool

首先明确一点,Jedis实例不是线程安全的,所以不可以多个线程共用一个Jedis实例

那是不是要开很多个Jedis实例呢?当然也不行,因为更多的Jedis实例意味着要建立更多的socket连接

所以就需要JedisPool,就是一个线程安全的网络连接池,类似ThreadPool可以复用Thread线程实例,JedisPool可以创建一些可靠的Jedis实例,并且在后续复用,能够提高性能,并且不需要更多的Socket连接

// 开一个连接池
JedisPool jedisPool = new JedisPool("127.0.0.1", 6379); // HOST, PORT
// 获取一个Jedis连接
Jedis jedis = jedisPool.getResource();

模拟延时队列

这里开两个线程:

  • 一个用于订阅监听事件:因为subscrib()会阻塞等待,需要异步初始化:
  • Jedis.subscribe(JedisPubSub实现类, channel)订阅频道需要两个参数:
    • channel参数:"keyevent@0:expired"是发布删除过期key信息的channel
    • JedisPubSub实现类:简单来说就是实现``JedisPubSub`接口的一些方法,当有key过期删除前/后/时执行一些逻辑
  • 一个用于生产数据:set(key, seconds, value)
    • key:这个订单集合的名字
    • seconds:过期时间(s)
    • value:当前订单的名字(编号)
public class RedisKeyExpireTest {
    private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);

    public static void main(String[] args) throws InterruptedException {
        // subscribe方法会阻塞等待,用异步去初始化订阅监听事件
        new Thread(() -> {
            jedisPool.getResource().subscribe(new RedisSub(), "__keyevent@0__:expired");
        }).start();
        // 添加几个带过期时间的key
        new Thread(() -> {
            try {
                for (int i = 0; i < 5; i++) {
                    String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                    jedisPool.getResource().setex("orderNo100" + i, i + 1, "orderNo100" + i);
                    System.out.println(time + ":生成订单,订单号:orderNo100" + i + ",有效期:" + (i + 1) + "秒");
                    Thread.sleep(1000);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
}

class RedisSub extends JedisPubSub {
    @Override
    public void onMessage(String channel, String message) {
        String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        System.out.println(time + ":订单号:" + message + "已到期");

    }
}

输出:

2024-03-30 22:04:21:生成订单,订单号:orderNo1000,有效期:1秒
2024-03-30 22:04:22:生成订单,订单号:orderNo1001,有效期:2秒
2024-03-30 22:04:22:订单号:orderNo1000已到期
2024-03-30 22:04:23:生成订单,订单号:orderNo1002,有效期:3秒
2024-03-30 22:04:24:生成订单,订单号:orderNo1003,有效期:4秒
2024-03-30 22:04:24:订单号:orderNo1001已到期
2024-03-30 22:04:25:生成订单,订单号:orderNo1004,有效期:5秒
2024-03-30 22:04:26:订单号:orderNo1002已到期
2024-03-30 22:04:28:订单号:orderNo1003已到期
2024-03-30 22:04:30:订单号:orderNo1004已到期

优缺点

优点

  • 实现简单,redis内存操作,速度快,性能高,集群扩展方便
  • 可以通过AOF和RDB实现消息队列的持久化,适合对延迟精度要求不高的业务场景

缺点

  • redis的key过期有惰性清除和定时清除两种策略,可能会存在延迟时间不精确的问题

    惰性清除:不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key

    • 优点:对 CPU 时间最友好。因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源。
    • 缺点:对内存不友好。如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。

    定期删除:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key,如果当前抽取的key中过期的超过25%,就再抽一次,循环至过期比例在25%以内。

    • 优点:可以减少内存压力
    • 缺点:难以确定删除操作执行的时长和频率。太频繁对CPU不友好;频率太低,过期key得不到及时释放,对内存不友好。

    而且,很明显,定期查询是一个循环,为了保证不会循环过度导致线程卡死,存在一个定期删除循环流程的时间上限,默认不超过25ms

  • 极端情况下不可靠:如果客户端故障或重启期间有key过期则过期通知事件的数据就丢失了(订单无法过期)

    • 可以用定时任务去做轮询补偿

ZSet 实现延时队列

Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列

Set 有一个 Score 属性可以用来存储延迟执行的时间

  • 使用zadd score1 value1 生产消息
  • 利用zrangebyscore 查询符合条件的任务,通过循环执行队列任务

引入依赖

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.2.0</version>
</dependency>

模拟延时队列

下面模拟一下这个过程,需要开两个线程:

  • 一个生产者,往redis数据库里生产数据,主要方法:
    • zadd(key, value, score)
      • key:这个订单集合的名字
      • value:当前订单的名字(编号)
      • score:当前订单的过期时刻
  • 一个消费者,不停轮询这个key的Sorted Set,主要方法:
    • zrangeWithScores(key, start, end) :返回key中在startend间的value和score的Tuple集合,比如
      • zrangeWithScores(key, 0, 0):获取score最小的一个tuple
      • zrangeWithScores(key, 0, 1):获取score最小的两个tuple
    • 也可以用zrangeWithScores(key, start, end),这是startend需要是准确时间。
public class CancelOrderRedisTest {
    private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);
    public static void main(String[] args) {
        // 开一个线程,放几个订单元素到zset中
        new Thread(() -> {
            try {
                for (int i = 0; i < 5; i++) {
                    String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                    redisClient().zadd("cancel:order:list", System.currentTimeMillis() + (i + 1) * 1000, "orderNo100" + i);
                    System.out.println(time + ":生成订单,订单号:orderNo100" + i + ",有效期:" + (i + 1) + "秒");
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        // 再开一个线程轮询这个有序集合
        new Thread(() -> {
            Jedis jedis = redisClient();
            while (true){
                // 取这个有序集合的第一个,也就是score最小的元素
                Set<Tuple> items = jedis.zrangeWithScores("cancel:order:list", 0, 1);
                if (items == null || items.isEmpty()){
                    // 避免空指针异常,因为是两个线程,一个线程生产数据(往redis里放订单),一个(现在这个)线程消费数据(清理过期数据)
                    // 可能目前redis里的数据都消费完了,所以要sleep一会,等待另一个线程生产
                    try {
                        Thread.sleep(100);
                    }catch (InterruptedException e){
                        e.printStackTrace();
                    }
                } else {
                    // 轮询成功拿到redis的数据,下面就判断一下是否到期
                    Tuple tuple = (Tuple) items.toArray()[0];
                    long score = (long) tuple.getScore();
                    // 如果现在的时间大于当前数据的score(过期时间),进行清理
                    if (System.currentTimeMillis() >= score){
                        Long num = jedis.zrem("cancel:order:list", tuple.getElement());
                        if (num != null && num > 0){
                            String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                            System.out.println(time +  ":订单号:" + tuple.getElement() + "已到期");
                        }

                    }
                }
            }
        }).start();
    }

    /**
     * 获取Redis连接
     * @return
     */
    private static Jedis redisClient() {
        return jedisPool.getResource();
    }
}

输出:

2024-03-30 20:32:58:生成订单,订单号:orderNo1000,有效期:1秒
2024-03-30 20:32:59:订单号:orderNo1000已到期
2024-03-30 20:32:59:生成订单,订单号:orderNo1001,有效期:2秒
2024-03-30 20:33:00:生成订单,订单号:orderNo1002,有效期:3秒
2024-03-30 20:33:01:订单号:orderNo1001已到期
2024-03-30 20:33:01:生成订单,订单号:orderNo1003,有效期:4秒
2024-03-30 20:33:02:生成订单,订单号:orderNo1004,有效期:5秒
2024-03-30 20:33:03:订单号:orderNo1002已到期
2024-03-30 20:33:05:订单号:orderNo1003已到期
2024-03-30 20:33:07:订单号:orderNo1004已到期

优缺点

优点(跟过期监听一样):

  • 实现简单,redis内存操作,速度快,性能高,集群扩展方便
  • 可以通过AOF和RDB实现消息队列的持久化,适合对延迟精度要求不高的业务场景

缺点

  • 第一个就是轮询带来的问题:
    • 轮询线程如果不休眠或休眠时间过短,可能导致过多的空轮询,CPU飙高
    • 如果带休眠时间过长,因为现在过期的数据得等到下一轮轮询才能处理,延时队列的延时也就不准确了
    • 另外,【没有队列的纯粹轮询】还有个问题,就是数据量太大时,可能一个轮询周期检查不完,这里只需要轮询队头的一个或几个数据,所以不太会有这个问题
  • 然后是大部分中间件做延时队列都会有的问题:
    • 极端条件下,会丢失数据,不可靠,比如:
      • Redis过期通知时,应用正好重启,可能丢失事件(导致订单一直无法关闭)。
      • 先删数据在处理订单还是先处理订单再删除数据,处理异常时可能会导致数据丢失。
      • 可以用定时任务去做轮询补偿
    • 存储维护成本高:需要监听的数据较大时会占用中间件大量的存储空间,增加维护成本

Reference

订单超时自动取消的技术方案解析及代码实现

  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 延时队列是指队列中的任务需要在一定延时后才能被执行,可以有效地解决某些任务需要延时处理的问题。Java Redis是一个基于RedisJava客户端,它可以实现Java语言对Redis数据结构的操作,因此可以很方便地实现延时队列的功能。 Java Redis实现延时队列的主要思路是利用Redis的Sorted Set数据结构,将任务按照延时时间作为Score,任务内容作为Value,插入Sorted Set中,并设置过期时间,过期后将任务弹出并执行。具体实现步骤如下: 1. 创建一个Sorted Set,将任务插入其中,Score为任务的延时时间,Value为任务的内容。 2. 使用Redis的zremrangebyscore命令扫描Sorted Set中Score小于等于当前时间的任务并弹出,并将任务内容推送到执行队列中。 3. 设置延时任务的过期时间,过期时间为延时时间加当前时间,可以使用Redis的zadd命令添加任务时同时设置Score和过期时间。 4. 执行队列按顺序执行任务,任务执行完成后从执行队列中删除。 通过以上步骤,可以实现一个高效可靠的延时队列,可以优化系统任务调度、异步处理、消息通知等场景下的问题。 ### 回答2: Java Redis延时队列是一种常用的消息队列模式,在很多应用场景中都有应用。Java Redis延时队列通过将消息发送到Redis进行存储,在指定的时间后再将消息取出来进行处理。这个过程中,通过Redis的Sorted Set类型进行排序来保证队列中的消息有序。下面来详细介绍Java Redis延时队列实现方式。 一、Redis数据结构 Java Redis延时队列的关键在于Redis数据结构的设计。在Redis中,Sorted Set就是用来解决排序问题的。所以我们需要借助Sorted Set实现延时队列。具体来说,可以使用Redis中的zadd命令将消息发送到Sorted Set中,并按照时间顺序进行排序。Sorted Set中的元素包含值和权重,我们可以根据权重(即时间戳)来实现有序存储。 二、消息入队 消息的入队过程如下: 1. 获取消息的过期时间TTL。 2. 计算出当前的时间戳now。 3. 将消息写入到Sorted Set中,权重为now+TTL。 ```redis-cli> ZADD delay-queue (now + TTL) message``` 三、消息出队 消息出队过程如下: 1. 获取当前时间戳now。 2. 使用zrangebyscore命令从Sorted Set中获取所有权重小于等于now的元素,即过期的元素。 3. 遍历查询结果,对每个元素执行出队操作(移除元素)。 ```redis-cli> ZRANGEBYSCORE delay-queue -inf now``` 四、多线程处理 为避免在出队过程中同时处理多个过期元素时出现问题,可以使用多线程处理消息。Java的并发包中提供了Executor框架,这里可以使用ThreadPoolExecutor线程池。 五、消息重试 有时候由于网络波动等原因,在执行消息处理时可能会失败,所以需要将失败的消息重新入队。此时,可以加入重试机制,重新入队时TTL加上重试时间,即可实现延时次数的控制。 六、总结 Java Redis延时队列利用Redis的Sorted Set实现有序存储,使用多线程和重试机制解决了消息处理时的并发和失败问题,保障了消息的可靠性。在实际应用中,可以根据业务需求进行调优和扩展,如设置合理的时间间隔、增加监控和报警等。 ### 回答3: Java Redis实现延时队列可以分为以下几步: 1.将任务加入到延时队列中:首先需要将任务和对应的过期时间放入Redis的有序集合中,以过期时间为score值,任务为value。这样可以保证按照过期时间顺序进行排序,具有先进先出的特点。代码实现如下: ```java //添加任务到延时队列 public void addToDelayQueue(String taskId, long delayTime) { //计算过期时间 long expireTime = System.currentTimeMillis() + delayTime; //添加到有序集合中,score为过期时间 jedis.zadd(DELAY_QUEUE_KEY, expireTime, taskId); } ``` 2.从延时队列中取出任务:需要循环从有序集合中取出第一个score小于等于当前时间的任务,并将其从有序集合中删除。代码实现如下: ```java //获取延时队列中的任务 public void getFromDelayQueue() { while (true) { //获取第一个score小于等于当前时间的任务 Set<String> set = jedis.zrangeByScore(DELAY_QUEUE_KEY, 0, System.currentTimeMillis(), 0, 1); if (set == null || set.isEmpty()) { try { Thread.sleep(1000);//如果没有取到任务,则等待1秒 } catch (InterruptedException e) { e.printStackTrace(); } continue; } String taskId = set.iterator().next(); //删除任务 jedis.zrem(DELAY_QUEUE_KEY, taskId); //处理任务 handleTask(taskId); } } //处理任务 public void handleTask(String taskId) { //TODO: 根据taskId执行对应的任务 } ``` 3.使用Redis发布订阅机制以及线程池来处理任务:在处理任务时,可以使用Redis发布订阅机制将任务相关的信息发布到指定的频道,由相应的消费者线程池来进行任务处理,可以降低单线程处理任务的压力。代码实现如下: ```java //处理任务 public void handleTask(String taskId) { //通过发布订阅机制将任务信息发布到指定频道 jedis.publish(TASK_CHANNEL, taskId); } //消费者线程池处理任务 public void consumeTask() { JedisPubSub jedisPubSub = new JedisPubSub() { public void onMessage(String channel, String message) { //TODO: 根据message信息执行对应的任务 } }; jedis.subscribe(jedisPubSub, TASK_CHANNEL); executorService.execute(jedisPubSub::quit); } ``` 通过以上步骤,我们就可以实现Java Redis延时队列的功能,实现了任务的延迟执行。延时队列实现可以实现具有任务按照时间顺序执行,且任务可取消、可重试等特点,应用场景广泛。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值