RocketMQ之顺序消息

RocketMQ之顺序消息

1. 概述

项目中采用局部顺序消息,具体为:发送端对同一个用户的消息,异步发送到同一个topic的同一个队列,消费端则通过实现接口MessageListenerOrderly来实现顺序消费。实际上要保证消息的顺序消费有三个关键点:

1)消息顺序发送

2)消息顺序存储

3)消息顺序消费

但是异步发送就不满足第一点,但是业务上正常,这就有点反常,因此需要深入分析一下顺序消息机制。

以下代码没有特殊说明的情况下,均为4.5.1分支。

2. 消息顺序发送

多线程发送的消息无法保证有序性,另外异步发送消息也无法保证顺序。下面为RocketMQ异步发送消息的代码。从源码中可以看出如果是异步发送的话,内部会采用线程线程池,来执行消息的异步发送。而跟踪消息发送内部代码都没有加锁的地方,所以如果是异步发送的话,会出现并行发送的情况,也就不能保证发送的顺序了。

java 复制代码
public void send(final Message msg, final MessageQueueSelector selector, final Object arg, final SendCallback sendCallback, final long timeout)
    throws MQClientException, RemotingException, InterruptedException {
    final long beginStartTime = System.currentTimeMillis();
    ExecutorService executor = this.getAsyncSenderExecutor();
    try {
        executor.submit(new Runnable() {
            @Override
            public void run() {
                long costTime = System.currentTimeMillis() - beginStartTime;
                if (timeout > costTime) {
                    try {
                        try {
                            sendSelectImpl(msg, selector, arg, CommunicationMode.ASYNC, sendCallback,
                                timeout - costTime);
                        } catch (MQBrokerException e) {
                            throw new MQClientException("unknownn exception", e);
                        }
                    } catch (Exception e) {
                        sendCallback.onException(e);
                    }
                } else {
                    sendCallback.onException(new RemotingTooMuchRequestException("call timeout"));
                }
            }

        });
    } catch (RejectedExecutionException e) {
        throw new MQClientException("exector rejected ", e);
    }
}

那么按此时的结论,业务肯定会有问题了。在业务上,一个用户对于一个Channel(Netty的Channel)会绑定一个核心线程(即使有业务线程池,核心线程和业务线程直接也是一一对应关系),也就是说不会存在多个线程同时发送同一个用户的消息。那么再看下RocketMQ发送的源码,用的RocketMQ版本是4.2.0

从以下代码中,发现发送数据并不是放到线程池中处理,而是发送的线程会一直执行,直到Channel.writeAndFlush写到Channel的缓冲区中才返回。所以发送端没有出现问题,是因为之前发送都是一条线程发送一个用户的数据到Channel的缓冲区中。但是如果把RocketMQ-Client升级为4.5.1版本,那么异步发送消息就会有问题了。

java 复制代码
public SendResult send(Message msg, MessageQueueSelector selector, Object arg) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
    return this.send(msg, selector, arg, (long)this.defaultMQProducer.getSendMsgTimeout());
}

public SendResult send(Message msg, MessageQueueSelector selector, Object arg, long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
    return this.sendSelectImpl(msg, selector, arg, CommunicationMode.SYNC, (SendCallback)null, timeout);
}

那么该如何解决?那就是采用同步发送。同步发送会再把数据发送到Channel之后,进行await,具体代码如下:

java 复制代码
//NettyRemotingAbstract#invokeSyncImpl
channel.writeAndFlush(request).addListener(...);
RemotingCommand responseCommand = responseFuture.waitResponse(timeoutMillis);
java 复制代码
//ResponseFuture#waitResponse
public RemotingCommand waitResponse(final long timeoutMillis) throws InterruptedException {
    this.countDownLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
    return this.responseCommand;
}

当有发送的结果返回时,或者发送数据到Channel失败的时候,会调用ResponseFuture的putResponse(超时时对象为null),然后会调用CountDownLatch的countDown方法,这个时候,之前同步发送消息的线程等待结束,返回结果。进行下一条消息的发送,以此来达到发送端顺序发送。

java 复制代码
//ResponseFuture#putResponse
public void putResponse(final RemotingCommand responseCommand) {
    this.responseCommand = responseCommand;
    this.countDownLatch.countDown();
}

3. 消息顺序存储

MQ的topic会存在多个queue,要保证消息的顺序存储,同一个业务标记的消息需要发送到同一个queue中。但如果接收端收到多个同一个queue的消息,内部是并行执行消息写入,那么也可能存在存储顺序错乱问题。具体可以看下RocketMQ的源码。

消息发送到Broker会由SendMessageProcessor进行处理。

java 复制代码
//BrokerController#registerProcessor
public void registerProcessor() {
    /**
     * SendMessageProcessor
     */
    SendMessageProcessor sendProcessor = new SendMessageProcessor(this);
    sendProcessor.registerSendMessageHook(sendMessageHookList);
    sendProcessor.registerConsumeMessageHook(consumeMessageHookList);

    this.remotingServer.registerProcessor(RequestCode.SEND_MESSAGE, sendProcessor, this.sendMessageExecutor);
    this.remotingServer.registerProcessor(RequestCode.SEND_MESSAGE_V2, sendProcessor, this.sendMessageExecutor);
    //...
}

在Broker启动的时候,会对一些Processor进行注册,其中就包括了SendMessageProcessor,并且会指定一个线程池。

java 复制代码
//BrokerController#initialize
this.sendMessageExecutor = new BrokerFixedThreadPoolExecutor(
    this.brokerConfig.getSendMessageThreadPoolNums(),
    this.brokerConfig.getSendMessageThreadPoolNums(),
    1000 * 60,
    TimeUnit.MILLISECONDS,
    this.sendThreadPoolQueue,
    new ThreadFactoryImpl("SendMessageThread_"));

线程池会在Broker初始化的时候,进行初始化。默认sendMessageThreadPoolNums的大小为1,可以配置。

arduino 复制代码
 //BrokerConfig
 /**
     * thread numbers for send message thread pool, since spin lock will be used by default since 4.0.x, the default
     * value is 1.
     */
private int sendMessageThreadPoolNums = 1;

下面看下Broker接收消息如何处理,具体看一下源码

java 复制代码
//NettyRemotingAbstract#processRequestCommand
public void processRequestCommand(final ChannelHandlerContext ctx, final RemotingCommand cmd) {
    final Pair<NettyRequestProcessor, ExecutorService> matched = this.processorTable.get(cmd.getCode());
    final Pair<NettyRequestProcessor, ExecutorService> pair = null == matched ? this.defaultRequestProcessor : matched;
    if (pair != null) {
        Runnable run = new Runnable() {
                @Override
                public void run() {
                   //省略一些代码
                   final RemotingCommand response = pair.getObject1().processRequest(ctx, cmd);           }
        };            
    } 
    //...省略一些代码
    final RequestTask requestTask = new RequestTask(run, ctx.channel(), cmd);
    pair.getObject2().submit(requestTask);
}    

从上面代码可以看出,会根据命令的Code找到Processor和对应的线程池进行处理。这里的初始化在Broker启动的时候已经做了。所以如果是发送消息,这里取出来的Processor就是SendMessageProcessor,ExecutorService就是sendMessageExecutor。根据上面的代码,那就是将受到的数据进行封装成Task然后丢到sendMessageExecutor线程池,处理SendMessageProcessor.processRequest()的逻辑。

继续跟踪代码逻辑,发现最后会到CommitLog

java 复制代码
//CommitLog#putMessage
public PutMessageResult putMessage(final MessageExtBrokerInner msg) {
   putMessageLock.lock(); //spin or ReentrantLock ,depending on store config
   try{
       result = mappedFile.appendMessage(msg, this.appendMessageCallback);
   }finally{
      putMessageLock.unlock();
   }

}

以上内容就是最终消息写入到CommitLog中,并且会采用加锁操作。起始真正会耗时的地方也就是在上面加锁的地方,前面的逻辑处理虽然看上去很多,但是实际上并不会耗时。这里需要注意一下加锁的使用的锁。

java 复制代码
//CommitLog
this.putMessageLock = defaultMessageStore.getMessageStoreConfig().isUseReentrantLockWhenPutMessage() ? new PutMessageReentrantLock() : new PutMessageSpinLock();
java 复制代码
//MessageStoreConfig
/**
     * introduced since 4.0.x. Determine whether to use mutex reentrantLock when putting message.<br/>
     * By default it is set to false indicating using spin lock when putting message.
     */
private boolean useReentrantLockWhenPutMessage = false;

锁会根据配置来决定,默认是使用自旋的方式,可以根据配置来选择是否是重入锁,重入锁就是JDK的ReentrantLock。

java 复制代码
//PutMessageSpinLock#lock
public void lock() {
    boolean flag;
    do {
        flag = this.putMessageSpinLock.compareAndSet(true, false);
    }
    while (!flag);
}

上面代码是自旋锁的实现。

综合上面的逻辑,可以发现,RocketMQ默认处理消息的方式都是单线程的方式,如果是默认配置下,把消息写到commitLog中也是顺序的。如果修改了默认配置,使用多线程进行处理的话,那么就有可能会导致写到commitLog的消息是乱序的。如果是多线程模式,还需要注意一下锁的选择,如果采用自旋锁的话,可能会浪费CPU资源。

4. 消息顺序消费

要保证消息顺序消费,就需要保证同一个queue就只能被一个消费者消费,一般来说消费者和队列分配关系是固定的。且顺序消费的消费者内部只能由一个消费线程来消费该队列。下面通过源码来验证这两点:

1)如何保证一个队列只被一个消费者消费

2)如何保证一个消费者中只有一个线程能进行消费。

4.1 锁定MessageQueue

消费队列存在于broker端,如果要保证一个队列被一个消费者消费,那么消费者在进行消息拉取消费时,就必须向broker申请加锁,消费者申请队列锁的代码在RebalanceService消息队列负载中。在集群模式下,同一个消费组共同承担某个Topic下所有消费者队列的消费,所以每个消费者都会定时重新负载并分配对应的消费队列,具体实现再RebalanceImpl的doRebalance()中。

Consumer加锁实现

rebalace消费队列后,会对新分配的消息队列进行加锁。具体代码如下

java 复制代码
//RebalanceImpl#updateProcessQueueTableInRebalance
//..、省略部分代码
List<PullRequest> pullRequestList = new ArrayList<PullRequest>();
for (MessageQueue mq : mqSet) {
    if (!this.processQueueTable.containsKey(mq)) {
        if (isOrder && !this.lock(mq)) {
            log.warn("doRebalance, {}, add a new mq failed, {}, because lock failed", consumerGroup, mq);
            continue;
        }
        //pullRequestList添加数据相关操作
    }
}
this.dispatchPullRequest(pullRequestList);

如果是新分配到的队列的话,如果是顺序消息,那么就需要先加锁,加锁成功后才能创建pullRequest进行消息的拉取。

java 复制代码
//RebalanceImpl#lock
public boolean lock(final MessageQueue mq) {
    FindBrokerResult findBrokerResult = this.mQClientFactory.findBrokerAddressInSubscribe(mq.getBrokerName(), MixAll.MASTER_ID, true);
    if (findBrokerResult != null) {
        LockBatchRequestBody requestBody = new LockBatchRequestBody();
        requestBody.setConsumerGroup(this.consumerGroup);
        requestBody.setClientId(this.mQClientFactory.getClientId());
        requestBody.getMqSet().add(mq);

        try {
            Set<MessageQueue> lockedMq =
                this.mQClientFactory.getMQClientAPIImpl().lockBatchMQ(findBrokerResult.getBrokerAddr(), requestBody, 1000);
            for (MessageQueue mmqq : lockedMq) {
                ProcessQueue processQueue = this.processQueueTable.get(mmqq);
                if (processQueue != null) {
                    processQueue.setLocked(true);
                    processQueue.setLastLockTimestamp(System.currentTimeMillis());
                }
            }

            boolean lockOK = lockedMq.contains(mq);
            log.info("the message queue lock {}, {} {}",
                lockOK ? "OK" : "Failed",
                this.consumerGroup,
                mq);
            return lockOK;
        } catch (Exception e) {
            log.error("lockBatchMQ exception, " + mq, e);
        }
    }

    return false;
}

lock内部实现逻辑是,构造数据向broker发送一个加锁请求,请求会返回加锁成功的队列。如果该队列在加锁成功的数组里面,则表示加锁成功。如果加锁成功,则会把ProcessQueue也进行加锁,这个会在消费的还是进行判断是否有加锁。

broker端实现

根据code 为LOCK_BATCH_MQ=14,找到broker端的实现RebalanceLockManager的tryLockBatch方法。

java 复制代码
//RebalanceLockManager
private final static long REBALANCE_LOCK_MAX_LIVE_TIME = Long.parseLong(System.getProperty(
    "rocketmq.broker.rebalance.lockMaxLiveTime", "60000"));
private final Lock lock = new ReentrantLock();
private final ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>> mqLockTable =
    new ConcurrentHashMap<String, ConcurrentHashMap<MessageQueue, LockEntry>>(1024);

1)可以看下RebalanceLockManager的关键属性,加锁时间默认是60秒,加锁器是JDK的重入锁。用一个map来存储不同group的加锁情况。

java 复制代码
//LockEntry
private String clientId;
private volatile long lastUpdateTimestamp = System.currentTimeMillis();

2)可以看下具体一个队列所对应的LockEntry,有两个属性,一个是客户端的id,用于判断是哪个客户端加的锁,另外一个是上一次更新锁的时间,用于判断是否过期。

java 复制代码
//RebalanceLockManager#tryLockBatch
Set<MessageQueue> lockedMqs = new HashSet<MessageQueue>(mqs.size());
Set<MessageQueue> notLockedMqs = new HashSet<MessageQueue>(mqs.size());

for (MessageQueue mq : mqs) {
    if (this.isLocked(group, mq, clientId)) {
        lockedMqs.add(mq);
    } else {
        notLockedMqs.add(mq);
    }
}

3)首先会把需要加锁的队列分成两部分,一部分是已经加锁的,一部分是没有加锁的。

java 复制代码
//RebalanceLockManager#isLocked
private boolean isLocked(final String group, final MessageQueue mq, final String clientId) {
    ConcurrentHashMap<MessageQueue, LockEntry> groupValue = this.mqLockTable.get(group);
    if (groupValue != null) {
        LockEntry lockEntry = groupValue.get(mq);
        if (lockEntry != null) {
            boolean locked = lockEntry.isLocked(clientId);
            if (locked) {
                lockEntry.setLastUpdateTimestamp(System.currentTimeMillis());
            }

            return locked;
        }
    }

    return false;
}

4)判断需要加锁的队列有没有加过锁,就取mqLockTable的group中的Map中的lockEntry进行判断

java 复制代码
//RebalanceLockManager$LockEntry#isLocked
public boolean isLocked(final String clientId) {
    boolean eq = this.clientId.equals(clientId);
    return eq && !this.isExpired();
}
//RebalanceLockManager$LockEntry#isExpired
public boolean isExpired() {
            boolean expired =
                (System.currentTimeMillis() - this.lastUpdateTimestamp) > REBALANCE_LOCK_MAX_LIVE_TIME;

            return expired;
}

5)LockEntry中的判断,先判断clientId是否相等,再判断加锁是否过期。

在RebalanceLockManager#tryLockBatch方法中,会对未加锁的队列,进行加锁,具体逻辑如下:

java 复制代码
//RebalanceLockManager#tryLockBatch
LockEntry lockEntry = groupValue.get(mq);
if (null == lockEntry) {
    lockEntry = new LockEntry();
    lockEntry.setClientId(clientId);
    groupValue.put(mq, lockEntry);
}

6)如果锁对象不存在则创建一个,后面再进行判断。

java 复制代码
//RebalanceLockManager#tryLockBatch
if (lockEntry.isLocked(clientId)) {
    lockEntry.setLastUpdateTimestamp(System.currentTimeMillis());
    lockedMqs.add(mq);
    continue;
}

7)如果已经加锁了,则更新最后加锁时间

java 复制代码
//RebalanceLockManager#tryLockBatch
if (lockEntry.isExpired()) {
    lockEntry.setClientId(clientId);
    lockEntry.setLastUpdateTimestamp(System.currentTimeMillis());
}    

8)如果旧锁已经过期了,则设置新的clientId和更新加锁时间。

总述:broker会维护group中的队列的加锁情况,让同一时刻,一个MessageQueue只能被一个消费者消费。

4.2 Consumer消费加锁实现

如果MessageQueue加锁成功,则会创建PillRequest进行消息的拉取,消息拉取代码实现再PillMessageService,消息拉取完后,需要提交到ConsumeMessageService,顺序消息的实现为ConsumeMessageOrderlyService,提交消息进行消费的方法为:ConsumeMessageOrderlyService#submitConsumeRequest。

java 复制代码
public void submitConsumeRequest(
    final List<MessageExt> msgs,
    final ProcessQueue processQueue,
    final MessageQueue messageQueue,
    final boolean dispathToConsume) {
    if (dispathToConsume) {
        ConsumeRequest consumeRequest = new ConsumeRequest(processQueue, messageQueue);
        this.consumeExecutor.submit(consumeRequest);
    }
}

创建一个消费请求任务,然后丢到线程池中进行消费。

java 复制代码
//ConsumeMessageOrderlyService构造函数
this.consumeRequestQueue = new LinkedBlockingQueue<Runnable>();
this.consumeExecutor = new ThreadPoolExecutor(
    this.defaultMQPushConsumer.getConsumeThreadMin(),
    this.defaultMQPushConsumer.getConsumeThreadMax(),
    1000 * 60,
    TimeUnit.MILLISECONDS,
    this.consumeRequestQueue,
    new ThreadFactoryImpl("ConsumeMessageThread_"));

线程池在对象构造函数中创建,注意阻塞队列是LinkedBlockingQueue,而且是没有指定容量的,也就是说,队列的容量为Integer.MAX_VALUE,相当于无界队列。线程池的maximumPoolSize不会生效。

下面看一下ConsumeRequest的run方法实现情况。

java 复制代码
//ConsumeMessageOrderlyService$ConsumeRequest#run
final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue);
synchronized (objLock) {
    //...
}

1)首先会先获取这消息队列对应的Object,用于加锁。fetchLockObject方法内部如果没有这个对象则会创建一个(内部可以保证一个MessageQueue不会生成两个Object)。然后这个objLock用于实现消费逻辑的加锁,这里可以看出,如果MessageQueue和Object的关系是一对一的话,那么消费就会是,一个队列只有一个线程进行消费。

java 复制代码
//ConsumeMessageOrderlyService$ConsumeRequest#run
//如果是集群消费(BROADCASTING是广播),则判断ProcessQueue是否加锁,且是否过期;记录一下开始执行时间;如果加锁失败的话,则会延迟100ms,重新尝试向broker申请锁定MessageQueue,锁定成功后重新提交消费请求。
if (MessageModel.BROADCASTING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())
    || (this.processQueue.isLocked() && !this.processQueue.isLockExpired())) {
    final long beginTime = System.currentTimeMillis();
    for (boolean continueConsume = true; continueConsume; ) {
        //如果ProcessQueue被废弃,则不执行消费逻辑。
        if (this.processQueue.isDropped()) {
            break;
        }
       //如果是集群消费,但是ProcessQueue没有加锁,则延迟10ms消费。
        if (MessageModel.CLUSTERING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())
            && !this.processQueue.isLocked()) {
            ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 10);
            break;
        }
       //如果是集群消费,但是ProcessQueue的锁过期,则延迟10ms消费。
        if (MessageModel.CLUSTERING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())
            && this.processQueue.isLockExpired()) {
            ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 10);
            break;
        }
       //一个线程执行的时间超过最大持续执行时间(默认60),则延迟10ms消费。
        long interval = System.currentTimeMillis() - beginTime;
        if (interval > MAX_TIME_CONSUME_CONTINUOUSLY) {
            ConsumeMessageOrderlyService.this.submitConsumeRequestLater(processQueue, messageQueue, 10);
            break;
        }

2)会在消费请进行一些判断,具体逻辑可以参考注释。

java 复制代码
final int consumeBatchSize =
    ConsumeMessageOrderlyService.this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize();

List<MessageExt> msgs = this.processQueue.takeMessags(consumeBatchSize);
//...省略部分代码
status = messageListener.consumeMessage(Collections.unmodifiableList(msgs), context);
//...省略部分代码
continueConsume = ConsumeMessageOrderlyService.this.processConsumeResult(msgs, status, context, this);

3)首先会根据Consumer的配置consumeMessageBatchMaxSize(默认为1,修改该值可以做批量消费)来获取一批消息。然后会调用messageListener的consumeMessage方法,该方法是实现具体业务逻辑的地方。处理后会返回status,然后会根据status进行消费进度的处理以及决定是否继续消费。另外需要注意的一点是,默认情况下顺序消息会不断的进行消息重试,重试的最大值是Integer.MAX_VALUE。

4.3 总结

基本上就是两个步骤:

1)创建消息拉取任务前,Consumer需要向broker申请锁定MessageQueue,使得每一个MessageQueue在同一时刻只能被同一个group的一个Consumer消费。

2)消费消费时,同一个消息队列的消费会先尝试用synchronized进行申请独占锁,加锁成功后才能进行消费,使得同一个MessageQueue同一个时刻,只能被一个Consumer中个一个线程消费。

5. 结论

1)项目上rocketMQ-client的版本使用4.2.0,如果保证发送消息的线程是同一个,那么异步发送的消息在顺序消费模式下,也是顺序的 。但是高版本就不能确定,比如:4.5.1内部会用线程池进行发送。

2)broker接收消息默认处理也能够支持顺序存储。如果修改了发送消息处理线程,那么很有可能消息不是顺存储。

3)消息消费端,如果业务中不采用接收数据放到线程池中处理业务,也能够支持消息的顺序。

综合考虑,顺序消息,建议采用同步发送。

6. 参考资料

1)RocketMQ-顺序消息Demo及实现原理分析blog.csdn.net/hosaos/arti...

2)RocketMQ 4.2.0&4.5.1源码

相关推荐
chuanauc5 分钟前
Kubernets K8s 学习
java·学习·kubernetes
一头生产的驴21 分钟前
java整合itext pdf实现自定义PDF文件格式导出
java·spring boot·pdf·itextpdf
YuTaoShao28 分钟前
【LeetCode 热题 100】73. 矩阵置零——(解法二)空间复杂度 O(1)
java·算法·leetcode·矩阵
zzywxc78731 分钟前
AI 正在深度重构软件开发的底层逻辑和全生命周期,从技术演进、流程重构和未来趋势三个维度进行系统性分析
java·大数据·开发语言·人工智能·spring
srrsheng2 小时前
RocketMQ面试题
rocketmq
YuTaoShao3 小时前
【LeetCode 热题 100】56. 合并区间——排序+遍历
java·算法·leetcode·职场和发展
程序员张33 小时前
SpringBoot计时一次请求耗时
java·spring boot·后端
llwszx6 小时前
深入理解Java锁原理(一):偏向锁的设计原理与性能优化
java·spring··偏向锁
云泽野6 小时前
【Java|集合类】list遍历的6种方式
java·python·list
二进制person7 小时前
Java SE--方法的使用
java·开发语言·算法