延迟队列的几种实现方案

延迟队列是这样一种队列:元素有一个过期时间,元素在队列中顺序是按照过期时间排序的,只有到达过期时间的元素才能出队,最先到达过期时间的元素最先出队。

延迟队列可以用到这样的场景:比如有一个任务需要 10 分钟后执行,那么就可以把这个任务存入延迟队列,并且设置过期时间为 10 分钟,队列的另一头有一个消费者去消费,没到达过期时间时消费者是取不到这个任务的,到达过期时间后消费者才能取到这个任务。

像这种多少分钟之后执行的任务特别适合使用延迟队列实现,如果不使用延迟队列,把任务存入数据库定期扫描也能实现,但是会有一个问题:如果扫描时间间隔长了,执行时间不准,如果扫描间隔时间短了,那么对数据库压力比较大。

DelayQueue

DelayQueue 是 JDK 中自带的延迟队列实现,向该队列中提交的任务需要实现 Delayed 接口,这个接口只有一个方法 long getDelay(TimeUnit unit) 这个接口如果返回 0 或负数表示任务已经到达了过期时间,可以执行;否则表示还没有到达过期时间,不能执行。

DelayQueue使用示例

public class DelayedTask implements Delayed {
    private String taskName;
    private long avaibleTime;

    public String getTaskName() {
        return taskName;
    }

    public DelayedTask(String taskName, long delayTime) {
        this.taskName = taskName;
        this.avaibleTime = System.currentTimeMillis() + delayTime;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(avaibleTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        return (int)(this.avaibleTime - ((DelayedTask)o).avaibleTime);
    }
}

上面代码中我们定义了 DelayedTask 任务并且实现了 Delayed 接口,在 DelayedTask 中维护了一个成员变量 avaibleTime 存放任务到期时间,在 getDelay 方法实现中用 avaibleTime 减去当前时间戳,如果是一个负数或 0,表示任务已经到达了到期时间,可以执行了。

测试

DelayQueue<DelayedTask> queue = new DelayQueue<>();
new Thread(()->{
    try {
        //take方法会阻塞,直到有任务到期
        DelayedTask task = queue.take();
        System.out.println(task.getTaskName());
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

queue.put(new DelayedTask("任务", 3000));

三秒种后输出 任务

实现原理

主要属性

//优先队列
private final PriorityQueue q = new PriorityQueue();
//leader线程表示正在等待出队的线程
private Thread leader = null;
//可用条件
private final Condition available = lock.newCondition();

DelayQueue 的底层实现是优先队列

take方法

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();try {
            for (;;) {
                E first = q.peek();
                if (first == null)      ②
                    available.await();
                else {
                    long delay = first.getDelay(NANOSECONDS);
                    if (delay <= 0)return q.poll();
                    first = null; // don't retain ref while waiting
                    if (leader != null)  ④
                        available.await();
                    else {
                        Thread thisThread = Thread.currentThread();
                        leader = thisThread;try {
                            available.awaitNanos(delay);
                        } finally {
                            if (leader == thisThread)
                                leader = null;
                        }
                    }
                }
            }
        } finally {
            if (leader == null && q.peek() != null)
                available.signal();    ⑥
            lock.unlock();
        }
    }

在 take 方法中,首先获取锁,然后查看队首元素

  • 如果队首元素为 null,说明队列是空的,调用 available.await() 等待 ②

  • 如果队首元素不为 null,调用 getDelay 判断该任务是否到期

    • 如果返回负数或 0 ,说明任务已经到期,直接获取队首元素返回 ③
    • 如果不是负数或 0,说明任务还没到期。这时判断 leader 是否为 null
      • 如果 leader 不为 null,说明当前有一个 leader 在等待队列头元素到期,调用 available.await() 将当前线程挂起 ④
      • 如果 leader 为 null,将当前线程设置为 leader ,并调用 available.await(delay) 挂起等待剩余到期时间。当线程被系统唤醒后,将 leader 设置为 null,重新去获取元素,这时队首元素就已经到期了,这个线程会在 ③ 处返回。
  • 最后,方法执行返回前,会判断如果 leader 为 null,并且队列中有元素,会调用 avilable.signal 唤醒一个等待的线程去竞争 leader。⑥

image.png

如图,现在有三个线程去消费这个队列,队列中的 TaskA 没到执行时间,Thread1 是当前的 Leader 线程,它会等待延迟纳秒数,然后拉取队列头节点消费并且调用 signal() 唤醒 Follower 节点。如下图,Thread3 节点被唤醒,它会先看一下头节点是否到期,如果没到期就等待到它到期。

image.png

时间轮算法

image.png

如上图,在时间轮算法中,有一个轮,这个轮由 n 个槽组成,有一个指针向钟表一样每隔一个时间间走到下个槽位上,然后这个槽位上的任务就可以执行。假设现在有 8 个槽组成时间轮,指针每秒移动一个槽位,现在指针指向 1 这个槽,假设现在有一个任务要在 5 秒钟之后执行,那么把这个任务就放在 5 + 1 = 6 这个槽上,这样在 5 秒钟后指针就会移动到这个槽,然后这个槽对应的任务就会被执行。

如果有任务要在 20 秒后执行,那么就需要指针多转两圈,将任务放在 (20%8 + 1)= 5 这个槽上。

这个圈数需要记录在槽中的数据结构里面。这个数据结构最重要的是两个指针,一个是触发任务的函数指针,另外一个是触发的总第几圈数。时间轮可以用简单的数组或者是环形链表来实现。

netty 的 HashedWheelTimer 就采用了时间轮算法。

相比 DelayQueue 的数据结构,时间轮在算法复杂度上有一定优势。DelayQueue 由于涉及到排序,需要调整数据的位置,插入和移除的复杂度是 O(lgn),而时间轮在插入和移除的复杂度都是 O(1)。

使用Redis的zset实现延迟队列

消息的生产者使用 zadd quque [当前时间戳 + 延迟时间] [序列化成字符串的消息] 将消息插入消息队列。

开启多个线程不断执行 zrangebyscore quque [0] [当前时间戳] limit 0 1 命令,ZRANGEBYSCORE这个命令表示获取队列中的成员,并按分数从小到大排列,limit 0 1 偏移量为 0,获取一条数据,所以返回的就是时间戳最小的那条。

判断这条数据是否到达了执行时间,如果到达了执行时间,执行 zrem memberZREM执行这个命令有两个目的:

1.从延迟队列中删除这条消息防止其它线程再次获取;

2.可能存在多个线程同时获取到这条消息,所以这里靠 zrem 只有返回 > 0,才说明当前线程成功获取到消息,可以消费消息,如果返回 = 0,说明已有其它线程抢先获取到消息了,当前线程不可以消费消息。

优化:

因为我们是用多个线程去同时 zrangebyscore 然后再通过 zrem 是否成功来确定是否争抢到消息,没有抢到消息的线程实际上就白白执行了一次 zrangebyscore,可以使用 Lua 脚本将 zrangebyscore 和 zrem 放到一起在服务端执行。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值