延时队列实战

延时队列,顾名思义它是一种带有延迟功能的消息队列。下面我们先谈谈延时队列的使用场景。

背景

我们先看看如下业务场景:

  • XXX时间之后给用户发送通知;
  • 处于未支付状态的订单,一小时之后自动关闭,等等

类似场景非常之多,这里不一一列举。为了解决以上问题,最简单直接的办法就是定时去扫表。简单场景下,这种方案还是可行的。但是当我们需要发送大批量的通知,或者需要扫描的表数据量很大时,这无疑会加重DB的负担。

总结:定时扫表的办法存在很大的效率问题,此外,存在无法控制时延的问题。

延时队列恰好是为了解决此类问题而生,下面进入本文主题。

单机环境解决方案

单机环境下我们不需要实现自己的延时队列,Java已经为我们提供了现成的解决方案----DelayQueue

DelayQueue简介

Java中的DelayQueue位于java.util.concurrent包下,本质是由PriorityQueue和BlockingQueue实现的无界阻塞优先级队列。DelayQueue设计要点:

  • Delayed:一个带有过期时间的元素;
  • PriorityQueue:优先级队列,意味着队列里元素需要根据Delayed的时延属性进行排序;
  • BlockingQueue:阻塞队列,意味着当我们使用take方法从队列取元素时,如果不存在已经过期的元素,线程会阻塞直到有可用元素。

DelayQueue本质是将元素保存在PriorityQueue中,并根据Delayed的时延属性进行排序。这样即可保证最先过期的元素排在队首,每次从队列里取出来都是最先要过期的元素。

DelayQueue原理

下面结合源码谈谈DelayQueue的实现。

入队

DelayQueue的add、put、offer均可向队列中添加一个元素,add和put本质是调用offer。我们知道,当队列满时,阻塞队列的put是会阻塞的,由于DelayQueue是无界的,put永远不会被阻塞。看源码:

public boolean offer(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        q.offer(e);
        if (q.peek() == e) {
            leader = null;
            available.signal();
        }
        return true;
    } finally {
        lock.unlock();
    }
}

offer实现还是比较清晰易懂的,首先加锁,然后将offer操作转发给PriorityQueue,由PriorityQueue实现元素添加并排序。如果当前元素是队头元素,将leader置为空,并唤醒第一个阻塞的线程(如果有)。

出队

我们可以调用poll和take从队列中取出队头元素,并从队列中删除该元素。区别在于元素未过期时,poll会返回null,take则会阻塞。下面看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内部是一个自旋操作,只有队列有可用元素可出队才会返回。首先会进行加锁,然后执行以下操作:

  1. 查询队头元素first(不从队列删除),若为空,说明队列为空,直接进入无限期等待状态;
  2. 如果队头元素first不为空,判断元素是否过期,已过期则将元素出队,跳到第6步;
  3. 如果元素未过期,释放first引用,接着判断leader是否为空,不为空说明Leader线程正在等待队头元素,当前线程立即进入无限期等待状态;
  4. 如果leader为空,将当前线程赋值给leader,然后等待指定delay时间,即等待队头元素到达可以出队的时间,最后在finally块中释放leader元素的引用;
  5. 重复以上过程,直到有元素可出队;
  6. 元素出队后,如果leader为空且队列非空,在finally块顺序唤醒等待中的第一个线程。

关键点说明:

当元素未过期,线程进入等待之前,会释放first引用。请注意,这是非常重要的。当多个线程同时在等待队列中时,Leader线程从队列中成功获取元素之后,first已经从队列中移除,这个对象理应被GC回收。如果不释放first引用,其他阻塞中的线程就会有引用指向该元素,GC链可达,导致GC无法回收first指向的内存。如果后续线程长期等待,就会造成内存泄漏。

leader的作用

leader指向正在等待队头元素的线程。这里使用了领导者/追随者Leader-Follower多线程模型的思想进行设计,目的是为了最小化不必要的定时等待。当一个线程成为Leader时,它只需等待下一个延迟,但Follower线程需要无限期地等待。Leader线程从take返回之前,负责发送唤醒通知,等待队列中第一个线程会被唤醒,开始竞争Leader。

当队列为空时,线程A调用take并获得锁,线程A直接进入无限期等待状态,并释放锁。此时若有其他线程请求take,将进入Condition的等待队列。

当队列加入第一个元素时,leader会被置为空。Condition负责唤醒其等待队列的第一个线程A,如果此时没有其他线程来竞争锁。线程A将会如愿获得锁,然后判断元素是否过期。如果未过期,当前线程就会成为Leader线程,然后等待指定delay时间后唤醒,将leader置为空。当线程A成功获取元素,如果延时队列不为空,它负责发送唤醒通知Condition的等待队列的第一个线程,如此循环。

当一个线程调用take并获得锁之后,如果leader不为空,说明Leader线程正在等待队头元素,当前线程将直接进入Condition的等待队列,并等待信号通知。

DelayQueue实战

构建消息体:

import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

public class CheckNoticeDelay implements Delayed {
    private Integer recordId;
    private long delayTime;

    public CheckNoticeDelay(Integer recordId, long delayTime) {
        this.recordId = recordId;
        this.delayTime = delayTime;
    }

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

    @Override
    public int compareTo(Delayed o) {
        if (o == null) {
            return 1;
        }
        if (o == this) {
            return 0;
        }
        long diff = this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS);
        return diff > 0 ? 1 : diff == 0 ? 0 : -1;
    }

    public long getDelayTime() {
        return delayTime;
    }

    public Integer getRecordId() {
        return recordId;
    }
}

队列管理CheckNotifyManager:

import java.util.concurrent.DelayQueue;
import org.springframework.util.Assert;

public class CheckNotifyManager {
    private DelayQueue<CheckNoticeDelay> pushQueue = new DelayQueue();

    public boolean addNotice(CheckNoticeDelay checkNotice) {
        Assert.notNull(checkNotice);
        return pushQueue.offer(checkNotice);
    }

    public CheckNoticeDelay takeNotice() throws InterruptedException {
        return pushQueue.take();
    }
}

构建Task,不断从延时队列取消息并发送push:

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

public class CheckRecordPushTask implements Runnable {
    private static Logger logger = LoggerFactory.getLogger(CheckRecordPushTask.class);

    private CheckNotifyManager manager;

    public CheckRecordPushTask(CheckNotifyManager manager) {
        this.manager = manager;
    }

    @Override
    public void run() {
        while (true) {
            try {
                CheckNoticeDelay delay = manager.takeNotice();
                if (delay != null) {
                    logger.info("push msg,recordId:{}", delay.getRecordId());
                }
            } catch (InterruptedException e) {
                logger.error("Thread is interrupted");
            }
        }
    }
}

测试:

@Test
public void testPushNotice() {
    CheckNotifyManager manager = new CheckNotifyManager();
    manager.addNotice(new CheckNoticeDelay(1, System.currentTimeMillis() + 1 * 1000));//1s
    manager.addNotice(new CheckNoticeDelay(2, System.currentTimeMillis() + 6 * 1000));//6s
    manager.addNotice(new CheckNoticeDelay(3, System.currentTimeMillis() + 3 * 1000));//3s
    CheckRecordPushTask task = new CheckRecordPushTask(manager);
    Thread thread = new Thread(task);
    thread.start();
    try {
        Thread.sleep(10 * 1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    thread.interrupt();
}

请注意,由于DelayQueue中的数据是保存在当前JVM堆内存中,一旦服务重启,数据就会丢失。因此,需要考虑数据持久化,服务重启时,需要从DB中load未处理的数据。

DelayQueue使用限制

不可否认,单机环境下,DelayQueue是实现延时需求很理想的方案,精巧好用。但仍然存在以下问题:

  • DelayQueue中的数据始终是保存在当前JVM堆内存中,受JVM内存大小的限制,无法动态扩展以满足大容量需求;
  • 每次服务重启,都需要从DB中load未处理的数据;
  • 无法迁移到分布式环境下,这是硬伤。

分布式环境解决方案

为了解决分布式环境下延时场景的需求,我们采用了redis实现延时队列。

redis实现

场景需求:我们的形象抽查系统负责对用户发起一个抽查,如果一个用户被抽查之后未处理,那么我们需要在抽查即将过期时给用户发送通知。

Redis提供了很多数据结构,其中的zset是一种有序的数据结构,正好适合实现一个自己的延时队列。实现思路如下:

消息体Message:recordId,delayTime

入队:每创建一条抽查记录,构造一个消息Message,以delayTime作为元素的score将消息存入zset。

出队:我们有专门的调度线程负责从zset中获取队头元素。如果元素未过期,将该元素重新存入zset,然后退出本次循环;如果元素已过期,将消息提交到任务线程池异步给用户发送通知,调度线程继续处理下一个元素。

方案优点:

  • 容量大小理论上只受redis集群的限制,能够满足大容量需求;
  • 应用重启不必考虑load未处理的数据,redis重启自有机制保证数据不丢失,满足持久化需求;
  • 满足分布式应用场景。

待优化的点:

  • 调度线程由定时参数控制(如1min跑一次),定时参数需要根据场景定制化;
  • 通过调度线程定时去扫描zset队列,未免太被动,容易造成资源浪费;
  • 只有一个队列,容易造成消息的堆积;
  • 当大批量元素集中在某一时刻过期时,容易导致CPU彪高或处理延迟。

队列管理CheckNotifyRedisManager:

import org.springframework.util.Assert;

public class CheckNotifyRedisManager {
    private RedisCacheService redisCacheService;
    public CheckNotifyRedisManager(RedisCacheService redisCacheService){
        this.redisCacheService=redisCacheService;
    }

    public boolean addNotice(CheckNoticeDelay checkNotice) {
        Assert.notNull(checkNotice);
        redisCacheService.zput(RedisKeyConstant.DELAY_QUEUE_KEY, notice, notice.getDelayTime());
        return Boolean.TRUE;
    }

    public CheckNoticeDelay takeNotice() {
        Object obj = redisCacheService.zpop(RedisKeyConstant.DELAY_QUEUE_KEY);
        if (obj != null && obj instanceof CheckNoticeDelay) {
            CheckNoticeDelay delay= (CheckNoticeDelay) obj;
            if (delay.getDelayTime() > System.currentTimeMillis()) {
                addNotice(delay);
                return null;
            }
            return delay;
        }
        return null;
    }
}

task改造:

@Override
public void run() {
    while (true) {
        try {
            CheckNoticeDelay delay = manager.takeNotice();
            if (delay == null) {
                break;
            }
            logger.info("push msg,recordId:{}", delay.getRecordId());
        } catch (InterruptedException e) {
            logger.error("Thread is interrupted");
        }
    }
}

后续优化

使用Java单线程池newSingleThreadExecutor作为调度线程,负责扫描zset队列,然后分两种情况:

  1. 队列为空,借助Java对象锁的wait/notify机制,调度线程进入无限等待,当我们向队列加入元素时唤醒;
  2. 队列不空,队头元素未过期,则调度线程sleep队头元素即将超时的时间。

CheckNotifyRedisManager改造:

public class CheckNotifyRedisManager {
    private RedisCacheService redisCacheService;
    public CheckNotifyRedisManager(RedisCacheService redisCacheService){
        this.redisCacheService=redisCacheService;
    }

    public boolean addNotice(CheckNoticeDelay checkNotice) {
        Assert.notNull(checkNotice);
        redisCacheService.zput(RedisKeyConstant.DELAY_QUEUE_KEY, notice, notice.getDelayTime());
        synchronized (waitLock){
            waitLock.notifyAll();
        }
        return Boolean.TRUE;
    }

    public CheckNoticeDelay takeNotice() {
        while(true){
            Object obj = redisCacheService.zpop(RedisKeyConstant.DELAY_QUEUE_KEY);
            if (obj != null && obj instanceof CheckNoticeDelay) {
                CheckNoticeDelay delay= (CheckNoticeDelay) obj;
                if (delay.getDelayTime() <= System.currentTimeMillis()) {
                    return (CheckNoticeDelay) obj;
                }
                pushDelayNotice(delayNotice);
                synchronized (waitLock){
                    waitLock.wait(delayNotice.getDelayTime()-System.currentTimeMillis());
                }
            }
            synchronized (waitLock){
                waitLock.wait();
            }
        }
    }
}

这样调度线程可以更加灵活,当延时队列长期为空时不会大致大量无效查询。但该方案有一个极端情况,若某一台服务长期未向队列加入元素,可能导致调度线程一直等待。更可怕的是,如果延时队列已经累积大量的任务,而某台机器由于未得到通知,将迟迟不能运行。当然,负载均衡策略可以避免出现这种极端情况。

使用redis的zset来实现延时队列是分布式环境下比较便捷的一种方案。但不是唯一方案,可选择的方案还是比较多的,如RabbitMQ和Beanstalkd,有兴趣自行研究。

欢迎指出本文有误的地方。

转载于:https://my.oschina.net/7001/blog/1600633

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值