常用定时任务方案

单机版

单机版定时任务方案比较简单,通常用于控制单台主机或者单个进程的定时任务。
常用的单机版定时任务方案有:
1.Linux crontab
2.JDK TimerTask,Spring TaskQuartz(Java语言开发)
3.APScheduler框架,schedule模块(Python语言)

集群版

XXL-JOB

https://www.xuxueli.com/xxl-job/

XXL-JOB是目前使用得比较流行的开源定时任务解决方案,目前社区一直还在维护。

基于RabbitMQ

原理概述

基于RabbitMQ实现定时任务主要就是利用“死信”机制,详见Dead Letter ExchangesTime-To-Live and Expiration

如何来描述RabbitMQ的这个“死信”机制呢?如下图所示。
基于RabbitMQ实现定时任务

如上图所示,这正是RabbitMQ的“死信”实现机制,其中最关键的2个要素就是:
1.消息如何成为“死信”
2.死信交换器(本身也是一个普通的交换器,只是成为“死信”的消息会自动转发到这个交换器)

于是就可以利用这个机制来实现某些场景的定时任务了。
具体来说,在发送RabbitMQ消息的时候设置TTL超时时间,同时给队列设置“死信交换机”,这样当消息在队列中超时后就会被自动转发到“死信交换机”,消费者再通过队列去绑定这个死信交换机就能在一定的延时之后获取到消息了。

具体实现

网络上的一些博客文章都是基于SpringBoot框架直接注入相应的Bean对象来编写演示代码,个人觉得这样不够直观。
如下代码示例直接基于RabbitMQ的API接口来实现,并添加了必要的注释信息。

<dependency>
    <groupId>com.rabbitmq</groupId>
    <artifactId>amqp-client</artifactId>
    <version>5.18.0</version>
</dependency>
public class DeadLetterSimple {
    // MQ信息
    private final static String MQ_HOST = "192.168.100.20";
    private final static String MQ_USER = "admin";
    private final static String MQ_PASS = "admin";

    // 死信交换器,用于转发死信消息
    private final static String DEAD_LETTER_EXCHANGE = "dead_letter_exchange";

    // 死信进入到死信交换器之后新的routing-key
    private final static String DEAD_LETTER_ROUTING_KEY ="dead-letter-routing-key";

    // 保存带超时时间的消息的源队列
    private final static String DEAD_LETTER_QUEUE_ORIGIN = "dead_letter_queue_origin";

    // 最终从死信交换器读取数据的目标队列
    private final static String DEAD_LETTER_QUEUE_TARGET = "dead_letter_queue_target";

    // 生产死信消息
    private void produce(String message, long expiration) throws IOException, TimeoutException {
        try (Connection connection = buildConnectionFactory().newConnection(); Channel channel = connection.createChannel()) {
            // 定义死信交换器,也是一个普通的交换器
            channel.exchangeDeclare(DEAD_LETTER_EXCHANGE, "direct");

            // 定义死信队列,用于保存死信消息,并指定死信交换器
            // 这样进入该死信队列的消息在超时之后就会被重新转发到指定的交换器
            Map<String, Object> args = new HashMap<>();
            args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
            args.put("x-dead-letter-routing-key", DEAD_LETTER_ROUTING_KEY);
            channel.queueDeclare(DEAD_LETTER_QUEUE_ORIGIN, false, false, false, args);

            // 将消息发布到默认交换器,默认交换器隐含绑定到每一个队列,routing-key等于队列名称
            // 给发布到交换器的消息都设置一个超时时间
            AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
                    //.expiration("60000")
                    .expiration(String.valueOf(expiration))
                    .build();
            channel.basicPublish("", DEAD_LETTER_QUEUE_ORIGIN, properties, message.getBytes());
            System.out.println(String.format("[x] Sent '%s' %s", message, new Date()));
        }
    }

    // 消费死信消息
    private void consume() throws IOException, TimeoutException {
        Connection connection = buildConnectionFactory().newConnection();
        Channel channel = connection.createChannel();

        // 定义消费数据的队列,该队列与死信交换器进行绑定
        channel.queueDeclare(DEAD_LETTER_QUEUE_TARGET, false, false, false, null);
        channel.queueBind(DEAD_LETTER_QUEUE_TARGET, DEAD_LETTER_EXCHANGE, DEAD_LETTER_ROUTING_KEY);
        System.out.println(String.format("[*] Waiting for message..."));

        DeliverCallback callback = new DeliverCallback() {
            @Override
            public void handle(String consumerTag, Delivery delivery) throws IOException {
                String message = new String(delivery.getBody(), "UTF-8");
                System.out.println(String.format("[x] received message: %s %s", message, new Date()));
            }
        };
        // 从指定队列消费
        channel.basicConsume(DEAD_LETTER_QUEUE_TARGET, true, callback, consumerTag -> {});
    }

    private ConnectionFactory buildConnectionFactory() {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost(MQ_HOST);
        factory.setUsername(MQ_USER);
        factory.setPassword(MQ_PASS);
        return factory;
    }

    private void startConsumer() throws InterruptedException {
        new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                consume();
            }
        }).join();
    }

    private void doSleep(int seconds) {
        try {
            Thread.sleep(seconds * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws IOException, TimeoutException {
        DeadLetterSimple simple = new DeadLetterSimple();
        simple.consume();
        simple.produce("hello_60000", 60000);
    }
}

运行上述代码,输出:
基于RabbitMQ实现定时任务

显然,利用RabbitMQ的“死信”机制达到了延迟消费的效果。
于是,在处理诸如“订单超过30分钟未支付自动关闭”,“优惠券定时过期”这样的延迟任务时就非常适合使用RabbitMQ的“死信”机制了。

存在的问题

在使用RabbitMQ实现延迟任务处理时需要注意2个问题:
1.消息只有达到队列头部才能成为死信,即使消息的TTL时间已经超时,但是它并不在头部也不能成为死信。换句话说,先发送的消息一定是先被消费,即使有TTL比较小的消息后发布到队列也无法被最先处理。
2.默认情况下,死信消息在被重新转发到死信交换器是不存在确认机制的,这样在RabbitMQ集群环境下不能确保安全性。具体来讲,死信消息在被转发到死信交换器的目标队列时就会被立即从源队列删除,此时如果目标队列不能接受消息时就存在丢失的风险。从RabbitMQ 3.10版本开始,可以使用quorum queues来做一些保障。

如下来验证下问题1的情况:

DeadLetterSimple simple = new DeadLetterSimple();
simple.consume();

// 验证一个问题:
// 消息只有到达队列头部的时候才可能会成为死信;如果并未到达队列头部即使超时时间到了,也不会成为死信被转发的
simple.produce("hello1_60000", 60000);
simple.doSleep(3);

simple.produce("hello2_60000", 60000);
simple.doSleep(3);

// 这个消息会最先超时但是因为不在队列头部,不能最先得到处理
simple.produce("hello3_30000", 30000);

输出:

[*] Waiting for message...
[x] Sent 'hello1_60000' Fri Jul 14 18:13:48 CST 2023
[x] Sent 'hello2_60000' Fri Jul 14 18:13:51 CST 2023
[x] Sent 'hello3_30000' Fri Jul 14 18:13:54 CST 2023
[x] received message: hello1_60000 Fri Jul 14 18:14:48 CST 2023  # 最先发送的消息最先被处理
[x] received message: hello2_60000 Fri Jul 14 18:14:51 CST 2023  
[x] received message: hello3_30000 Fri Jul 14 18:14:51 CST 2023  # TTL最小,但是依然在最后被处理

由于存在问题1,所以在应用RabbitMQ处理延迟任务的场景一定要注意是否存在中途需要“加塞”延迟任务的情况,如果存在就可能不适合使用RabbitMQ了。

【参考】
java定时器实现总结
分布式定时任务调度框架选型
Rabbitmq延迟队列实现定时任务,这才是正确的方式!
RabbitMQ基于Java的定时任务实现
利用RabbitMQ 的死信队列来做定时任务

posted @ 2022-01-13 23:08  nuccch  阅读(156)  评论(0编辑  收藏  举报