参考:

shanml :【RocketMQ】【源码】顺序消息实现原理

低学历程序员 : RocketMQ系列之客户端顺序消息线程模型(八)

王侦 : RocketMQ基础原理

李玥:消息队列高手课

IT小栈 :RocketMQ延迟消息

顺序消息

一个订单产生了三条消息分别是订单创建、订单付款、订单完成。消费时要按照这个顺序消费才能有意义,但是同时订单之间是可以并行消费的。

顺序消息分为全局顺序消息与局部顺序消息,全局顺序是指某个Topic下的所有消息都要保证顺序;局部顺序消息只要保证每一组消息被顺序消费即可,同一组消息有一个共同的 shardingkey。

如果想要实现全局顺序消息,那么只能使用一个队列,以及单个生产者,这是会严重影响性能。

局部顺序消息实际上有两个核心点:

  • 一个是生产者有序存储,把同一个 shardingkey 的消息投到同一个队列
  • 另一个是消费者有序消费,保证同一时刻,一个队列里的消息只能被一个消费者中的一个线程消费。

实际上,采用队列选择器的方法不能保证消息的严格顺序,我们的目的是将消息发送到同一个队列中,如果某个broker挂了,那么队列就会减少一部分,如果采用取余的方式投递,将可能导致同一个业务中的不同消息被发送到不同的队列中,导致同一个业务的不同消息被存入不同的队列中,短暂的造成部分消息无序。同样的,如果增加了服务器,那么也会造成短暂的造成部分消息无序。

生产端有序生产

生产端发送消息时根据 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);
        }
    }
实际上,采用队列选择器的方法不能保证消息的严格顺序,我们的目的是将消息发送到同一个队列中,如果某个broker挂了,那么队列就会减少一部分,如果采用取余的方式投递,将可能导致同一个业务中的不同消息被发送到不同的队列中,导致同一个业务的不同消息被存入不同的队列中,短暂的造成部分消息无序。同样的,如果增加了服务器,那么也会造成短暂的造成部分消息无序。

消费端有序消费

消费端也要确保消费这个队列时是一个线程消费的

首先来看看 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);
        }
    }

消费端上锁过程:

  1. 将该消费者订阅的 topic 分配给当前消费者的所有队列,按照 broker 进行分组 HashMap<String, Set<MessageQueue>> brokerMqs 内容为 brockerName-该broker下该消费者消费的所有队列
  2. 遍历上面根据 broker 分组过的 brokerMqs ,按照不同的 broker,对一个 broker 下该消费者被分配的所有队列,一次性向这个 broker 申请加锁
  3. 如果加锁成功, 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版本后支持了事务消息:

  1. 事务消息发送及提交:
    1. 发送消息(half消息)。
    2. 服务端响应消息写入结果。
    3. 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)。
    4. 根据本地事务状态提交Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)。
  2. 补偿流程:
    1. 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
    2. Producer收到回查消息,检查回查消息对应的本地事务的状态
    3. 根据本地事务状态,重新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。流程如下:

  1. 消息消费者将message投递到broker的commitLog服务
  2. commitLog服务判断message为延迟消息,将实际的topic和queueId保存到message的属性中(为了后面的流程用于消息的重新投递)。并将topic设置成内部延迟topic(SCHEDULE_TOPIC_XXXX),queueId对应的延迟级别。消息投递时间保存在tagCode中。
  3. 消息延迟服务(ScheduleMessageService)从SCHEDULE_TOPIC_XXXX主题循环拉取消息。
  4. 达到延迟时间要求的消息,Topic和Queue替换成真正的目标的Topic和Queue之后通过一次普通消息的写入操作来生成一条对用户可见的消息。
  5. 消费者就可以拉取到新写入的 user_topic 的消息并消费了。

没错,就是 SCHEDULE_TOPIC_XXXX 刚开始看到还以为 XXXX 在指代什么。。。

public class TopicValidator {

    public static final String RMQ_SYS_SCHEDULE_TOPIC = "SCHEDULE_TOPIC_XXXX";
}

重要的类:

  1. org.apache.rocketmq.store.DefaultMessageStore.putMessage(MessageExtBrokerInner):消息进入mq文件系统的入口
  2. org.apache.rocketmq.store.CommitLog.putMessage(MessageExtBrokerInner):消息保存到Commit文件的入口(保存消息之后,包含刷满策略,ha处理)
  3. org.apache.rocketmq.store.DefaultMessageStore.ReputMessageService:消息分发服务
  4. org.apache.rocketmq.store.schedule.ScheduleMessageService:延迟消息服务
  5. 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"