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