RocketMQ开源版本延迟消息改造方案分享

RocketMQ开源版本延迟消息改造方案分享

1. 前言

消息队列已经逐渐成为企业IT系统内部通信的核心手段。它具有低耦合、可靠投递、广播、流量控制、最终一致性等一系列功能,成为异步RPC的主要手段之一。RocketMQ 自诞生以来,因其架构简单、业务功能丰富、具备极强可扩展性等特点被众多企业开发者以及云厂商广泛采用。历经十余年的大规模场景打磨,RocketMQ 已经成为业内共识的金融级可靠业务消息首选方案,被广泛应用于互联网、大数据、移动互联网、物联网等领域的业务场景。洞窝项目中大量使用RocketMQ来实现异步处理,应用解耦,流量削锋和消息通讯。

2. 背景

为了快速实现业务,项目最初使用的是商业版RocketMQ,随着业务发展,消息队列使用场景越来越多,消息体量急剧增长,成本日益增高,团队考虑在无侵入的条件下,将线下环境切换为开源RocketMQ以减少使用成本。

3.挑战及问题

在推进切换过程中,遇到了不兼容的问题,定位问题原因是由于商业版RocketMQ中,阿里对开源RocketMQ进行了封装,在功能上两者虽然大致相同,但是还是存在一些区别。其次,商业版提供的API也并不完全适用开源版,所以需要对开源版本进行二次开发,才能在业务中进行无感切换。

解决问题前,我们先了解下开源版本延迟消息的实现原理

3.1 开源RocketMQ延迟消息逻辑

1692688087420.png

1.Broker处理延迟消息逻辑,首先判断是非否事务消息,延迟消息属于非实物消息,将消息传递给CommitLog。

public class SendMessageProcessor extends AbstractSendMessageProcessor implements NettyRequestProcessor{
​
    /**无关逻辑已省略**/
​
    private CompletableFuture<RemotingCommand> asyncSendMessage(ChannelHandlerContext ctx, RemotingCommand request, SendMessageContext mqtraceContext, SendMessageRequestHeader requestHeader) {
        /**无关逻辑已省略**/
        CompletableFuture<PutMessageResult> putMessageResult = null;
        String transFlag = origProps.get(MessageConst.PROPERTY_TRANSACTION_PREPARED);
        //判断是否为事务消息
        if (transFlag != null && Boolean.parseBoolean(transFlag)) {
            if (this.brokerController.getBrokerConfig().isRejectTransactionMessage()) {
                response.setCode(ResponseCode.NO_PERMISSION);
                response.setRemark(
                        "the broker[" + this.brokerController.getBrokerConfig().getBrokerIP1()
                                + "] sending transaction message is forbidden");
                return CompletableFuture.completedFuture(response);
            }
            putMessageResult = this.brokerController.getTransactionalMessageService().asyncPrepareMessage(msgInner);
        } else {
        //非事务消息
            putMessageResult = this.brokerController.getMessageStore().asyncPutMessage(msgInner);
        }
        return handlePutMessageResultFuture(putMessageResult, response, request, msgInner, responseHeader, mqtraceContext, ctx, queueIdInt);
    }
}
​

2.CommitLog处理事务消息,将Topic的名称修改为SCHEDULE_TOPIC_XXXX,并设置投递队列的id为延迟等级-1,将真实topic和队列id存入消息体。

public class CommitLog {
​
    /**无关逻辑已省略**/
    public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg) {
        /**无关逻辑已省略**/
        if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE || tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) {
          
            // 判断是否为延迟消息
            if (msg.getDelayTimeLevel() > 0) {
                if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
                    msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
                }
                //修改topic为SCHEDULE_TOPIC_XXXX
                topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC;
                queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());
​
                // 将真实topic, queueId 存入Message
                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()));
​
                msg.setTopic(topic);
                msg.setQueueId(queueId);
            }
        }
 }
​
​
public class MessageConst {
    /**无关逻辑已省略**/
    public static final String PROPERTY_DELAY_TIME_LEVEL = "DELAY";
}
​
​
​
​
public class Message implements Serializable {
    /**无关逻辑已省略**/
    public int getDelayTimeLevel() {
        String t = this.getProperty(MessageConst.PROPERTY_DELAY_TIME_LEVEL);
        if (t != null) {
            return Integer.parseInt(t);
        }
​
        return 0;
    }
}
​
​

3.CommitLog将处理后的延迟消息放入对应延迟等级的延迟队列中。 4.Broker在启动后会注册一个定时任务,定时将不同延迟队列中到期的消息还原为真实消息,发送到真实topic中。

3.2 延迟消息失效问题排查

切换后对不同功能的消息进行了测试,发现延迟消息功能失效,排查后发现是由于两点原因引起:

  • 阿里云延迟消息API和开源的KEY值不同。
  • 开源版只支持固定延迟等级,商业版支持任意时间的延迟消息。
3.2.1 Key值不同

使用阿里云API业务代码如下:

private String sendDelayMessage(Producer producer, String topic, String message, String msgKey, Long delayTime, String tag) {
        Message msg = new Message(topic,tag,message.getBytes());
        msg.setKey(msgKey);
        try {
            // 设置消息需要被投递的时间。
            msg.setStartDeliverTime(System.currentTimeMillis() + delayTime);
            SendResult sendResult = producer.send(msg);
            // 同步发送消息,只要不抛异常就是成功。
            if (sendResult != null) {
                log.info("消息id,messageId:{},==消息体:{}", msgKey, message);
                return sendResult.getMessageId();
            }
            log.info("消息投递时sendResult为空:message:{},tag:{},msgKey:{},delayTime:{}", message,tag,msgKey,delayTime);
            return null;
        } catch (Exception e) {
            // 消息发送失败,需要进行重试处理,可重新发送这条消息或持久化这条数据进行补偿处理。
            log.error("延时队列消息投递失败!Topic:{},tag:{},msgKey:{},delayTime:{},message:{},msgId:{}",topic, tag, msgKey, delayTime, message, e);
            return null;
        }
    }

阿里云API源码如下

package com.aliyun.openservices.ons.api;
​
import java.io.Serializable;
import java.util.Properties;
​
public class Message implements Serializable {
    /**无关逻辑已省略**/
    Properties systemProperties;
    
    public void setStartDeliverTime(final long value) {
        putSystemProperties(com.aliyun.openservices.ons.api.Message.SystemPropKey.STARTDELIVERTIME, String.valueOf(value));
    }
    
    
    void putSystemProperties(final String key, final String value) {
        if (null == this.systemProperties) {
            this.systemProperties = new Properties();
        }
​
        if (key != null && value != null) {
            this.systemProperties.put(key, value);
        }
    }
​
    static public class SystemPropKey {
        /**无关逻辑已省略**/
        /**
         * 设置消息的定时投递时间(绝对时间). <p>例1: 延迟投递, 延迟3s投递, 设置为: System.currentTimeMillis() + 3000; <p>例2: 定时投递, 2016-02-01
         * 11:30:00投递, 设置为: new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2016-02-01 11:30:00").getTime()
         */
        public static final String STARTDELIVERTIME = "__STARTDELIVERTIME";
    }
}
​

可以看到商业版RocketMQ延迟消息设置properties的Key值为"__STARTDELIVERTIME",而开源版本延迟消息Key值为"DELAY"。

3.2.1 延迟方式不同

当前开源RocketMQ不支持任意时间的延迟。 生产者发送延迟消息前需要设置几个固定的延迟级别,1到18个延迟级分别对应“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”。如下,delayTimeLevel设置为3代表延迟10s。

 message.setDelayTimeLevel(3); 

而阿里云API是支持任意时间维度,如下代码代表消息延迟delayTime(ms)。

 message.setStartDeliverTime(System.currentTimeMillis() + delayTime);

3.3 开源RocketMQ延迟消息改造

为了能够无侵入的将线下环境切换为开源RocketMQ,我们需要将开源版本的延迟消息进行改造以兼容目前使用的阿里云API,通过阅读源码,提出了以下三种改造延迟消息方案。

3.3.1 修改方案
修改方案一

1692688076947.png
优点:

  • 降低了源码的耦合
  • 延迟消息存储压力从MQ本身转移到代理服务

缺点:

  • 消息传递过程中增加了一层网络传输
  • 增加了代理服务,维护成本较高
修改方案二

1692688145409.png

在写入CommitLog之前,如果是延迟消息,判断是否十分钟内到期,十分钟内的写入时间轮,十分钟外的按照每10分钟写入delayfile文件,同时定时任务在broker服务启动时开启,每秒钟执行,对于快到时间执行的(10分钟内),直接写入时间轮,时间轮每秒钟执行,如果时间到了,就执行队列中的任务,写入commitlog文件中,commitlog会自动写入 comsumqueue中,然后客户端就能消费到了。

优点:

  • 使用时间轮,可以对文件进行预热处理,响应及时
  • 不需要引入新的服务

缺点:

  • 由于引用时间轮,如果某段时间内消息量增大,会导致时间轮的大小增加,直到到期后才会释放,对内存空间造成压力。
修改方案三

1692688604588.png
生产者在将消息发送到broker时,会进行判断是否为定时消息。如果消息被标记为定时消息,它将被发送到代理服务,由代理服务负责按照预定的时间将消息发送给消费者。

优点:

  • 设计简单
  • 每次只加载一秒内的消息,内存压力相对较小
  • 不需要引入新的服务

缺点:

  • 多次磁盘IO,消息量大时可能存在延迟
3.3.2 方案选择

由于切换线下环境RocketMQ的目的为了降低使用成本,所以我们选择方案需要考虑硬件资源、网络带宽和维护成本等因素,方案一需要引入代理服务,维护成本较高,方案二虽然可以减少延迟,但是内存使用成本较高,且开发相对复杂,方案三设计简单,且只加载一秒内的消息,硬件成本和维护成本相对方案一、方案二要低,虽然可能存在短暂延迟,但是业务中延迟消息对时效性要求并没有太高,线下环境可以接受短暂延迟,因此我们此次改造选择了方案三。

3.3.3 方案落地

首先在Broker中判断是否为任意时间延迟消息,不是则走原有逻辑,是则拦截处理。

private CompletableFuture<RemotingCommand> asyncSendMessage(ChannelHandlerContext ctx, RemotingCommand request,
                                                                SendMessageContext mqtraceContext,
                                                                SendMessageRequestHeader requestHeader) {
       /**无关逻辑已省略**/                                                   
        boolean isDelayMsg = false;
        long nextStartTime = 0;
        String startTime = msgInner.getProperty(MessageConst.STARTDELIVERTIME);
        int delayTimeLevel = msgInner.getDelayTimeLevel();
        if (!Objects.isNull(startTime) && delayTimeLevel <= 0) {
            nextStartTime = Long.parseLong(startTime);
            if (nextStartTime >= System.currentTimeMillis()) {
                isDelayMsg = true;
            }
        }
        //判断是否为阿里云API延迟消息
        if (isDelayMsg) {
            //新增延迟消息处理逻辑
            return new FileUtil().handlePutMessageResultFuture(response, request, msgInner, ctx, nextStartTime, brokerController);
        } else {
            //原有逻辑
            CompletableFuture<PutMessageResult> putMessageResult = null;
            String transFlag = origProps.get(MessageConst.PROPERTY_TRANSACTION_PREPARED);
            if (Boolean.parseBoolean(transFlag)) {
                if (this.brokerController.getBrokerConfig().isRejectTransactionMessage()) {
                    response.setCode(ResponseCode.NO_PERMISSION);
                    response.setRemark(
                            "the broker[" + this.brokerController.getBrokerConfig().getBrokerIP1()
                                    + "] sending transaction message is forbidden");
                    return CompletableFuture.completedFuture(response);
                }
                putMessageResult = this.brokerController.getTransactionalMessageService().asyncPrepareMessage(msgInner);
            } else {
                putMessageResult = this.brokerController.getMessageStore().asyncPutMessage(msgInner);
            }
            return handlePutMessageResultFuture(putMessageResult, response, request, msgInner, responseHeader, mqtraceContext, ctx, queueIdInt);
        }
    }

拦截后将延迟消息存入文件并返回给客户端响应。

public class FileUtil {
​
    protected static final InternalLogger log = InternalLoggerFactory.getLogger(FileUtil.class.getCanonicalName());
    
    public CompletableFuture<RemotingCommand> handlePutMessageResultFuture(RemotingCommand response, RemotingCommand request, MessageExtBrokerInner msgInner, ChannelHandlerContext ctx, long nextStartTime, BrokerController brokerController) {
        return CompletableFuture.completedFuture(this.handlePutMessageResult(response, request, msgInner, ctx, nextStartTime, brokerController));
    }
​
    private RemotingCommand handlePutMessageResult(RemotingCommand response, RemotingCommand request, MessageExtBrokerInner msg, ChannelHandlerContext ctx, long nextStartTime, BrokerController brokerController) {
        //将消息存入对应文件、文件夹
        boolean svOk = saveMsgFile(nextStartTime, msg, brokerController);
        SendMessageResponseHeader sendMessageResponseHeader = new SendMessageResponseHeader();
        sendMessageResponseHeader.setQueueId(1);
        sendMessageResponseHeader.setMsgId("0");
        sendMessageResponseHeader.setQueueOffset(0L);
        sendMessageResponseHeader.setTransactionId("");
        RemotingCommand newCommand = RemotingCommand.createRequestCommand(ResponseCode.SUCCESS, sendMessageResponseHeader);
​
        if (svOk) {
            newCommand.setCode(ResponseCode.SUCCESS);
        } else {
            newCommand.setCode(ResponseCode.SYSTEM_ERROR);
            newCommand.setRemark("send delay message error!");
        }
        newCommand.setExtFields(request.getExtFields());
        newCommand.setVersion(response.getVersion());
        newCommand.setOpaque(response.getOpaque());
        newCommand.setLanguage(response.getLanguage());
        newCommand.setBody(request.getBody());
​
        if (!request.isOnewayRPC()) {
            try {
                ctx.writeAndFlush(newCommand);
            } catch (Throwable e) {
                log.error("DelayProcessor process request over, but response failed", e);
                log.error(request.toString());
                log.error(response.toString());
            }
        }
        return newCommand;
    }

Broker启动时会调用initialize()方法来启动服务中的定时任务,扫描线程可以在这个方法中添加,Broker启动时会启动扫描线程,持续对延迟文件进行扫描,过期消息投递到对应topic队列。

public boolean initialize() throws CloneNotSupportedException {
        /**无关逻辑已省略**/
        boolean result = this.topicConfigManager.load();
        result = result && this.consumerOffsetManager.load();
        result = result && this.subscriptionGroupManager.load();
        result = result && this.consumerFilterManager.load();
        if (result) {
            DelayProcessor delayProcessor = new DelayProcessor(this);
            ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
            executor.schedule(() -> {
                /**扫描线程**/
                delayProcessor.run();
            }, 0, TimeUnit.SECONDS);
            executor.shutdown();
            /**无关逻辑已省略**/
        }
        return result;
 }

线程扫描逻辑如下,首先获取延迟根目录下的所有非空文件夹,按照文件名(到期时间)从小到大排序,判断当前时间是否大于文件夹所属过期时间,将过期文件夹内的文件全部加载入内存,转换外消息对象重新投递到相应队列,投递成功后删除文件,文件全部正确处理后删除文件夹。

public class DelayProcessor implements Runnable {

    protected static final InternalLogger log = InternalLoggerFactory.getLogger(DelayProcessor.class.getCanonicalName());

    protected final BrokerController brokerController;
    protected final SocketAddress storeHost;

    private ExecutorService jobTaskExecute = Executors.newFixedThreadPool(16);


    public DelayProcessor(final BrokerController brokerController) {
        this.brokerController = brokerController;
        this.storeHost = new InetSocketAddress(brokerController.getBrokerConfig().getBrokerIP1(), brokerController
                .getNettyServerConfig().getListenPort());
        File file = new File(FileUtil.getDelayPath());
        if (!file.exists()) {
            file.mkdirs();
        }
        sendMsg();
    }


    @Override
    public void run() {
        for (; ; ) {
            sendMsg();
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                log.error("run e:", e);
            }
        }
    }
    private void sendMsg() {
        File lst = new File(FileUtil.getDelayPath());
        File[] files = lst.listFiles();
        if(!Objects.isNull(files)) {
            Arrays.stream(files).filter(f -> f.isDirectory() && !f.getName()
                    .equals(".") && !f.getName().equals(".."))
                    .filter(Objects::nonNull)
                    .sorted(Comparator.comparing(f -> Long.parseLong(f.getName())))
                    .forEach(f -> putMsg(f));
        }
    }
    private void putMsg(File file) {
        long fileTime = Long.parseLong(file.getName());
        if (fileTime <= System.currentTimeMillis() / 1000) {
            File[] files = file.listFiles();
            if (!Objects.isNull(files)) {
                AtomicReference<Boolean> allSend = new AtomicReference<>(true);
                Arrays.stream(files).forEach(f -> {
                            MessageExtBrokerInner msgInner = FileUtil.readFile(f);
                            if (msgInner != null) {
                                PutMessageResult result = putMessageToQueen(msgInner);
                                if (result != null && result.getPutMessageStatus() == PutMessageStatus.PUT_OK) {
                                    f.delete();
                                } else {
                                    allSend.set(false);
                                }
                            }
                        });
                if (allSend.get()) {
                    file.delete();
                }
            }
        }
    }


    public PutMessageResult putMessageToQueen(MessageExtBrokerInner msgInner) {
        return this.brokerController.getMessageStore().putMessage(msgInner);
    }
}

4.总结

在将商业版RocketMQ切换为开源RocketMQ的过程中,团队遇到了不兼容的问题。这是因为商业版RocketMQ对开源RocketMQ进行了封装,导致两者在功能上存在一些区别。此外,商业版提供的API也无法完全适用于开源版,因此需要进行二次开发才能实现无感切换。 为了解决这些问题,团队需要深入研究开源RocketMQ的源码,了解其架构和实现原理。然后,根据业务需求,对开源版本进行二次开发,以确保功能的兼容性和稳定性。 在进行切换之前,团队还需要进行充分的测试和验证,确保切换过程不会对现有系统造成任何影响。同时,团队还需要制定详细的切换计划和风险控制措施,以应对可能出现的问题。 总的来说,将商业版RocketMQ切换为开源RocketMQ是一个复杂的过程,需要团队具备深入的技术理解和丰富的经验。但是,通过这样的切换,团队可以减少测试成本,并且能够更好地适应业务的发展需求。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 我可以用Java来写一个RocketMQ延迟消息的例子。首先,您需要配置RocketMQ的环境,然后创建一个Producer实例,并且设置消息的属性,将消息发送到指定的topic。在发送消息时,可以设置消息延迟时间,使用sendDelayMsg()方法,消费者将在指定的时间后收到消息。 ### 回答2: RocketMQ是一个开源的分布式消息队列系统,在实现延迟消息时可以使用RocketMQ提供的特性——延迟消息延迟消息是指在发送消息时可以指定该消息延迟时间,消息会在指定延迟时间之后才会被消费者消费。 要写一个RocketMQ延迟消息,首先需要搭建一个RocketMQ的环境,包括安装RocketMQ服务器和创建Producer和Consumer。然后,按照以下步骤实现延迟消息: 1. 创建Producer:在程序中创建一个RocketMQ的Producer对象,设置NameServer的地址,并启动Producer。 2. 创建延迟消息:使用Producer创建一个延迟消息,包括指定Topic和Tags,并在消息中设置延迟时间,单位为毫秒。 3. 发送消息:使用Producer发送延迟消息。 4. 创建Consumer:在程序中创建一个RocketMQ的Consumer对象,设置NameServer的地址和要消费的Topic,并启动Consumer。 5. 消费消息:Consumer会从RocketMQ服务器中拉取消息,当延迟时间到达时,消费者会收到消息并进行消费。 需要注意的是,RocketMQ延迟消息有一定的误差,实际的延迟时间可能比设置的延迟时间要长。这是因为消息发送和消费都需要一定的时间,以及网络延迟等因素的影响。 总结起来,实现RocketMQ延迟消息需要搭建RocketMQ环境,创建Producer和Consumer,并在消息中设置延迟时间。通过这些步骤,即可实现RocketMQ延迟消息的功能。 ### 回答3: RocketMQ是一个分布式消息队列系统,可以实现可靠的消息传递和事务消息等。要写一个RocketMQ延迟消息,可以按照以下步骤进行: 1. 首先,确保已经正确设置了RocketMQ的环境和配置,并创建了所需的生产者和消费者。 2. 创建一个Topic(主题)和一个Tag(标签),以便将延迟消息发送到正确的目标。 3. 在生产者端创建消息,并设置延迟时间。可以使用`ScheduleMessage`类的`setDelayTimeLevel`方法来设置延迟级别,取值范围为1-18,代表延迟时间分别为1s、5s、1m、2m等。 4. 将设置好延迟时间的消息发送到指定的Topic和Tag。 5. 在消费者端,通过订阅指定的Topic和Tag,接收延迟消息。 6. 消费者接收到延迟消息后,根据业务需求进行相应的处理。 需要注意的是,RocketMQ延迟消息依赖于Broker的定时任务,默认会每隔1秒扫描所有的延迟消息发送。 综上所述,以上是一个简单的描述如何写一个RocketMQ延迟消息的步骤。具体实现时,需要按照RocketMQ的API文档和具体业务需求进行操作。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值