《消息队列高手课》二:消息模型介绍以及“RocketMQ是如何维护队列的消费位置的?”


因为没有对消息做一些统一的标准制定(历史上有JMS和AMQP这些标准的指定的尝试,但是MQ的演化速度过快,导致这些标准很快废弃)每个消息队列都有自己的一套消息模型。十几年来不断的消息队列的不断演进发展出了很多模式,各种模式随之而来的就是——冒出了很多让初学者望而生畏的名词:队列(Queue)、主题(Topic)、分区(Partition)...

下面的内容会阐明一部分概念。

主题和队列的区别

MQ模型:队列模型

首先队列Queue是一种数据结构,先进先出的线性表。推进队列元素的参与者称为Producer,在队列另外一端删除/消费元素的参与者称为消费者Consumer。存放这些元素(MQ里面称为消息)的容器被称为队列。

image-20220321151803115

上面描述的,就是一种MQ的模型,被称为队列模型。

这个模型的缺点是什么?消费者是竞争关系,只有一个消费者能够抢占到消费的机会,无法做到广播消费。

MQ模型:发布-订阅模型

为了解决上述这个缺点,新的消息模型演化出来,称为“发布 - 订阅模型(Publish-Subscribe Pattern)”。

image-20220321151851241

下面做一下概念迁移,从队列模型中迁移到这个模型当中去

  • 生产者称为发布者
  • 消费者称为订阅者
  • 存放消息的容器不再是队列,现在被称为“主题”,也就是Topic

重点来了,和上面队列模型完全不同的是:订阅者接受消息进行消费之前,必须要订阅某个主题。

订阅可以是一个“动作”,类似与订报纸。也可以是主题(某个消息)的一个副本。本质上和队列模型其实区别不大,但是这个订阅带来的特殊性就是,同一个消息,因为被多个消费者订阅,那么意味着一份消息可以被多次消费。这个就是和队列模型完全不同的地方。

消息模型的案例

从RabbitMQ开始讲起

RabbitMQ比较特殊,并没有使用看起来更新潮的发布 - 订阅模型,而是坚持使用队列模型。之前说过,RabbitMQ自身包含一个类似路由器的Exchange模块——位于生产者和需要接受生产出来的消息的队列容器之间,该模块会根据路由的策略将生产者投递来的消息发往指定队列容器中。

比如在官方教程里面,你就能看到这个模型的图示:

img

a message goes to the queues whose binding key exactly matches the routing key of the message.

消息会进入绑定的key值与路由的键值完全匹配的队列中去。如果发布的消息的key为橙色,经过Exchange模块之后,就会根据类似路由表的东西关联到Q1里面去,否则会路由到Q2里面去。

如果想要实现单个消息被多个消费者消费如何实现?—— 配置Exchange模块内部的路由规则,将一个消息的多个副本发到多个队列里面去。

RocketMQ 的消息模型是怎样的

👏 标准的发布 - 订阅模型!

为什么是“标准的”?因为RocketMQ的发布 - 订阅模型和上面介绍模型的发布 - 订阅模型一部分的内容的解释完全一致。

那么这里也有Queue队列的概念,这里的Queue有什么作用?

首先要说明,很多MQ产品使用“请求-确认机制”保障消息传递不发生故障,类似TCP的握手机制一样:

  1. Broker服务端收到生产者的消息并将其写入MessageQueue之后,会给生产者一个确认的Response。如果生产者没有接收到,则会重发消息。
  2. 消费者消费消息同理,消费完发送响应,否则Broker会发送消息给消费者再次确认是否消费。

这个机制存在一个问题:为了确保消息的有序性,需要保障——如果消息被成功消费之前,接着的一条是不能被消费的。那么意味着同一个Topic同一时刻智能存在一个消费者(即使是多个消息容器)。这种竞争的状态导致整个不能横向的扩展(理想的情况是:比如订单的各个有序的消息分布在多个队列上,保证有序的同时,生产和消费依然能够并行发生)。为了解决这个问题,引入了RocketMQ当中的Queue概念。

Topic和Queue的关系

一个主题下面包含多个Queue,多个队列是为了支持并行的生产和消费,RocketMQ保证每个队列上的消费顺序是严格的,一个消息,只可以存在于一个队列当中,但是对于整个Topic来说,你无法在保证消息的严格顺序,因为多个Queue的访问是并行的。

image-20220321230801854

Consumer和ConsumerGroup

在RocketMQ当中,订阅者通过消费组体现自己的概念,一个消费组消费提分完整的消息,不同消费组之间消费的进度互不影响。消费组的产生的主要目的,是为了区分不同的订阅者,

image-20220321231724223

同组内的消费者是竞争消费关系,如果某消息A被同组的某消费者α消费了,那么消息A就不会被消费者β、消费者γ消费掉。

Topic在消费的时候,因为会被不同的消费组多次消费。所以消费完毕的消息不会被删掉,RocketMQ会将队列中的消息标记一个消费位置(Consumer Offset)并维护。后续不断被消费的其他消息也会入队并赋予递增的消费位置,你可以理解为下标。

消费位置是相当重要的概念,丢消息的原因,大多数是因为消费位置处理不当!

文章的最后面会讲一下RocketMQ是如何维护这个Consumer Offset

小总结:对于RabbitMQ和RocketMQ可以将整个模型归纳如下:

image-20220322205057330
  1. Topic和Queue是1-n的数量关系,生产者可指定自己的本次生产消息到这个Topic下的某个Queue当中。
  2. 同个队列可以存放被多个ConsumerGroup需要的消息,被ConsumerGroup们公用。Queue上有个指针标记偏移量offset,被消费一条消息之后,offset会向后移动。
  3. 同个消费组内会指定“本组需要消费什么消息”,分别需要两种完整消息的两个业务不能放在一个ConsumerGroup内。消费能力的“水平扩容”指的是增加同组内的消费者,提高消息的并行消费能力。
  4. 同ConsumerGroup内的Consumer们彼此之间竞争消息(这个和队列模型是一样的
  5. 想要顺序消费消息怎么办?— 比如根据订单编号、用户编号等,通过一致性Hash计算出对应的队列编号,将对应的消息按照顺序指定往这个消息队列里面压入。对于一个Queue里的多个消息来说,串行消费必然是有序的。

提一嘴Kafka的消息模型

Kafka的消息模型在业务模型方面其实和RocketMQ几乎误差,只不过Kafka的消息模型当中有个和RocketMQ的Queue差不多的概念,只不过在Kafka当中叫做分区“Partition”。

但是在实现层面上,Kafka的实现和RocketMQ的区别就很大了。

RocketMQ是如何维护Queue的ConsumerOffset?

抽象类NettyRemotingAbstract

首先,NettyRemotingAbstract#processRequestCommand。通信包(remoting)下的抽象类定义了一个处理请求命令的方法,处理远程传入的请求命令。

实现类ConsumerManageProcessor

其中一个实现是ConsumerManageProcessor类,ConsumerManageProcessor#processRequest方法会识别请求的CODE,如果是更新ConsumerOffset的值的请求(RequestCode.UPDATE_CONSUMER_OFFSET),则会路由到相关的方法。

ConsumerManageProcessor类持有了BrokerController。而BrokerController又包含一个

BrokerController里的ConsumerOffsetManager

在ConsumerManageProcessor#updateConsumerOffset这个方法里面可以看到这一行调用。

this.brokerController.getConsumerOffsetManager()
    .commitOffset(RemotingHelper.parseChannelRemoteAddr(ctx.channel()), requestHeader.getConsumerGroup(),
        requestHeader.getTopic(), requestHeader.getQueueId(), requestHeader.getCommitOffset());

代码内联的很厉害,阅读起来比较困难,我们可以将其拆分一下:

/*修改Queue消费位置(offset)*/
ConsumerOffsetManager consumerOffsetManager = this.brokerController.getConsumerOffsetManager();
String clientHost = RemotingHelper.parseChannelRemoteAddr(ctx.channel());
String consumerGroup = requestHeader.getConsumerGroup();
String topic = requestHeader.getTopic();
Long commitOffset = requestHeader.getCommitOffset();
Integer queueId = requestHeader.getQueueId();
consumerOffsetManager.commitOffset(clientHost, consumerGroup, topic, queueId, commitOffset);

这样,修改Queue消费位置(offset)这个动作的入参和调用者就非常清晰了。

commitOffset其实嵌套调用了两次,第二次调用前以“topic@group”也就是“主题@consumerGroup”的格式组装了一个key。这个key会用来获取ConsumerOffsetManager类的一个offsetTable属性。

看到offsetTable的名称后缀,其实熟悉guava的应该知道,这是一个Map嵌套Map的数据结构。

private ConcurrentMap<String/* topic@group */, ConcurrentMap<Integer, Long>> offsetTable =
    new ConcurrentHashMap<String, ConcurrentMap<Integer, Long>>(512);

根据注释,ConcurrentMap<String, ConcurrentMap<Integer, Long>> offsetTable的第一个维度是字符串类型,也就是上面说的“主题@consumerGroup”的格式组装的key。

第二个维度是在方法ConsumerOffsetManager#commitOffset当中也可以看出,这个ConcurrentHashMap的key是Integer类型的QueueId,也就是消息队列的编号。Long类型的其实就是消费位置offset。

看一眼源码:org.apache.rocketmq.broker.offset.ConsumerOffsetManager#commitOffset

private void commitOffset(final String clientHost, final String key, final int queueId, final long offset) {
    ConcurrentMap<Integer, Long> map = this.offsetTable.get(key);
    if (null == map) {
        map = new ConcurrentHashMap<Integer, Long>(32);
        map.put(queueId, offset);
        this.offsetTable.put(key, map);
    } else {
        //storeOffset是上一个Key的值,上一个消费位置会比当前消费位置offset小,所以下面校验会告警一个异常
        Long storeOffset = map.put(queueId, offset);
        if (storeOffset != null && offset < storeOffset) {
            log.warn(
                "[NOTIFYME]update consumer offset less than store. clientHost={}, key={}, queueId={}, requestOffset={}, storeOffset={}",
                clientHost, key, queueId, offset, storeOffset);
        }
    }
}
posted @ 2022-03-22 22:00  來福l4ifu  阅读(160)  评论(0编辑  收藏  举报