实现延时任务可以有几种方式

在开发中,延时任务属于比较常见的需求,比如,订单在限定时间内未支付则自动取消并释放库存;外卖的商家端在设置特定时间后仍未接单时则自动接单等这都需要延时任务来完成。
实现延时任务的方式可以有许多种:
1 DelayQueue(JDK提供实现)
2 ScheduledExecutorService(JDK提供实现)
3 Redis(使用ZSET数据结构实现)
4 RabbitMQ实现
5 ……

下面我们来简单看看这几种方式的实现,并分析其优缺点。

1 DelayQueue

DelayQueue属于JDK并发包java.util.concurrent中提供的一个类,它是一个无界阻塞队列,元素只有在延迟时间到达时才能被获取。该队列的元素必须实现Delayed接口,队列头部是剩余延迟时间最小的元素。
使用过程中,添加任务时使用add()方法,获取任务时使用poll()或take()方法,poll()方法和take()方法的区别是take()方法是阻塞的,如果没有到点的任务可取,take()方法会等待直到可用,而poll()方法则会直接返回null。
其实现如下:

class DelayTask implements Delayed {

    //延迟时间
    private long delayTime;

    //任务开始时间
    private long startTime;

    //任务消息
    private T data;

    public DelayTask(long delayTime, T data) {
        this.delayTime = delayTime;
        this.data = data;
        this.startTime = System.currentTimeMillis() + delayTime;
    }

    public long getDelayTime() {
        return delayTime;
    }

    public void setDelayTime(long delayTime) {
        this.delayTime = delayTime;
    }

    public long getStartTime() {
        return startTime;
    }

    public void setStartTime(long startTime) {
        this.startTime = startTime;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

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

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

public class DelayQueueTest {
    public static void main(String[] args){
        DelayQueue delayQueue = new DelayQueue<>();
        DelayTask delayTask = new DelayTask<>(1000, "1s后执行");
        DelayTask delayTask2 = new DelayTask<>(3000, "3s后执行");

        delayQueue.add(delayTask);
        delayQueue.add(delayTask2);

        for (;;){
            try {
                DelayTask task = delayQueue.take();
                new Thread(() -> {
                    System.out.println(task.getData());
                }).start();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if(delayQueue.isEmpty()){
                break;
            }
        }
    }
}

该方式实现非常简单,但缺点也是显而易见的,其适用于单机环境下,而且延迟任务没有进行持久化存储,一旦关机断电,任务便不存在了。

2 ScheduledExecutorService

与DelayQueue一样,ScheduledExecutorService同属于java.util.concurrent包中,使用起来也是拿来即用,非常简单的:

 ScheduledExecutorService executorService = Executors.newScheduledThreadPool(5);
 executorService.schedule(() -> {
     System.out.println("1s后执行");
 }, 1, TimeUnit.SECONDS);

其优缺点与DelayQueue一般,这里就不多说了。

3 Redis(使用ZSET数据结构实现)

ZSET是Redis提供的一种有序集合数据结构,集合的元素value会关联一个double类型的分数(score),集合会根据这个分数来对元素进行从小到大的排序。
我们可以将延时任务消息序列化成一个字符串作为 zset 的value,这个任务消息的到期处理时间作为score进行存储,另外启用一个或者多个线程对集合中的任务进行到期判断处理(以当前时间为界限,获取到集合的首个元素,进行处理并从集合中删除元素),其中,在多线程环境下,为了使获取元素和移除元素的操作的原子性,这里使用到了lua脚本。

其实现如下:
对应lua脚本:

local key = KEYS[1]
local minVal = ARGV[1]
local maxVal = ARGV[2]
local todolist = redis.call("ZRANGEBYSCORE", key, minVal, maxVal, "limit", 0, 1)
local todo = todolist[1]
if todo == nil then
	return nil
else
	redis.call("ZREM", key, todo)
	return todo
end

延时任务处理实现:

class RedisTasksExecutor{

    private String queueKey;

    private RedisPoolWrapper redisPoolWrapper;

    public RedisTasksExecutor(String queueKey){
        this.queueKey = queueKey;
        this.redisPoolWrapper = new RedisPoolWrapper();
    }

    public void submit(T msg,final long delayTime){
        final DelayTask task = new DelayTask();
        task.id = UUID.randomUUID().toString();
        task.msg = msg;
        redisPoolWrapper.execute((Jedis jedis) -> {

            jedis.zadd(queueKey, System.currentTimeMillis() + delayTime, JSON.toJSONString(task));
        });
    }

    public void handle(T msg){
        System.out.println(msg);
    }

    public void loop(){
    	//加载lua脚本
        String luaScript = ScriptLoad.load("delayed.lua");
        redisPoolWrapper.execute((Jedis jedis) -> {
            for (;;){
                String taskStr = (String) jedis.eval(luaScript, Arrays.asList(queueKey), Arrays.asList("0", String.valueOf(System.currentTimeMillis())));
                if(taskStr == null){
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        break;
                    }
                    continue;
                }
                DelayTask task = JSON.parseObject(taskStr, new TypeReference>(){}.getType());
                this.handle(task.msg);
            }
        });
    }

    static class DelayTask{
        public String id;
        public T msg;
    }

}
public class RedisDelayedTaskTest {
    public static void main(String[] args) {
        RedisTasksExecutor executor = new RedisTasksExecutor<>("zset");
        new Thread(() -> {
            for (int i = 0; i < 10; i++){
                int ii = new Random().nextInt(10);
                executor.submit((ii + 1) + "s后执行", (ii+1) * 1000);
            }
        }).start();
        new Thread(() -> {
            executor.loop();
        }).start();
    }
}

使用Redis的这种延时任务实现适用于分布式环境,消息也能够保证持久化存储,但其并不能保证任务消息消费过程中的可靠性(就上面的实现来说,如果我们获取到了任务,但还没处理完成出现了异常,操作被中断了,那么这条任务是彻底丢失了)。

4 RabbitMQ实现

RabbitMQ本身并不提供延时任务功能的实现,但可以通过它的Time-To-Live Extensions 与 Dead Letter Exchange 的特性模拟出延迟队列的功能。
RabbitMQ支持为队列或者消息设置TTL(存活时间)。TTL表明了一条消息可在队列中存活的最大时间。当某条消息被设置了TTL或者当某条消息进入了设置了TTL的队列时(如果既配置了消息的TTL,又配置了队列的TTL,那么较小的那个值会被取用),这条消息会在TTL时间后死亡成为Dead Letter。如果为这个队列设置了x-dead-letter-exchangex-dead-letter-routing-key,那么这些Dead Letter就会被重新发送到x-dead-letter-exchange指定的exchange中,然后通过根据x-dead-letter-routing-key路由到相应队列,这时我们通过监听x-dead-letter-exchange中绑定的队列,即可实现延迟队列的功能。
实例(整合Spring Boot):
配置文件:

spring.rabbitmq.host=
spring.rabbitmq.port=
spring.rabbitmq.username=
spring.rabbitmq.password=
spring.rabbitmq.virtual-host=/

#消息发送确认
spring.rabbitmq.publisher-confirms=true
#消息没有相应队列和交换器绑定时是否返回,好像没有用?
#spring.rabbitmq.publisher-returns=true
#与return机制结合配置此属性,true返回消息,false丢弃消息
#spring.rabbitmq.template.mandatory=true

#消息消费手动确认
spring.rabbitmq.listener.simple.acknowledge-mode=manual

RabbitMQ配置:

@Configuration
public class RabbitMQConfig
        implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {

    //Dead Letter Exchange
    public static final String DELAYED_EXEC_EXCHANGE_NAME = "delayed.exec.exchange";

    //Dead Letter Queue
    public static final String DELAYED_EXEC_QUEUE_NAME = "delayed.exec.queue";

    //Dead Letter Routing Key
    public static final String DELAYED_EXEC_ROUTING_KEY = "delayed.exec.routing.key";

    public static final String DELAYED_EXCHANGE_NAME = "delayed.exchange";

    public static final String DELAYED_QUEUE_NAME = "delayed.queue";

    public static final String DELAYED_ROUTING_KEY = "delayed.routing.key";

    @Bean
    public RabbitTemplate rabbitTemplate(CachingConnectionFactory connectionFactory){
        RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
        rabbitTemplate.setMandatory(true);
        rabbitTemplate.setConfirmCallback(this);
        rabbitTemplate.setReturnCallback(this);
        return rabbitTemplate;
    }

    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        System.out.println("correlationData:" + correlationData + ",cause:" + cause);
        if(!ack){
            System.out.println("消息发送失败!");
        }else {
            System.out.println("消息发送成功!");
        }
    }

    @Override
    public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
        System.out.println("没有找到对应的队列!");
        System.out.println("message:" + message +
                ",replyCode:" + replyCode +
                ",replyText:" + replyText +
                ",exchange:" + exchange +
                ",routingKey:" + routingKey);
    }

    @Bean
    public Queue delayedQueue(){
        Map params = new HashMap<>();
        params.put("x-dead-letter-exchange", DELAYED_EXEC_EXCHANGE_NAME);
        params.put("x-dead-letter-routing-key", DELAYED_EXEC_ROUTING_KEY);
//        params.put("x-message-ttl", 5 * 1000);
        return new Queue(DELAYED_QUEUE_NAME, true,false, false, params);
    }

    @Bean
    public DirectExchange delayedExchange(){
        return new DirectExchange(DELAYED_EXCHANGE_NAME);
    }

    @Bean
    public Binding delayedBind(){
        return BindingBuilder.bind(delayedQueue()).to(delayedExchange()).with(DELAYED_ROUTING_KEY);
    }

    @Bean
    public Queue delayedExecQueue(){
        return new Queue(DELAYED_EXEC_QUEUE_NAME,true);
    }

    @Bean
    public TopicExchange delayedExecExchange(){
        return new TopicExchange(DELAYED_EXEC_EXCHANGE_NAME);
    }

    @Bean
    public Binding delayedExecBind(){
        return BindingBuilder.bind(delayedExecQueue()).to(delayedExecExchange()).with(DELAYED_EXEC_ROUTING_KEY);
    }
}

RabbitMQ延迟消息发送:

@Component
public class RabbitMQSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendDelayedMsg(String data){
        rabbitTemplate.convertAndSend(RabbitMQConfig.DELAYED_EXCHANGE_NAME, RabbitMQConfig.DELAYED_ROUTING_KEY, data, message -> {
            message.getMessageProperties().setExpiration(5 * 1000 + "");
            return message;
        });
    }

}

延迟队列监听:

@Component
public class RabbitMQReceiver {

    @RabbitListener(queues = {RabbitMQConfig.DELAYED_EXEC_QUEUE_NAME})
    public void delayedExec(String data, Message message, Channel channel){
        System.out.println("data:" + data);
        try {
          //消息确认
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

使用RabbitMQ的延迟任务实现适用于分布式环境,并且消息也支持持久化,消息的发送和消费也因为有了确认机制的支持而有了更高的可靠性。但需要注意的是,用这种方式实现的延时任务,如果需要实现不同消息的消息有不同的延迟时间的话,共用一个队列是不可行的。比如两条消息,一条延迟时间为20s的消息先抵达队列,另一条延迟时间为10s的消息后抵达,那么此时的消息消费顺序是,经过20s后,第一条消息将会先被消费,第二条消息在紧接其后被消费(与入队顺序保持了一致),在这种情况下,就只能通过设置多个不同延时时间的队列来实现了。

完整代码:Github地址

参考:
《Redis 深度历险:核心原理与应用实践》——老钱
https://juejin.im/post/5b5e52ecf265da0f716c3203
https://segmentfault.com/a/1190000015369917

你可能感兴趣的:(Backend,延时任务,RabbitMQ,Redis)