浅析基于消息中间件延迟消息的几种实现方式

1,448 阅读5分钟

1. RabbitMQ

TTL+死信队列

TTL是RabbitMQ中一个消息或者队列的属性,表明一条消息或者该队列中的所有消息的最大存活时间,单位是毫秒。

进入死信队列具备的条件:

  1. 消息被否定确认
  2. 消息的存活时间超过设置的最大TTL时间
  3. 消息队列的长度已经超过最大长度

实现思想:创建一个带过期属性的队列,不创建消费者,借助消息过期时间,当一条消息过期后成为死信,这条消息会投递给死信交换机,死信交换机则将消息发给死信队列,死信队列为普通队列,可以被消费者监听和消费。

  • 创建队列时指定x-message-ttl,此时整个队列具有统一过期时间
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 6000);
//设置延迟队列绑定的死信交换机 
//args.put("x-dead-letter-exchange", "delay-exchange"); 
//设置延迟队列绑定的死信路由键 
//args.put("x-dead-letter-routing-key", "delay-route");
channel.queueDeclare(queueName, durable, exclusive, autoDelete, args);

① 队列中这个属性的设置要在第一次声明队列的时候设置才有效,如果队列一开始已存在且没有这个属性,则要删掉队列再重新声明才可以。

② 队列的 ttl 只能被设置为某个固定的值,一旦设置后则不能更改,否则会抛出异常。

缺点:队列中的消息过期时间一致,由于不同的业务场景对过期时间的要求可能不同,会创建比较多的队列

  • 发送消息为每个消息设置expiration,此时消息之间过期时间不同
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());

缺点:消息过期不一定马上丢弃,因为rabbitmq只会对队头的消息进行扫描,只有当队列头部的消息消费后,才能对后续进行消费,如果当期队列有严重的消息积压情况,已过期的消息可能存活较长时间

如果同时配置了队列的TTL和消息的TTL,那么较小的那个值将会被使用

延迟插件

rabbitmq-delayed-message-exchange插件官方下载地址:github.com/rabbitmq/ra…, 下载*.ez的文件

找到RabbitMQ的安装路径,将下载的插件放到plugins目录中。比如:

启用插件 使用rabbitmq-plugins enable rabbitmq_delayed_message_exchange命令启用插件

@Configuration
public class RabbitMqConfig {
    /**
     * 延迟交换器
     */
    public final static String DELAY_EXCHANGE_NAME = "exchange_delay";
    /**
     * 订单队列
     */
    public final static String ORDER_QUEUE_NAME = "order_delay";

    /**
     * 订单队列路由key
     */
    public static final String ROUTING_KEY_DELAY = "routing.order.delay";


    @Bean("delayOrderQueue")
    public Queue delayOrderQueue() {
        return new Queue(ORDER_QUEUE_NAME, true, false, false);
    }

    @Bean("delayExchange")
    public DirectExchange delayExchange(){
        DirectExchange directExchange = new DirectExchange(DELAY_EXCHANGE_NAME, true, false);
        directExchange.setDelayed(true);
        return directExchange;
    }
    //@Bean 
    //public CustomExchange delayExchange() { 
        //Map<String, Object> args = new HashMap<>(); args.put("x-delayed-type", "direct");
        //return new CustomExchange("delayedExchange", "x-delayed-message", true, false, args); 
    //}
    @Bean
    Binding bindingExchangeOrderMessage() {
        return BindingBuilder.bind(delayOrderQueue()).to(delayExchange()).with(ROUTING_KEY_DELAY);
    }

}

生产者

//订单超时
rabbitTemplate.convertAndSend("exchange_delay","routing.order.delay",result,message->{
    message.getMessageProperties().setDelay(5000);
    return message;
});

消费者

@RabbitListener(queues = "order_delay")
public void receiveMessage(Message message, Channel channel) throws Exception {
    JSONObject result= JSON.parseObject(new String( message.getBody()));
    log.info(" 收到订单超时消息: " + result);
  //开启confirm模式,ack应答
  channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}

延迟插件的问题作者有解释,请看链接: Delay interval predictability · Issue #72 · rabbitmq/rabbitmq-delayed-message-exchange · GitHub

作者的回复大概意思是这种设计不适合百万级别延迟消息,该插件依赖Erlang计数器,存活一段时间后会抢夺调度器资源,会随着时间累加而累加。插件的优化不是他们的优先任务。

2. RocketMQ

RocketMQ支持基于18个等级的延迟方案

private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";

分别对应 1~18个等级,0表示不延迟,超过最大等级按最大等级18延迟2h。不支持自定义延迟时间。

 Message<String> message = MessageBuilder.withPayload("hello").build();
 SendResult result = rocketMQTemplate.syncSend("sync-tags", message, 15000, 3);

实现流程:

①生产者投递消息给broker的commitLog服务。

②commitLog服务对于接收的消息判断是普通消息还是延时消息(延迟级别大于0),如果是延时消息,将实际的topic和queueId保存到message的属性中,重新设置topic为SCHEDULE_TOPIC_XXXX,根据延迟级别确定投递到那个队列下。

if (msg.getDelayTimeLevel() > 0) {
    // 如果延迟级别超过最大级别,就设置延迟级别为18
    if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
        msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
    }

    // 设置延迟消息的topic和queue
    topic = ScheduleMessageService.SCHEDULE_TOPIC;
    queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

    // Backup real topic, queueId
    // 将真正的topic和queueId存起来,存到property属性中
    MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
    MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
    msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));

    // 将消息的topic和queue替换为延迟队列的
    msg.setTopic(topic);
    msg.setQueueId(queueId);
}

③消息延迟服务(ScheduleMessageService)启动后会创建定时器扫描个自己对应延迟等级的队列,消息到期的消息,会根据存入消息的原始topic和queueId重新设置,存储到commitLog,最后投递到原来的队列中由消费者消费

/**
 * 在启动broker的时候,会初始化这里的timer
 * 并且会根据延迟级别,创建对应的timer任务
 */
public void start() {
    if (started.compareAndSet(false, true)) {
        this.timer = new Timer("ScheduleMessageTimerThread", true);
        // 这里for循环,会为每一个延迟级别创建一个延迟任务
        for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
            /**
             * level是设置的延迟级别
             */
            Integer level = entry.getKey();
            /**
             * value是延迟级别所对应的延迟时间
             * 以及对应的offset 偏移量?
             */
            Long timeDelay = entry.getValue();
            Long offset = this.offsetTable.get(level);
            if (null == offset) {
                offset = 0L;
            }

            // 初始化时,第一次延迟时间是1S,在后面任务执行之后(DeliverDelayedMessageTimerTask),会修改任务延迟时间
            if (timeDelay != null) {
                this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);
            }
        }
        // 这里是每隔10s,就把延迟队列的最大消息偏移量写入到磁盘中
        this.timer.scheduleAtFixedRate(new TimerTask() {
            @Override
            public void run() {
                try {
                    if (started.get()) ScheduleMessageService.this.persist();
                } catch (Throwable e) {
                    log.error("scheduleAtFixedRate flush exception", e);
                }
            }
        }, 10000, this.defaultMessageStore.getMessageStoreConfig().getFlushDelayOffsetInterval());
    }
}

每个等级开启一个定时器,用于将队列的延迟消息重新投递到原始的topic和队列

if (timeDelay != null) { this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);}

先介绍到这里,具体逻辑就暂不放,我真是太懒了,copy都懒得copy