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源码

相关推荐
源码哥_博纳软云1 分钟前
JAVA同城服务场馆门店预约系统支持H5小程序APP源码
java·开发语言·微信小程序·小程序·微信公众平台
禾高网络3 分钟前
租赁小程序成品|租赁系统搭建核心功能
java·人工智能·小程序
学会沉淀。9 分钟前
Docker学习
java·开发语言·学习
如若12310 分钟前
对文件内的文件名生成目录,方便查阅
java·前端·python
初晴~40 分钟前
【Redis分布式锁】高并发场景下秒杀业务的实现思路(集群模式)
java·数据库·redis·分布式·后端·spring·
盖世英雄酱581361 小时前
InnoDB 的页分裂和页合并
数据库·后端
小_太_阳1 小时前
Scala_【2】变量和数据类型
开发语言·后端·scala·intellij-idea
直裾1 小时前
scala借阅图书保存记录(三)
开发语言·后端·scala
黑胡子大叔的小屋1 小时前
基于springboot的海洋知识服务平台的设计与实现
java·spring boot·毕业设计
ThisIsClark1 小时前
【后端面试总结】深入解析进程和线程的区别
java·jvm·面试