实战|当然我还是更建议你用MQ搞定超时订单的-(2)

6,413 阅读8分钟

一、依然用三根鸡毛做引言

  • 真的! 不骗你们的喔~ 相信大家都遇到类似于:订单30min后未支付自动取消的开发任务

二、MQ 延迟消息实现原理

傲娇的RabbitMQ官网赫然写着:

RabbitMQ is the most widely deployed open source message broker.

由此可见,RabbitMQ是一个消息中间件,生产者生成消息,消费者消费消息,它遵循AMQP(高级消息队列协议),是最广泛部署的开源消息代理。 所以,今天我用RabbitMQ为大家捣鼓一下延迟队列。

使用RabbitMQ来实现延迟任务必须先了解RabbitMQ的两个概念:消息的TTL和死信Exchange,通过这两者的组合来实现上述需求。

  • 消息的TTL(Time To Live)

消息的TTL就是消息的存活时间。RabbitMQ 可以对队列和消息分别设置TTL。对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信。如果队列设置了,消息也设置了,那么会取小的(谁小谁尴尬)。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。

那么,如何设置这个TTL值呢?有两种方式,第一种是在创建队列的时候设置队列的"x-message-ttl"属性,如下:

Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 6000);
channel.queueDeclare(queueName, durable, exclusive, autoDelete, args);

这样所有被投递到该队列的消息都最多不会存活超过6s。

另一种方式便是针对每条消息设置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());

这样这条消息的过期时间也被设置成了6s。

但这两种方式是有区别的,如果设置了队列的TTL属性,那么一旦消息过期,就会被队列丢弃,而第二种方式,消息即使过期,也不一定会被马上丢弃,因为消息是否过期是在即将投递到消费者之前判定的,如果当前队列有严重的消息积压情况,则已过期的消息也许还能存活较长时间。 另外,还需要注意的一点是,如果不设置TTL,表示消息永远不会过期,如果将TTL设置为0,则表示除非此时可以直接投递该消息到消费者,否则该消息将会被丢弃。

单靠死信还不能实现延迟任务,还要靠Dead Letter Exchange

  • Dead Letter Exchanges

Exchage的概念在这里就不在赘述。一个消息在满足如下条件下,会进死信路由,记住这里是路由而不是队列,一个路由可以对应很多队列。

  1. 一个消息被Consumer拒收了,并且reject方法的参数里requeuefalse。也就是说不会被再次放在队列里,被其他消费者使用。
  2. 上面的消息的TTL到了,消息就过期了。
  3. 队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上。

Dead Letter Exchange其实就是一种普通的exchange,和创建其他exchange没有两样。只是在某一个设置Dead Letter Exchange的队列中有消息过期了,会自动触发消息的转发,发送到Dead Letter Exchange中去。

  • 原理图

延迟任务通过消息的TTLDead Letter Exchange来实现。我们需要建立2个队列,一个用于发送消息,一个用于消息过期后的转发目标队列。

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

三、实战演练

  • 下载安装 windows示例
  1. 下载RabbitMQ,需要ErLang环境的支持
  2. 运行命令
rabbitmq-plugins enable rabbitmq_management

开启Web管理插件,然后启动rabbitmq-server访问http://localhost:15672/#/,输入密令后你能看到就可以啦.

  • 插件安装

在 RabbitMQ 3.6.x 之前我们一般采用死信队列(DLX)+TTL过期时间来实现延迟队列,我们这里不做过多介绍,可以参考其他道友的:TTL+DLX实现方式。

在 RabbitMQ 3.6.x开始(现在都3.8.+了),RabbitMQ 官方提供了延迟队列的插件,可以下载放置到 RabbitMQ 根目录下的 plugins 下。延迟队列插件下载地址:

  1. 官方地址 2. JFrog Bintray地址 我安装的时候在官网没找到3.7.x的,但是3.8.0是向下兼容3.7.x的,然后我又在Bintray找到了3.7.x,大家信不过就找对应的版本插件哈....

下载好,放到plugins的目录中,运行如下命令:

rabbitmq-plugins enable rabbitmq_delayed_message_exchange
  • 搭建SpringBoot环境
  1. yml配置如下
#集成 rabbitmq
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    connection-timeout: 150000
    publisher-confirms: true    #开启确认机制 采用消息确认模式,
    publisher-returns: true     #开启return确认机制
    template:                   #消息发出去后,异步等待响应
      mandatory: true           #设置为 true 后,消费者在消息没有被路由到合适队列情况下会被return监听,而不会自动删除
  1. 启动配置声明几个Bean
@Configuration
public class MQConfig {
    @Bean
    public RabbitListenerContainerFactory<?> rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
        SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        factory.setMessageConverter(new Jackson2JsonMessageConverter());
        factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
        return factory;
    }

    public static final String DELAY_EXCHANGE = "Ex.DelayExchange";
    public static final String DELAY_QUEUE = "MQ.DelayQueue";
    public static final String DELAY_KEY = "delay.#";

    /**
     * 延时交换机
     *
     * @return TopicExchange
     */
    @Bean
    public TopicExchange delayExchange() {
        Map<String, Object> pros = new HashMap<>(3);
        //设置交换机支持延迟消息推送
        pros.put("x-delayed-message", "topic");
        TopicExchange exchange = new TopicExchange(DELAY_EXCHANGE, true, false, pros);
        //我们在也可以在 Exchange 的声明中可以设置exchange.setDelayed(true)来开启延迟队列
        exchange.setDelayed(true);
        return exchange;
    }

    /**
     * 延时队列
     * 
     * @return Queue
     */
    @Bean
    public Queue delayQueue() {
        return new Queue(DELAY_QUEUE, true);
    }

    /**
     * 绑定队列和交换机,以及设定路由规则key
     *
     * @return Binding
     */
    @Bean
    public Binding delayBinding() {
        return BindingBuilder.bind(delayQueue()).to(delayExchange()).with(DELAY_KEY);
    }
}
  1. 创建一个生产者
/**
 * @author LiJing
 * @ClassName: MQSender
 * @Description: MQ发送 生产者
 * @date 2019/10/9 11:50
 */
@Component
public class MQSender {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    final RabbitTemplate.ConfirmCallback confirmCallback = new RabbitTemplate.ConfirmCallback() {
        @Override
        public void confirm(CorrelationData correlationData, boolean ack, String cause) {
            System.out.println("correlationData: " + correlationData);
            System.out.println("ack: " + ack);
            if (!ack) {
                System.out.println("异常处理....");
            }
        }
    };

    final RabbitTemplate.ReturnCallback returnCallback = new RabbitTemplate.ReturnCallback() {
        @Override
        public void returnedMessage(Message message, int replyCode, String replyText, String exchange
        , String routingKey) {
            System.out.println("return exchange: " + exchange + ", routingKey: "
                    + routingKey + ", replyCode: " + replyCode + ", replyText: " + replyText);
        }
    };

    public void sendDelay(Object message, int delayTime) {
        //采用消息确认模式,消息发出去后,异步等待响应
        rabbitTemplate.setMandatory(true);
        rabbitTemplate.setConfirmCallback(confirmCallback);
        rabbitTemplate.setReturnCallback(returnCallback);
        //id + 时间戳 全局唯一
        CorrelationData correlationData = new CorrelationData("delay" + System.nanoTime());
        //发送消息时指定 header 延迟时间
        rabbitTemplate.convertAndSend(MQConfig.DELAY_EXCHANGE, "delay.boot", message,
                new MessagePostProcessor() {
                    @Override
                    public Message postProcessMessage(Message message) throws AmqpException {
                        //设置消息持久化
                        message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
                        // 两种方式 均可
                        //message.getMessageProperties().setHeader("x-delay", "6000");
                        message.getMessageProperties().setDelay(delayTime);
                        return message;
                    }
                }, correlationData);
    }
}
  1. 创建一个消费者
/**
 * @author LiJing
 * @ClassName: MQReceiver
 * @Description: 消费者
 * @date 2019/10/9 11:51
 */
@Component
@Slf4j
public class MQReceiver {
    @RabbitListener(queues = MQConfig.DELAY_QUEUE)
    @RabbitHandler
    public void onDelayMessage(Message msg, Channel channel) throws IOException {
        long deliveryTag = msg.getMessageProperties().getDeliveryTag();
        channel.basicAck(deliveryTag, true);
        System.out.println("延迟队列在" + LocalDateTime.now()+"时间," + "延迟后收到消息:" + new String(msg.getBody()));
    }
}

5.创建一个mq的测试控制器

@RestController
@RequestMapping("/mq")
public class MqController extends AbstractController {

    @Autowired
    private MQSender mqSender;

    @GetMapping(value = "/send/delay")
    public void sendDelay(int delayTime) {
        String msg = "hello delay";
        System.out.println("发送开始时间:" + LocalDateTime.now() + "测试发送delay消息====>" + msg);
        mqSender.sendDelay(msg, delayTime);
    }
}
  1. 启动,测试一把
 http://localhost:8080/api/mq/send/delay?delayTime=6000
 http://localhost:8080/api/mq/send/delay?delayTime=10000

果然,名不虚传..... 意思就是:你已经成功引起了我的注意...小小的演练,大家有收获就点个爱心

四、小结来了

延时队列在需要延时处理的场景下非常有用,使用RabbitMQ来实现延时队列,可以很好的利用RabbitMQ的特性,如:消息可靠发送、消息可靠投递、死信队列来保障消息至少被消费一次以及未被正确处理的消息不会被丢弃。

另外,通过RabbitMQ集群的特性,可以很好的解决单点故障问题,不会因为单个节点挂掉导致延时队列不可用或者消息丢失。

当然,延时队列还有很多其它选择,比如利用Redis的zset,Quartz或者利用kafka的时间轮,这些方式各有特点,但就像炉石传说一般,这些知识就好比手里的卡牌,知道的越多,可以用的卡牌也就越多,遇到问题便能游刃有余,所以需要大量的知识储备和经验积累才能打造出更出色的卡牌组合,让自己解决问题的能力得到更好的提升。

五、结束语

肥朝告诉我说:闻道有先后,术业有专攻,达者为师。

那今日份的讲解就到此结束,具体的代码请移步我的gitHub的mybot项目888分支查阅,fork体验一把,或者评论区留言探讨,写的不好,请多多指教~~