【Java笔记】实现延时队列(小结):JDK DelayQueue&MQ&Redis

概念及场景

延迟队列的概念

  • 队列:先进先出,有序
  • 延时队列:队列中的元素带有时间属性,希望在指定时间被处理

延时队列的应用场景

xxx事件之后/之前,执行xxx:

  • 订单未支付则,自动取消。
  • 新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。
  • 账单在一周内未支付,则自动结算。
  • 用户注册成功后,如果三天内没有登陆则进行短信提醒。
  • 用户发起退款,如果三天内没有得到处理则通知相关运营人员。
  • 预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议

为什么要用延时队列

定时轮询

上面说的应用场景除了延时队列,其实也可以用定时轮询的方式处理,定时轮询扫描数据库根据数据有效时间判断是否过期,如果过期则更新状态(删除订单等等)

但仅当定时任务数据量少时,可以用轮询(比如每秒差一次元素的时间属性)

轮询的缺点

轮询在大数据量时,轮询元素的时间可能大于轮询周期(一秒内不能轮询完)

如果元素周期比较长,比如一周后执行,每秒轮询效率比较低

实现延时队列1:JDK DelayQueue

创建订单类

放入DelayQueue的对象需要实现Delayed接口

public interface Delayed extends Comparable<Delayed> {
    long getDelay(TimeUnit unit);
}

可以看到,Delayed包含一个getDelay抽象方法,同时继承了Comparable<Delayed>接口,因此要实现Delayed接口需要实现getDelayComparable<Delayed>两个抽象方法,最后完成订单类CancelOrder,实现Delayed接口:

public class CancelOrder implements Delayed {
    // 订单号
    private String orderNo;
    // 过期时间 nano seconds
    private long timeout;

    public CancelOrder(String orderNo, long timeout) {
        this.orderNo = orderNo;
        this.timeout = timeout + System.nanoTime();
    }

    @Override
    public String toString() {
        return "CancelOrder{" +
                "orderNo='" + orderNo + '\'' +
                ", timeout=" + timeout +
                '}';
    }

    @Override
    public long getDelay(TimeUnit unit) {
        // 以JVM高分辨率时间源的值为参考,获取过期时刻
        return unit.convert(timeout - System.nanoTime(), TimeUnit.NANOSECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        if (o == this){
            return 0;
        }
        CancelOrder t = (CancelOrder) o;
        long d = (getDelay(TimeUnit.NANOSECONDS) - t.getDelay(TimeUnit.NANOSECONDS));
        return (d == 0) ? 0 : ((d < 0)?  -1 : 1);
    }
}

这里有几个地方需要啰嗦下:

  • 订单类CancelOrder包含两个成员属性:
    • orderNo:订单编号
    • timeout:过期时间,单位为纳秒(1ns = 10^-9s),所以要用long
  • getDelay()方法:用于获取订单过期的时刻,订单过期时刻是以JVM的时间作为起点计算的
    • System.nanoTime(): 返回正在运行的Java虚拟机的高分辨率时间源的当前值,以纳秒计
    • 订单过期的时刻 =JVM时间源的当前值+过期时间timeout
  • compareTo方法:就是实现了优先队列的比较方法,根据各个订单的过期时刻排序,这里其实就是一个小顶堆,队头为过期时刻最小的订单。

模拟延时队列

创建一个DelayQueue其实就跟创建PriorityQueue差不多,只不过这里不需要重写个Comparator,因为订单对象已经重写了CompareTo了,是一个队头为最早过期(过期时刻最小的)元素的小顶堆。

下面主要用到DelayQueue的两个方法,分别用于加入/取出订单:

  • put(): 非常亲切的入队方法,跟普通队列一样,将对象加入队尾;
  • take(): 取出队头【过期】对象(不是跟poll()一样直接取出了)

弄个测试类测试一下

public class DelayQueueTest {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<CancelOrder> queue = new DelayQueue<>();

        // 每秒生成1个订单,共生成5个订单
        for (int i = 0; i < 5; i++){
            // 10s过期
            CancelOrder cancelOrder = new CancelOrder("orderNo"+i, TimeUnit.NANOSECONDS.convert(10, TimeUnit.SECONDS));
            // 获取当前时间
            String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
            System.out.println(time + ": 生成订单, 10s有效,order: " + cancelOrder);
            // 将订单放入延时队列
            queue.put(cancelOrder);
            // 控制每秒生成一个订单
            Thread.sleep(1000);
        }
        // 延时队列取出超时订单
        try {
            while (!queue.isEmpty()){
                // 轮询获取队头过期元素
                CancelOrder order = queue.take();
                // 获取当前时间
                String timeout = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                System.out.println(timeout + ": 订单超时,order:"+order);
            }
        } catch (InterruptedException e){
            throw  new RuntimeException(e);
        }
    }
}

输出:

2024-03-30 18:54:37: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo0', timeout=636498762218800}
2024-03-30 18:54:38: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo1', timeout=636499784320900}
2024-03-30 18:54:39: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo2', timeout=636500788490700}
2024-03-30 18:54:40: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo3', timeout=636501792751100}
2024-03-30 18:54:41: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo4', timeout=636502796614500}
2024-03-30 18:54:47: 订单超时,order:CancelOrder{orderNo='orderNo0', timeout=636498762218800}
2024-03-30 18:54:48: 订单超时,order:CancelOrder{orderNo='orderNo1', timeout=636499784320900}
2024-03-30 18:54:49: 订单超时,order:CancelOrder{orderNo='orderNo2', timeout=636500788490700}
2024-03-30 18:54:50: 订单超时,order:CancelOrder{orderNo='orderNo3', timeout=636501792751100}
2024-03-30 18:54:51: 订单超时,order:CancelOrder{orderNo='orderNo4', timeout=636502796614500}

优缺点

优点

  • 简单,不需要借助其他第三方组件,成本低,适合单体应用

缺点

  • 不适合数据量较大的场景:所有可能超时的数据都要进入DelayQueue中,全部保存在JVM内存中,内存开销大,可能引发内存溢出
  • 无法持久化:因为存在JVM内存中,不像Redis可以通过AOF或者RDB持久化,宕机数据就丢失了
  • 无法较好地适配分布式集群:真要分布式就只能在集群中选一台leader专门处理,效率低

实现延时队列2:RabbitMQ

RabbitMQ实现消息队列主要有两种方式:

  • TTL+死信队列
  • RabbitMQ 延迟队列插件

TTL+死信队列

什么是TTL

RabbitMQ中消息或队列具有一个属性,TTL(Time To Live),表明一条消息或者队列中的所有消息的最大存活时间

// 设置队列的TTL
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 6000);
channel.queueDeclare(queueName, durable, exclusive, autoDelete, args);` 

// 设置每条消息的TTL
AMQP.BasicProperties.Builder builder = new AMQP.BasicProperties.Builder();
builder.expiration("6000");
AMQP.BasicProperties properties = builder.build();
channel.basicPublish(exchangeName, routingKey, mandatory, properties, "msg body".getBytes());` 

有一些注意事项:

  • 如果同时配置了队列的TTL和消息的TTL,就用较小值
  • 如果设置队列的TTL:消息过期就会被队列遗弃
  • 如果设置消息的TTL:消息过期会在即将送给消费者时确定,不会马上丢弃
  • 不设置TTL:消息永不过期
  • 如果将TTL设置为0:如果不能马上给到消费者就会被丢弃

TTL设置方式1:TTL设置在队列属性

  • 生产者生产一条延时消息,根据需要延时时间的不同,利用不同的routingkey将消息路由到不同的延时队列,每个队列都设置了不同的TTL属性,并绑定在同一个死信交换机中
  • 消息过期后,根据routingkey的不同,又会被路由到不同的死信队列中,消费者只需要监听对应的死信队列进行处理即可。

缺点:每增加一个新的实践需求就要增加一个新队列

TTL设置方式2:TTL设置在消息

延时队列可以接受任意延时时长到消息,增加一个对应的死信队列和routinkey

RabbitMQ延迟队列插件

springboot有集成,感兴趣朋友可以按这个博文自己试试

优缺点:

优点

  • RabbitMQ消息服务可靠性高,消息处理速度快,支持大数据量
  • 支持分布式横向扩展方便

缺点

  • 引入中间件系统复杂度增高,运维成本增加
  • 使用起来配置较复杂

类似的MQ中间件

比如RocketMQ

优点:

  • 使用简单,和普通消息一样
  • 支持分布式
  • 精度高,支持任意时刻

缺点:

  • 时长有限制:定时时长最大24h
  • 存储成本高:每新增一个定时消息,不会马上消费
  • 同一时刻大量消息会导致延迟(比如双十一活动)

实现延时队列3:Redis实现延时队列

过期监听

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过期通知时,应用正好重启,可能丢失事件(导致订单一直无法关闭)。
      • 先删数据在处理订单还是先处理订单再删除数据,处理异常时可能会导致数据丢失。
      • 可以用定时任务去做轮询补偿
    • 存储维护成本高:需要监听的数据较大时会占用中间件大量的存储空间,增加维护成本

总结

稍微总结一下

  • 延迟队列主要用于xxx事件之后/之前,执行xxx的场景。

  • 此类场景下,当数据量过大时,如果用长轮询,全部数据轮询一遍需要的时间可能会大于轮询周期,短板就出现了。虽然使用延时队列有时候也需要轮询,但只要轮询队头数据,不会出现这种问题

  • 实现延时队列的方法有很多:

    • JDK自带的DelayQueue,底层就是一个优先队列,传入对象需要实现Delayed接口,主要重写两个方法:getDelayComparable<Delayed>,让这个优先队列成为按过期时间排序的小顶堆,每次轮询堆顶元素即可;
    • 消息队列的中间件,比如RabbitMQ。RabbitMQ实现延时队列可以自己写TTL+死信队列(TTL可以加在数据上也可以加载队列上),或者通过插件配置,过期消息会被放入死信队列,消费者直接去死信队列消费数据;
    • Redis可以通过发布订阅模式实现过期监听,或者用Zset实现(zadd将score设为延时时间,通过zrangwithscores轮询获取队头元素)。
  • 各个方法各有优缺点。比如JDK DelayQueue实现简单不需要依赖第三方插件,但是不支持分布式集群,并且依赖于JVM内存,不好持久化,数据大量还容易内存溢出;MQ、Redis等中间件均支持分布式存储,而且基本都有持久化机制,但是普遍存在一些问题:

    • 极端情况下的不可靠:如果Redis过期通知时,应用正好重启,可能丢失事件(导致订单一直无法关闭)。
      • 可以通过定时任务做补偿机制
    • 存储维护成本高:需要监听的数据较大时会占用中间件大量的存储空间,增加维护成本

    但峰值数据量不大也不碍事,毕竟好用

Reference

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

阿里三面:生成订单未支付,则自动取消,该怎么实现 ?搞懵了。。。
还有个简书讲RabbitMQ的帖子找不到了

  • 17
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值