RocketMQ开源版本延迟消息改造方案分享
1. 前言
消息队列已经逐渐成为企业IT系统内部通信的核心手段。它具有低耦合、可靠投递、广播、流量控制、最终一致性等一系列功能,成为异步RPC的主要手段之一。RocketMQ 自诞生以来,因其架构简单、业务功能丰富、具备极强可扩展性等特点被众多企业开发者以及云厂商广泛采用。历经十余年的大规模场景打磨,RocketMQ 已经成为业内共识的金融级可靠业务消息首选方案,被广泛应用于互联网、大数据、移动互联网、物联网等领域的业务场景。洞窝项目中大量使用RocketMQ来实现异步处理,应用解耦,流量削锋和消息通讯。
2. 背景
为了快速实现业务,项目最初使用的是商业版RocketMQ,随着业务发展,消息队列使用场景越来越多,消息体量急剧增长,成本日益增高,团队考虑在无侵入的条件下,将线下环境切换为开源RocketMQ以减少使用成本。
3.挑战及问题
在推进切换过程中,遇到了不兼容的问题,定位问题原因是由于商业版RocketMQ中,阿里对开源RocketMQ进行了封装,在功能上两者虽然大致相同,但是还是存在一些区别。其次,商业版提供的API也并不完全适用开源版,所以需要对开源版本进行二次开发,才能在业务中进行无感切换。
解决问题前,我们先了解下开源版本延迟消息的实现原理
3.1 开源RocketMQ延迟消息逻辑
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 修改方案
修改方案一
优点:
- 降低了源码的耦合
- 延迟消息存储压力从MQ本身转移到代理服务
缺点:
- 消息传递过程中增加了一层网络传输
- 增加了代理服务,维护成本较高
修改方案二
在写入CommitLog之前,如果是延迟消息,判断是否十分钟内到期,十分钟内的写入时间轮,十分钟外的按照每10分钟写入delayfile文件,同时定时任务在broker服务启动时开启,每秒钟执行,对于快到时间执行的(10分钟内),直接写入时间轮,时间轮每秒钟执行,如果时间到了,就执行队列中的任务,写入commitlog文件中,commitlog会自动写入 comsumqueue中,然后客户端就能消费到了。
优点:
- 使用时间轮,可以对文件进行预热处理,响应及时
- 不需要引入新的服务
缺点:
- 由于引用时间轮,如果某段时间内消息量增大,会导致时间轮的大小增加,直到到期后才会释放,对内存空间造成压力。
修改方案三
生产者在将消息发送到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是一个复杂的过程,需要团队具备深入的技术理解和丰富的经验。但是,通过这样的切换,团队可以减少测试成本,并且能够更好地适应业务的发展需求。