是这样的,我在学习rocketmq的时候遇到了一个奇怪的问题,就是同一个消费者组内的消费者订阅同一个主题topic,不同的tag的时候看到一个消息丢失的问题
这个问题我也是向《RocketMQ技术内幕》一书的作者丁威大哥,然后他给我解释了我对于这个问题的困惑,我来给大家解释一下
先给大家描述一下这个具体的内容
两个一样的Consumer Group的Consumer订阅同一个Topic,但是是不同的tag,Consumer1订阅Topic的tag1,Consumer2订阅Topic的tag2,然后分别启动。
这时候往Topic的tag1里发送10条数据,Topic的tag2里发送10条。目测应该是Consumer1和Consumer2分别收到对应的10条消息。结果却是只有Consumer2收到了消息,而且只收到了4-6条消息,不固定。
MQ底层数据结构之精妙
RocketMQ专门按照Topic为每一个topic建立索引,方便消费端按照topic进行消费,其具体实现为消息队列。
在RocketMQ中,ConsumeQueue的引入并不是为了提高消息写入的性能,而是为消费服务的。
消息消费队列中的每一个条目是一个定长的,设计极具技巧性,其每个条目使用固定长度(8字节commitlog物理偏移量、4字节消息长度、8字节tag hashcode),这里不是存储tag的原始字符串,而是存储hashcode。
目的就是确保每个条目的长度固定,可以使用访问类似数组下标的方式来快速定位条目,极大的提高了ConsumeQueue文件的读取性能,这样根据消费进度去访问消息的方法为使用逻辑偏移量logicOffset * 20即可找到该条目的起始偏移量(consumequeue文件中的偏移量),然后读取该偏移量后20个字节即得到了一个条目,无需遍历consumequeue文件。
关于RocketMQ中的三个文件,来帮助RocketMQ完成如此高效率的伟业,我也写了一个文章来介绍这三个文件,大家可以看一下通过这三个文件彻底搞懂rocketmq的存储原理
消息过滤实现机制
消费端队列存储的是 tag 的 hashcode,众所周知,不同的字符串得到的hashcode值可能一样,故在服务端是无法精确对消息进行过滤的,所以在RocketMQ中会进行两次消息过滤。
当客户端向服务端拉取消息时,服务端在返回消息之前,会先根据hashcode进行过滤,然后客户端收到服务端的消息后,再根据消息的tag字符串进行精确过滤。
上面的原理很好理解呀,那为什么会丢失消息呢?这其实和消息队列负载机制有关。
在RocketMQ中使用集群模式消费时,同一个消费组中的多个消费者共同完成主题中的队列的消费,即一个消费者只会分配到其中某几个队列,并且同一时间,一个队列只会分配给一个消费者,这样结合上面的的过滤机制,就会明显有问题,请看示例图:
其问题的核心关键是,同一个tag会分布在不同的队列中,但消费者C1分配到的队列为q0,q1,q0,q1中有taga和tagb的消息,但tagb的消息会被消费者C1过滤,但这部分消息却不会被C2消费,造成了消息丢失。
所以在RocketMQ中,一个消费组内的所有消费这,其订阅关系必须保持一致。
我们再来回过头看这个问题
首先这是Broker决定的,而不是Consumer端决定的
Consumer端发心跳给Broker,Broker收到后存到consumerTable里(就是个Map),key是GroupName,value是ConsumerGroupInfo。
ConsumerGroupInfo里面是包含topic等信息的,但是问题就出在上一步骤,key是groupName,你同GroupName的话Broker心跳最后收到的Consumer会覆盖前者的。相当于如下代码:
map.put(groupName, ConsumerGroupInfo);
这样同key,肯定产生了覆盖。所以Consumer1不会收到任何消息,但是Consumer2为什么只收到了一半(不固定)消息呢?
那是因为:你是集群模式消费,它会负载均衡分配到各个节点去消费,所以一半消息(不固定个数)跑到了Consumer1上,结果Consumer1订阅的是tag1,所以不会任何输出。
如果换成BROADCASTING,那绝逼后者会收到全部消息,而不是一半,因为广播是广播全部Consumer。
/**
* Consumer信息
*/
public class ConsumerGroupInfo {
// 组名
private final String groupName;
// topic信息,比如topic、tag等
private final ConcurrentMap<String/* Topic */, SubscriptionData> subscriptionTable =
new ConcurrentHashMap<String, SubscriptionData>();
// 客户端信息,比如clientId等
private final ConcurrentMap<Channel, ClientChannelInfo> channelInfoTable =
new ConcurrentHashMap<Channel, ClientChannelInfo>(16);
// PULL/PUSH
private volatile ConsumeType consumeType;
// 消费模式:BROADCASTING/CLUSTERING
private volatile MessageModel messageModel;
// 消费到哪了
private volatile ConsumeFromWhere consumeFromWhere;
}
/**
* 通过心跳将Consumer信息注册到Broker端。
*/
public boolean registerConsumer(final String group, final ClientChannelInfo clientChannelInfo,
ConsumeType consumeType, MessageModel messageModel, ConsumeFromWhere consumeFromWhere,
final Set<SubscriptionData> subList, boolean isNotifyConsumerIdsChangedEnable) {
// consumerTable:维护所有的Consumer
ConsumerGroupInfo consumerGroupInfo = this.consumerTable.get(group);
// 如果没有Consumer,则put到map里
if (null == consumerGroupInfo) {
ConsumerGroupInfo tmp = new ConsumerGroupInfo(group, consumeType, messageModel, consumeFromWhere);
// put到map里
ConsumerGroupInfo prev = this.consumerTable.putIfAbsent(group, tmp);
consumerGroupInfo = prev != null ? prev : tmp;
}
// 更新Consumer信息,客户端信息
boolean r1 =
consumerGroupInfo.updateChannel(clientChannelInfo, consumeType, messageModel,
consumeFromWhere);
// 更新订阅Topic信息
boolean r2 = consumerGroupInfo.updateSubscription(subList);
if (r1 || r2) {
if (isNotifyConsumerIdsChangedEnable) {
this.consumerIdsChangeListener.handle(ConsumerGroupEvent.CHANGE, group, consumerGroupInfo.getAllChannel());
}
}
this.consumerIdsChangeListener.handle(ConsumerGroupEvent.REGISTER, group, subList);
return r1 || r2;
}
从这一步可以看出消费者信息是以groupName为key,ConsumerGroupInfo为value存到map(consumerTable)里的,那很明显了,后者肯定会覆盖前者的,因为key是一样的。
而后者的tag是tag2,那肯定覆盖了前者的tag1,这部分是存到ConsumerGroupInfo的subscriptionTable里面的。
private final ConcurrentMap<String/* Topic */, SubscriptionData> subscriptionTable =
new ConcurrentHashMap<String, SubscriptionData>();
SubscriptionData包含了topic等信息
public class SubscriptionData implements Comparable<SubscriptionData> {
// topic
private String topic;
private String subString;
// tags
private Set<String> tagsSet = new HashSet<String>();
private Set<Integer> codeSet = new HashSet<Integer>();
}
其实到这里,这个问题已经算是解决了七八成了,等同于是后来的消费者的注册信息会把之前的消费者的注册信息覆盖掉,这也就导致了上述出现的现象。
先启动订阅了tag1的消费者,然后启动了订阅了tag2的消费者,这时最新的心跳信息是来源于tag2的这个消费者,这就导致了这个消费者的订阅信息会覆盖掉之前的订阅信息,这是因为在RocketMQ中会认为同一个消费者组的消费者的订阅信息是需要保持一致的,如果不保持一致是不被允许的做法。
如果真有那种,你去新建一个topic不就好了,或者新建一个消费者组不就好了,在使用的过程中一定要保持消费者组的订阅信息保持一致。
这也就导致了发送者发送的tag1的消息压根不会被这个消费者接收到,而两个消费者自然不会消费这个的消息。
而为什么只收到tag2的部分消息
这是因为rocketMQ默认采用的是集群消费的模式,也就是生产者的消息会通过负载均衡将消息均匀的发送到多个consumerqueue队列中,默认是4个,也就是我们启动的两个消费者会分别监听两个consumerqueue队列
这也就意味着有大约一半的tag2的消息会被运送到消费者1的机器上消费,而消费者1监听的是tag1,不满足消息的条件,所以监听不到消息
topic和tag信息是如何覆盖的
/**
* 其实很简单,就是以topic为key,SubscriptionData为value。而SubscriptionData里包含了tags信息,所以直接覆盖掉
*/
public boolean updateSubscription(final Set<SubscriptionData> subList) {
for (SubscriptionData sub : subList) {
SubscriptionData old = this.subscriptionTable.get(sub.getTopic());
if (old == null) {
SubscriptionData prev = this.subscriptionTable.putIfAbsent(sub.getTopic(), sub);
} else if (sub.getSubVersion() > old.getSubVersion()) {
this.subscriptionTable.put(sub.getTopic(), sub);
}
}
}
本文参考文章:
https://codingw.blog.csdn.net/article/details/116299837。
https://dalin.blog.csdn.net/article/details/107241375。