参考:
shanml :【RocketMQ】【源码】顺序消息实现原理
低学历程序员 : RocketMQ系列之客户端顺序消息线程模型(八)
李玥:消息队列高手课
顺序消息
一个订单产生了三条消息分别是订单创建、订单付款、订单完成。消费时要按照这个顺序消费才能有意义,但是同时订单之间是可以并行消费的。
顺序消息分为全局顺序消息与局部顺序消息,全局顺序是指某个Topic下的所有消息都要保证顺序;局部顺序消息只要保证每一组消息被顺序消费即可,同一组消息有一个共同的 shardingkey。
如果想要实现全局顺序消息,那么只能使用一个队列,以及单个生产者,这是会严重影响性能。
局部顺序消息实际上有两个核心点:
- 一个是生产者有序存储,把同一个 shardingkey 的消息投到同一个队列
- 另一个是消费者有序消费,保证同一时刻,一个队列里的消息只能被一个消费者中的一个线程消费。
生产端有序生产
生产端发送消息时根据 shardingkey 对队列数量取模,把同一个 shardingkey 的消息发送到同一个队列
RocketMq提供了3种不同的选择队列方式:
- SelectMessageQueueByHash
- SelectMessageQueueByMachineRoom
- SelectMessageQueueByRandom
它们都实现了 MessageQueueSelector 接口,可以自己实现这个接口,定义自己的队列选择方式
顺序消息可以自己实现一个 MessageQueueSelector 用 shardingkey 对队列数取模,实现把同一个 shardingkey 的消息发送到同一个队列
public ProduceResult send(MQMessage message, String shardingKey) throws MQClientApiException { try { Message msg = MessageMapper.map(message); TracerUtil.setTraceByContext(msg); MessageQueueSelector selector = new MessageQueueSelector() { public MessageQueue select(List<MessageQueue> mqs, Message msg, Object shardingKey) { int select = Math.abs(shardingKey.hashCode()); if (select < 0) { select = 0; } return (MessageQueue)mqs.get(select % mqs.size()); } }; SendResult sendResult = this.producer.send(msg, selector, shardingKey); return MessageMapper.map(sendResult); } catch (Throwable var6) { throw new MQClientApiException("failed to send the order message", var6); } }
消费端有序消费
消费端也要确保消费这个队列时是一个线程消费的
首先来看看 consumer 如何订阅顺序消息,consumer 中注册的 Listener 来指定是顺序消息消费还是并发消费
public class PushConsumer { public static void main(String[] args) throws InterruptedException, MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("C-CLOUD-UPSTREAM-YSS"); consumer.subscribe("MEDIA_MESSAGE_UPSTREAM_YSS", "*"); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET); //wrong time format 2017_0422_221800 consumer.setConsumeTimestamp("20181109221800"); consumer.setNamesrvAddr("10.10.168.3:10812"); // MessageListenerOrderly 或 MessageListenerConcurrently consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); System.out.printf("Consumer Started.%n"); } }
PushConsumer 拉取到的消息提交到线程池后,会根据注册的 Listener 类型来决定是由 ConsumeMessageConcurrentlyService 还是 ConsumeMessageOrderlyService 来处理
if (this.getMessageListenerInner() instanceof MessageListenerOrderly) { this.consumeOrderly = true; this.consumeMessageService = new ConsumeMessageOrderlyService(this, (MessageListenerOrderly) this.getMessageListenerInner()); } else if (this.getMessageListenerInner() instanceof MessageListenerConcurrently) { this.consumeOrderly = false; // 并发消费服务 this.consumeMessageService = new ConsumeMessageConcurrentlyService(this, (MessageListenerConcurrently) this.getMessageListenerInner()); } // 启动 并发消费/顺序消费 服务的定时任务线程池 this.consumeMessageService.start();
ConsumeMessageOrderlyService
通过加锁来保证 同一时刻,一个消费队列只能被一个消费者中的一个线程消费。
说明:
ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable 是当前消费者订阅的topic分配给当前消费者的所有队列
MessageQueue,ProcessQueue 存的都是当前消费者订阅的topic分配给当前消费者的队列,但是存的内容侧重点有所不同
MessageQueue: (topic,brokerName,queueId) 主要存队列与broker的关系;
ProcessQueue: 存队列在消费者端的一些状态等
1.集群模式:定时向 broker 申请对队列加 clientId 分布式锁,保证 rebalance 时一个队列也只能被一个消费者消费
org.apache.rocketmq.client.impl.consumer.ConsumeMessageOrderlyService#start
初始化了一个定时线程池,然后在ConsumeMessageOrderlyService
启动的时候,创建了一个任务,1s执行一次,lockMQPeriodically
这个方法就是给当前这个客户端所消费的所有队列去borker进行上锁。
这里的客户端是指订阅了某个 topic 并且指定了顺序消费的客户端,全部队列是指这个 topic 下的所有队列中,分配给这个消费者的所有队列。
我们知道rocketmq集群模式一般都是队列粒度的负载均衡,已经是一个队列只能被一个消费者消费的,那为什么还要在 broker 对队列加上client 的锁呢?
分配给这个消费者的所有队列在发生 rebanlance 时可能会被指派给别的消费者,而在 rebalance 时可能会有两个消费者,所以要去 broker 上锁,保证 rebalance 前后只有获取了独占锁的消费者才可以消费。
public void start() { if (MessageModel.CLUSTERING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())) { this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() { @Override public void run() { try { ConsumeMessageOrderlyService.this.lockMQPeriodically(); } catch (Throwable e) { log.error("scheduleAtFixedRate lockMQPeriodically exception", e); } } }, 1000 * 1, ProcessQueue.REBALANCE_LOCK_INTERVAL, TimeUnit.MILLISECONDS); } }
消费端上锁过程:
- 将该消费者订阅的 topic 分配给当前消费者的所有队列,按照 broker 进行分组 HashMap<String, Set<MessageQueue>> brokerMqs 内容为 brockerName-该broker下该消费者消费的所有队列
- 遍历上面根据 broker 分组过的 brokerMqs ,按照不同的 broker,对一个 broker 下该消费者被分配的所有队列,一次性向这个 broker 申请加锁
- 如果加锁成功, ProcessQueue 的 isLocked 设为 true,否则设为 false
注:
ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable 是当前消费者订阅的topic分配给当前消费者的所有队列
MessageQueue,ProcessQueue 存的都是当前消费者订阅的topic分配给当前消费者的队列,但是存的内容侧重点有所不同
MessageQueue: (topic,brokerName,queueId) 主要存队列与broker的关系;
ProcessQueue: 存队列在消费者端的一些状态等
public abstract class RebalanceImpl { public void lockAll() { // ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable 是当前消费者订阅的topic分配给当前消费者的所有队列 // MessageQueue: (topic,brokerName,queueId) 主要存队列与broker的关系; // ProcessQueue: 存队列在消费者端的一些状态等 // 用这个 processQueueTable 构建 brokerMqs: brockerName-该broker下该消费者消费的所有队列 HashMap<String, Set<MessageQueue>> brokerMqs = this.buildProcessQueueTableByBrokerName(); Iterator<Entry<String, Set<MessageQueue>>> it = brokerMqs.entrySet().iterator(); // 遍历 map ,即根据 broker 对这个 broker 分配给这个消费者的所有队列,一次性发送加锁请求 while (it.hasNext()) { Entry<String, Set<MessageQueue>> entry = it.next(); final String brokerName = entry.getKey(); final Set<MessageQueue> mqs = entry.getValue(); if (mqs.isEmpty()) { continue; } // 找到 broker FindBrokerResult findBrokerResult = this.mQClientFactory.findBrokerAddressInSubscribe(brokerName, MixAll.MASTER_ID, true); if (findBrokerResult != null) { // 构建加锁请求体 LockBatchRequestBody requestBody = new LockBatchRequestBody(); requestBody.setConsumerGroup(this.consumerGroup); requestBody.setClientId(this.mQClientFactory.getClientId()); // 对这个 broker 分配给这个消费者的所有队列,一次性发送加锁请求 requestBody.setMqSet(mqs); try { // 向 broker 请求加锁,返回的 lockOKMQSet 是加锁成功的 MessageQueue Set<MessageQueue> lockOKMQSet = this.mQClientFactory.getMQClientAPIImpl().lockBatchMQ(findBrokerResult.getBrokerAddr(), requestBody, 1000); for (MessageQueue mq : mqs) { // ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable 是当前消费者订阅的topic分配给当前消费者的所有队列 // MessageQueue: (topic,brokerName,queueId) 主要存队列与broker的关系; // ProcessQueue: 存队列在消费者端的一些状态等 ProcessQueue processQueue = this.processQueueTable.get(mq); if (processQueue != null) { // 如果加锁成功 if (lockOKMQSet.contains(mq)) { if (!processQueue.isLocked()) { log.info("the message queue locked OK, Group: {} {}", this.consumerGroup, mq); } // processQueue Locked 状态为 true processQueue.setLocked(true); processQueue.setLastLockTimestamp(System.currentTimeMillis()); } // 如果加锁失败 else { // processQueue Locked 状态为 false processQueue.setLocked(false); log.warn("the message queue locked Failed, Group: {} {}", this.consumerGroup, mq); } } } } catch (Exception e) { log.error("lockBatchMQ exception, " + mqs, e); } } } } }
broker 端:
下面这段代码,对里面的逻辑进行整合概括就是:或者执行时不再包含被重新分配的队列,也就不会对这些已被自己加锁的队列进行续期。那新分配的消费者判断队列锁已经过期,就可以该队列加锁成功。
- 被当前请求的client加锁的队列:进行续期,更新最新加锁时间
- 没有被任何client加锁的队列:加上当前请求的client锁,更新最新加锁时间
- 被其它client加锁的队列:判断是否过期,如果过期,将加锁client替换为当前请求的client,并进行续期,更新最新加锁时间
上面的第三点就保证了,如果rebalance服务将某些队列分配了新的消费者,而旧的消费者不再每秒执行一次(挂掉时)定时去broker加队列锁任务lockMQPeriodically,
或者执行时请求的队列不再包含被重新分配的队列,也就不会对这些之前被自己加锁的队列进行续期。那新分配的消费者判断这些队列上的队列锁已经过期,就可以对它们加锁成功。
public Set<MessageQueue> tryLockBatch(final String group, final Set<MessageQueue> mqs, final String clientId) { Set<MessageQueue> lockedMqs = new HashSet<>(mqs.size()); Set<MessageQueue> notLockedMqs = new HashSet<>(mqs.size()); // mqs 是 requestBody 的客户端所有请求加锁的队列 for (MessageQueue mq : mqs) { // 进行分类: // this.isLocked 还会在里面对已经被clientId加锁了的,进行续期,更新最新加锁时间 if (this.isLocked(group, mq, clientId)) { // 已经被请求的client加锁的队列 lockedMqs.add(mq); } else { // 没有被请求的client加锁的队列,包括两类: // 1.没有被任何client加锁的队列 // 2.被其它client而不是请求的client加锁的队列 notLockedMqs.add(mq); } } if (!notLockedMqs.isEmpty()) { try { // 对下面这段代码加同步锁 this.lock.lockInterruptibly(); try { ConcurrentHashMap<MessageQueue, LockEntry> groupValue = this.mqLockTable.get(group); if (null == groupValue) { groupValue = new ConcurrentHashMap<>(32); this.mqLockTable.put(group, groupValue); } // 遍历没有被请求的client加锁的队列,包括两类: // 1.没有被任何client加锁的队列 // 2.被其它client而不是请求的client加锁的队列 for (MessageQueue mq : notLockedMqs) { LockEntry lockEntry = groupValue.get(mq); // 队列没有被任何client锁上,加上属于该client的锁 if (null == lockEntry) { lockEntry = new LockEntry(); lockEntry.setClientId(clientId); groupValue.put(mq, lockEntry); log.info( "RebalanceLockManager#tryLockBatch: lock a message which has not been locked yet, " + "group={}, clientId={}, mq={}", group, clientId, mq); } // 1.没有被任何client加锁的队列,在上面被当前请求的client加锁成功 // 这种情况需要进行续期,更新最新加锁时间 if (lockEntry.isLocked(clientId)) { lockEntry.setLastUpdateTimestamp(System.currentTimeMillis()); lockedMqs.add(mq); continue; } String oldClientId = lockEntry.getClientId(); // 2.被其它client而不是请求的client加锁的队列 // 这种情况需要检查锁是否过期, 如果过期, 将client替换为当前请求的client,并更新加锁时间 if (lockEntry.isExpired()) { lockEntry.setClientId(clientId); lockEntry.setLastUpdateTimestamp(System.currentTimeMillis()); log.warn( "RebalanceLockManager#tryLockBatch: try to lock a expired message queue, group={}, " + "mq={}, old client id={}, new client id={}", group, mq, oldClientId, clientId); lockedMqs.add(mq); continue; } log.warn( "RebalanceLockManager#tryLockBatch: message queue has been locked by other client, " + "group={}, mq={}, locked client id={}, current client id={}", group, mq, oldClientId, clientId); } } finally { this.lock.unlock(); } } catch (InterruptedException e) { log.error("RebalanceLockManager#tryBatch: unexpected error, group={}, mqs={}, clientId={}", group, mqs, clientId, e); } } // 所以最后加锁成功的包含: // 1.没被任何client加锁,这里新被请求的client加锁 2.被其它client加锁,但是锁已经过期,这里将加锁的client替换为当前请求的client return lockedMqs; } 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) { // 对已经被当前请求的client加了锁的队列进行续期 lockEntry.setLastUpdateTimestamp(System.currentTimeMillis()); } return locked; } } return false; }
processQueue 在 broker 获得了队列锁之后,是怎么用的呢?
集群模式在 ConsumeMessageOrderlyService 消费消息时,会判断只有 this.processQueue.isLocked() 才会消费。也就是说消费者获取了这个锁,才能消费消息:
class ConsumeRequest implements Runnable { private final ProcessQueue processQueue; private final MessageQueue messageQueue; public ConsumeRequest(ProcessQueue processQueue, MessageQueue messageQueue) { this.processQueue = processQueue; this.messageQueue = messageQueue; } public ProcessQueue getProcessQueue() { return processQueue; } public MessageQueue getMessageQueue() { return messageQueue; } @Override public void run() { if (this.processQueue.isDropped()) { log.warn("run, the message queue not be able to consume, because it's dropped. {}", this.messageQueue); return; } final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue); synchronized (objLock) { if (MessageModel.BROADCASTING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel()) || this.processQueue.isLocked() && !this.processQueue.isLockExpired()) { // ....... // 暂时省略中间的消费代码 } else { if (this.processQueue.isDropped()) { log.warn("the message queue not be able to consume, because it's dropped. {}", this.messageQueue); return; } ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 100); } } } }
2.消费时 consumer 对 MessageQueue 加本地互斥锁,保证同一时间队列只能被消费者的一个线程消费
public class ConsumeMessageOrderlyService implements ConsumeMessageService { class ConsumeRequest implements Runnable { private final ProcessQueue processQueue; private final MessageQueue messageQueue; public ConsumeRequest(ProcessQueue processQueue, MessageQueue messageQueue) { this.processQueue = processQueue; this.messageQueue = messageQueue; } public ProcessQueue getProcessQueue() { return processQueue; } public MessageQueue getMessageQueue() { return messageQueue; } @Override public void run() { if (this.processQueue.isDropped()) { log.warn("run, the message queue not be able to consume, because it's dropped. {}", this.messageQueue); return; } // MessageQueue 本地锁,保证一个队列同一时刻只能有一个线程消费 // 这个锁是广播模式和集群模式都会加的。广播模式是为了消费一个队列时的顺序性? final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue); synchronized (objLock) { // 集群模式下,如果没有从 broker 获得的队列锁, 就不会往下走, 不会消费 if (MessageModel.BROADCASTING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel()) || this.processQueue.isLocked() && !this.processQueue.isLockExpired()) { final long beginTime = System.currentTimeMillis(); for (boolean continueConsume = true; continueConsume; ) { if (this.processQueue.isDropped()) { log.warn("the message queue not be able to consume, because it's dropped. {}", this.messageQueue); break; } // 集群模式走到这,broker 的队列锁却没了,提交一个到线程池中:等一会获取broker上的锁,并重新消费 if (MessageModel.CLUSTERING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel()) && !this.processQueue.isLocked()) { log.warn("the message queue not locked, so consume later, {}", this.messageQueue); ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 10); break; } // 集群模式走到这,broker 的队列锁却过期了,提交一个到线程池中:等一会获取broker上的锁,并重新消费 if (MessageModel.CLUSTERING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel()) && this.processQueue.isLockExpired()) { log.warn("the message queue lock expired, so consume later, {}", this.messageQueue); ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 10); break; } long interval = System.currentTimeMillis() - beginTime; if (interval > MAX_TIME_CONSUME_CONTINUOUSLY) { ConsumeMessageOrderlyService.this.submitConsumeRequestLater(processQueue, messageQueue, 10); break; } final int consumeBatchSize = ConsumeMessageOrderlyService.this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize(); // 获取 consumeBatchSize 条消息。注意这里获取到的消息是按 offset 排序的 // TreeMap是java中按key排序的map类型数据结构。msgTreeMap 是 offset-msg // 也就是说 msgTreeMap 是按 offset 排序的消息类型 List<MessageExt> msgs = this.processQueue.takeMessages(consumeBatchSize); defaultMQPushConsumerImpl.resetRetryAndNamespace(msgs, defaultMQPushConsumer.getConsumerGroup()); if (!msgs.isEmpty()) { final ConsumeOrderlyContext context = new ConsumeOrderlyContext(this.messageQueue); ConsumeOrderlyStatus status = null; // init the consume context 这里省略 ....... long beginTimestamp = System.currentTimeMillis(); ConsumeReturnType returnType = ConsumeReturnType.SUCCESS; boolean hasException = false; try { // 对 ProcessQueue 加锁,防止负载均衡时将 ProcessQueue 删除 this.processQueue.getConsumeLock().lock(); if (this.processQueue.isDropped()) { log.warn("consumeMessage, the message queue not be able to consume, because it's dropped. {}", this.messageQueue); break; } // 消费消息 status = messageListener.consumeMessage(Collections.unmodifiableList(msgs), context); } catch (Throwable e) { log.warn(String.format("consumeMessage exception: %s Group: %s Msgs: %s MQ: %s", UtilAll.exceptionSimpleDesc(e), ConsumeMessageOrderlyService.this.consumerGroup, msgs, messageQueue), e); hasException = true; } finally { this.processQueue.getConsumeLock().unlock(); } if (null == status || ConsumeOrderlyStatus.ROLLBACK == status || ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT == status) { log.warn("consumeMessage Orderly return not OK, Group: {} Msgs: {} MQ: {}", ConsumeMessageOrderlyService.this.consumerGroup, msgs, messageQueue); } // 这里是计算 RT 的,省略...... if (ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.hasHook()) { consumeMessageContext.getProps().put(MixAll.CONSUME_CONTEXT_TYPE, returnType.name()); } if (null == status) { status = ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT; } // 执行 hook 的, 省略..... continueConsume = ConsumeMessageOrderlyService.this.processConsumeResult(msgs, status, context, this); } else { continueConsume = false; } } } else { if (this.processQueue.isDropped()) { log.warn("the message queue not be able to consume, because it's dropped. {}", this.messageQueue); return; } // 集群模式下 broker 没有这个队列的对这个消费者的锁 this.processQueue.isLocked(), 就会走到这个分支,尝试稍后获取锁并消费 ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 100); } } } } }
3.消费时 consumer 对 ProcessQueue 加本地互斥锁,保证正在消费时,rebalance不会解除队列在broker的分布式锁,本地不会将正在消费的 processQueue 删除
除了上面顺序消费线程池消费时,对 ProcessQueue 加锁,还有一个地方也用了 processQueue.getConsumeLock().lock();
就是在 RebalancePushImpl 的 removeUnnecessaryMessageQueue
所以说这个锁的目的就是防止在消费消息的过程中,该消息队列因为发生负载均衡而被分配给其他客户端,导致 ProcessQueue 被移除
public class RebalancePushImpl extends RebalanceImpl { @Override public boolean removeUnnecessaryMessageQueue(MessageQueue mq, ProcessQueue pq) { this.defaultMQPushConsumerImpl.getOffsetStore().persist(mq); this.defaultMQPushConsumerImpl.getOffsetStore().removeOffset(mq); // 如果是顺序消费并且是集群模式 if (this.defaultMQPushConsumerImpl.isConsumeOrderly() && MessageModel.CLUSTERING.equals(this.defaultMQPushConsumerImpl.messageModel())) { try { // 如果获得了 consumeLock, 说明 ProcessQueue 现在没有在被消费, 可以去 broker 解锁 if (pq.getConsumeLock().tryLock(1000, TimeUnit.MILLISECONDS)) { try { // 异步去 broker 解锁队列的 clientId 分布式锁 return this.unlockDelay(mq, pq); // 解除成功, 就会返回 true, 就会移除 ProcessQueue } finally { pq.getConsumeLock().unlock(); } } // 没有获得 consumeLock, 说明 ProcessQueue 现在正在被消费, 不可以去 broker 解锁 else { log.warn("[WRONG]mq is consuming, so can not unlock it, {}. maybe hanged for a while, {}", mq, pq.getTryUnlockTimes()); pq.incTryUnlockTimes(); } } catch (Exception e) { log.error("removeUnnecessaryMessageQueue Exception", e); } return false; } return true; } private boolean unlockDelay(final MessageQueue mq, final ProcessQueue pq) { if (pq.hasTempMessage()) { log.info("[{}]unlockDelay, begin {} ", mq.hashCode(), mq); this.defaultMQPushConsumerImpl.getmQClientFactory().getScheduledExecutorService().schedule(new Runnable() { @Override public void run() { log.info("[{}]unlockDelay, execute at once {}", mq.hashCode(), mq); RebalancePushImpl.this.unlock(mq, true); } }, UNLOCK_DELAY_TIME_MILLS, TimeUnit.MILLISECONDS); } else { this.unlock(mq, true); } return true; }
}
rebalance unlock
public abstract class RebalanceImpl { public void unlock(final MessageQueue mq, final boolean oneway) { FindBrokerResult findBrokerResult = this.mQClientFactory.findBrokerAddressInSubscribe(this.mQClientFactory.getBrokerNameFromMessageQueue(mq), MixAll.MASTER_ID, true); if (findBrokerResult != null) { UnlockBatchRequestBody requestBody = new UnlockBatchRequestBody(); requestBody.setConsumerGroup(this.consumerGroup); requestBody.setClientId(this.mQClientFactory.getClientId()); requestBody.getMqSet().add(mq); try { this.mQClientFactory.getMQClientAPIImpl().unlockBatchMQ(findBrokerResult.getBrokerAddr(), requestBody, 1000, oneway); log.warn("unlock messageQueue. group:{}, clientId:{}, mq:{}", this.consumerGroup, this.mQClientFactory.getClientId(), mq); } catch (Exception e) { log.error("unlockBatchMQ exception, " + mq, e); } } } }
总结
push 消费模式,拉取到消息后,是提交一个线程到消费者线程池处理的,所以是多线程消费。要通过加锁保证顺序消费
集群模式下,有rebalance服务,还要保证 同一时刻,一个消费队列只能被一个消费者中的一个线程消费。所以要在 broker 加一个分布式锁:
- 定时在 broker 对队列加 clientId 分布式锁(消费者记录在ProcessQueue.locked),保证 rebalance 时一个队列也只能被一个消费者消费
- 消费时 consumer 对 MessageQueue 加本地互斥锁(MessageQueueLock类中mqLockTable),保证同一时间队列只能被消费者的一个线程消费
- 消费时 consumer 对 ProcessQueue 加本地互斥锁(ProcessQueue.consumeLock),保证正在消费时,rebalance不会解除队列在broker的分布式锁,本地不会将正在消费的 processQueue 删除
注:
ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable 是当前消费者订阅的topic分配给当前消费者的所有队列
MessageQueue,ProcessQueue 存的都是当前消费者订阅的topic分配给当前消费者的队列,但是存的内容侧重点有所不同
MessageQueue: (topic,brokerName,queueId) 主要存队列与broker的关系;
ProcessQueue: 存队列在消费者端的一些状态等
事务消息
消息队列需要事务的例子:
订单系统创建订单后,发消息给购物车系统,将已下单的商品从购物车中删除。因为从购物车删除已下单商品这个步骤,并不是用户下单支付这个主要流程中必需的步骤,使用消息队列来异步清理购物车是更加合理的设计。
在分布式系统中,上面提到的这些步骤,任何一个步骤都有可能失败,如果不做任何处理,那就有可能出现订单数据与购物车数据不一致的情况,比如说:
- 创建了订单,没有清理购物车;
- 订单没创建成功,购物车里面的商品却被清掉了。
那我们需要解决的问题可以总结为:在上述任意步骤都有可能失败的情况下,还要保证订单库和购物车库这两个库的数据一致性。
消息队列如何实现分布式事务
事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。
首先,订单系统在消息队列上开启一个事务。然后订单系统给消息服务器发送一个“半消息”,这个半消息不是说消息内容不完整,它包含的内容就是完整的消息内容,半消息和普通消息的唯一区别是,在事务提交之前,对于消费者来说,这个消息是不可见的。
半消息发送成功后,订单系统就可以执行本地事务了,在订单库中创建一条订单记录,并提交订单库的数据库事务。然后根据本地事务的执行结果决定提交或者回滚事务消息。
- 如果订单创建成功,那就提交事务消息,购物车系统就可以消费到这条消息继续后续的流程。
- 如果订单创建失败,那就回滚事务消息,购物车系统就不会收到这条消息。
这样就基本实现了本地事务和消息发送“要么都成功,要么都失败”的原子性要求。但这个实现过程中,有一个问题是没有解决的。如果在第四步提交事务消息时失败了怎么办?
对于这个问题,Kafka 和 RocketMQ 给出了 2 种不同的解决方案。
- Kafka 的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。
- RocketMQ 则给出了另外一种解决方案:Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。
RocketMQ 事务消息
RocketMQ发布了4.3.0版本后支持了事务消息:
- 事务消息发送及提交:
- 发送消息(half消息)。
- 服务端响应消息写入结果。
- 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)。
- 根据本地事务状态提交Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)。
- 补偿流程:
- 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
- Producer收到回查消息,检查回查消息对应的本地事务的状态
- 根据本地事务状态,重新Commit或者Rollback
其中,补偿阶段用于解决消息Commit或者Rollback发生超时或者失败的情况。
为了支撑这个事务反查机制,我们的业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。在我们这个例子中,反查本地事务的逻辑也很简单,我们只要根据消息中的订单 ID,在订单库中查询这个订单是否存在即可,如果订单存在则返回成功,否则返回失败。RocketMQ 会自动根据事务反查的结果提交或者回滚事务消息。
RocketMQ 如何实现事务消息
1.事务消息在一阶段对用户不可见
如果消息是half消息, 将备份原消息的主题与消息消费队列,然后改变主题为 RMQ_SYS_TRANS_HALF_TOPIC(内部topic)。由于消费组未订阅该主题,故消费端 无法消费half类型的消息,然后RocketMQ会开启一个定时任务,从Topic为RMQ_SYS_TRANS_HALF_TOPIC中拉取消息进行消费,根据生产者组获取一个服务提供者回查事务状态请求,根据事务状态来决定是提交或回滚消息。
RocketMQ 消息在 broker 每条消息都会有对应的索引信息,Consumer通过ConsumeQueue这个二级索引 来读取消息实体内容,
具体实现策略是:写入的如果事务消息,对消息的Topic和Queue等属性进行替换,同时将原来的Topic和Queue信息 存储到消息的属性中。
2.Op消息的引入
在完成一阶段写入一条对用户不可见的消息后,二阶段如果是Commit操作,则需要让消息对用户可见;如果是Rollback则需要撤销一阶段的消息。
但是区别于这条消息没有确定状态(Pending状态,事务悬而未决),需要一个操作来标识这条消息的最终状态。RocketMQ事务消息方案中引入了Op消息的概念,用Op消息标识事务消息已经确定的状态(Commit或者Rollback)。如果一条事务消息没有对应的Op消息,说明这个事务的状态还无法确定(可能是二阶段 失败了)。引入Op消息后,事务消息无论是Commit或者Rollback都会记录一个Op操作。
3.Op消息的存储和对应关系
RocketMQ将Op消息写入到全局一个特定的Topic中通过源码中的方法—TransactionalMessageUtil.buildOpTopic();这个Topic是一 个内部的Topic RMQ_SYS_TRANS_OP_HALF_TOPIC(像Half消息的Topic一样),不会被用户消费。Op消息的内容为对应的Half消息的存储的Offset,这样通过Op消息能索引到Half消息进行后续的回查操作。
4.Commit和Rollback操作
Rollback 的情况:
本身一阶段的消息对用户是不可见的,其实不需要真正撤销消息(实际上RocketMQ也无法去真正的删除一条消息,因为是顺序写文件的)。
所以在opTopic写入一条消息,标志它已经是rollback状态即可。
Commit 的情况:
要读取出Half消息,并将Topic和Queue替换成真正的目标的Topic和Queue,之后通过一次普通消息的写入操作来生成一条对 用户可见的消息。所以RocketMQ事务消息二阶段其实是利用了一阶段存储的消息的内容,在二阶段时恢复出一条完整的普通消 息,然后走一遍消息写入流程。
再在opTopic写入一条消息,标志它已经是 commit 状态了。
5.如何处理二阶段失败的消息?
如果在RocketMQ事务消息的二阶段过程中失败了,例如在做Commit操作时,出现网络问题导致Commit失败,那么需要通过一定 的策略使这条消息最终被Commit。
Broker端对未确定状态的消息发起回查,将消息发送到对应的Producer端(同一个Group的Producer),由Producer根据消息来检查本地事务的状态,进而执行Commit或者 Rollback。
Broker端通过对比Half消息和Op消息进行事务消息的回查并且推进CheckPoint(记录那些事务消息的状态是确定的)。 值得注意的是,rocketmq并不会无休止的的信息事务状态回查,默认回查15次,如果15次回查还是无法得知事务状态,rocketmq 默认回滚该消息。
6.如何确定哪些消息需要回查
- 获取Half Topic的所有队列,循环队列开始检测需要获取的prepare消息,实际上Half Topic只有一个队列。
- 获取Half Topic与Op Topic的消费进度。
- 调用
fillOpRemoveMap
方法,获取Op Half Topic中已完成的prepare事务消息。 - 从Half Topic中当前消费进度依次获取消息,与第3步获取的已结束的prepare消息进行对比,判断是否进行回查:
- 如果Op消息中包含该消息,则不进行回查
- 如果Op消息不包含该消息,获取Half Topic中的该消息,判断写入时间是否符合回查条件,若是新消息则不处理下次处理,并将消息重新写入Half Topic,判断回查次数是否小于15次,写入时间是否小于72h,如果不满足就丢弃消息,若满足则更新回查次数,并将消息重新写入Half Topic并进行事务回查,
- 在循环完后重新更新Half Topic与Op Topic中的消费进度,下次判断回查逻辑时,将从最新的消费进度获取信息。
延迟消息
功能特点:
- RocketMQ支持发送延迟消息,但不支持任意时间的延迟消息的设置(商业版本中支持),仅支持内置预设值的延迟时间间隔的延迟消息;
- 预设值的延迟时间间隔为:1s、 5s、 10s、 30s、 1m、 2m、 3m、 4m、 5m、 6m、 7m、 8m、 9m、 10m、 20m、 30m、 1h、 2h;
- 在消息创建的时候,调用 setDelayTimeLevel(int level) 方法设置延迟时间;
实现原理:
定义用户topic为user_topic。流程如下:
- 消息消费者将message投递到broker的commitLog服务
- commitLog服务判断message为延迟消息,将实际的topic和queueId保存到message的属性中(为了后面的流程用于消息的重新投递)。并将topic设置成内部延迟topic(SCHEDULE_TOPIC_XXXX),queueId对应的延迟级别。消息投递时间保存在tagCode中。
- 消息延迟服务(ScheduleMessageService)从SCHEDULE_TOPIC_XXXX主题循环拉取消息。
- 将达到延迟时间要求的消息,Topic和Queue替换成真正的目标的Topic和Queue,之后通过一次普通消息的写入操作来生成一条对用户可见的消息。
- 消费者就可以拉取到新写入的 user_topic 的消息并消费了。
没错,就是 SCHEDULE_TOPIC_XXXX 刚开始看到还以为 XXXX 在指代什么。。。
public class TopicValidator { public static final String RMQ_SYS_SCHEDULE_TOPIC = "SCHEDULE_TOPIC_XXXX";
}
重要的类:
- org.apache.rocketmq.store.DefaultMessageStore.putMessage(MessageExtBrokerInner):消息进入mq文件系统的入口
- org.apache.rocketmq.store.CommitLog.putMessage(MessageExtBrokerInner):消息保存到Commit文件的入口(保存消息之后,包含刷满策略,ha处理)
- org.apache.rocketmq.store.DefaultMessageStore.ReputMessageService:消息分发服务
- org.apache.rocketmq.store.schedule.ScheduleMessageService:延迟消息服务
- org.apache.rocketmq.store.DefaultMessageStore.doDispatch(DispatchRequest):消息服务入口(分发给consumerqueue、index)
批量消息
批量消息是指将多条消息合并成一个批量消息,一次发送出去。这样的好处是可以减少网络IO,提升吞吐量。
如果批量消息大于1MB就不要用一个批次发送,而要拆分成多个批次消息发送。也就是说,一个批次消息的大小不要超过1MB。
实际使用时,这个1MB的限制可以稍微扩大点,实际最大的限制是4194304字节,大概4MB。但是使用批量消息时,这个消息长度确实是必须考虑的一个问题。而且批量消息的使用是有一定限制的,这些消息应该有相同的Topic,相同的waitStoreMsgOK。而且不能是延迟消息、事务消息等。
消息过滤
1.Tag过滤
- 生产者发送消息时设置 tag
- 消费者订阅时可以只订阅 topic 下 某些 tag 的消息
- 消费者拉取消息时,就会在对 broker 的 pull 请求上带上过滤的 tag 表达式。broker 会根据 tag 过滤。
org.apache.rocketmq.client.impl.consumer.PullAPIWrapper#pullKernelImpl
boolean isTagType = ExpressionType.isTagType(subscriptionData.getExpressionType()); PullResult pullResult = this.pullAPIWrapper.pullKernelImpl( mq, subscriptionData.getSubString(), subscriptionData.getExpressionType(), isTagType ? 0L : subscriptionData.getSubVersion(), offset, maxNums, sysFlag, 0, this.defaultMQPullConsumer.getBrokerSuspendMaxTimeMillis(), timeoutMillis, CommunicationMode.SYNC, null ); PullMessageRequestHeader requestHeader = new PullMessageRequestHeader(); ....... requestHeader.setSubscription(subExpression); requestHeader.setSubVersion(subVersion); requestHeader.setMaxMsgBytes(maxSizeInBytes); requestHeader.setExpressionType(expressionType); ......
对于实时计算系统来说,它订阅交易Topic下所有的消息,Tag用星号(*)表示即可。
consumer.subscribe("TagFilterTest", "*");
对于交易成功率分析系统来说,它订阅了订单和支付两个Tag的消息,在多个Tag之间用两个竖线(||)分隔即可。
consumer.subscribe("TagFilterTest", "TagA||TagB");
需要注意的是,如果同一个消费者多次订阅某个Topic下的Tag,以最后一次订阅为准。
//如下错误代码中,Consumer只能订阅到TagFilterTest下TagB的消息,而不能订阅TagA的消息。 consumer.subscribe("TagFilterTest", "TagA"); consumer.subscribe("TagFilterTest", "TagB");
订阅关系:一个消费者组订阅一个 Topic 的某一个 Tag,这种记录被称为订阅关系。
订阅关系一致:同一个消费者组下所有消费者实例所订阅的Topic、Tag必须完全一致。如果订阅关系(消费者组名-Topic-Tag)不一致,会导致消费消息紊乱,甚至消息丢失。
如下面这种情况,同一个消费者组的不同实例,订阅了同一个 topic,但是一个订阅了 TAGA , 一个订阅了 TAGB :
2.Sql过滤
SQL92过滤是在消息发送时设置消息的Tag或自定义属性,消费者订阅时使用SQL语法设置过滤表达式,根据自定义属性或Tag过滤消息。
如下图,tag 有物流消息 "logistics" 订单消息 "order",自定义属性有 region 里面存放物流的地域,那么
- 只订阅物流消息且低于为杭州或上海 TAGS = "logistics" and region in ('HZ','SH')
- 只订阅订单消息 TAGS = "order"
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库