rocketMQ--offset
offset
在rocketMQ中,offset用来管理每个消费队列的不同消费组的消费进度。对offset的管理分为本地模式和远程模式,本地模式是以文本文件的形式存储在客户端,而远程模式是将数据保存到broker端,对应的数据结构分别为LocalFileOffsetStore和RemoteBrokerOffsetStore。
默认情况下,当消费模式为广播模式时,offset使用本地模式存储,因为每条消息会被所有的消费者消费,每个消费者管理自己的消费进度,各个消费者之间不存在消费进度的交集;当消费模式为集群消费时,则使用远程模式管理offset,消息会被多个消费者消费,不同的是每个消费者只负责消费其中部分消费队列,添加或删除消费者,都会使负载发生变动,容易造成消费进度冲突,因此需要集中管理。同时,RocketMQ也提供接口供用户自己实现offset管理(实现OffsetStore接口)。
生产环境上一般使用集群模式,本文主要记录集群模式下offset的管理,即RemoteBrokerOffsetStore。
broke端
offset的存储与加载
rocketMQ的broker端中,offset的是以json的形式持久化到磁盘文件中,文件路径为${user.home}/store/config/consumerOffset.json。其内容示例如下:
{
"offsetTable": {
"test-topic@test-group": {
"0": 88526,
"1": 88528
}
}
}
broker端启动后,会调用BrokerController.initialize()方法,方法中会对offset进行加载,consumerOffsetManager.load()。获取文件内容后,序列化为ConsumerOffsetManager对象,实质是其属性ConcurrentMap
/**ConsumerOffsetManager.offsetTable*/
private ConcurrentMap> offsetTable =
new ConcurrentHashMap>(512);
/**ConsumerOffsetManager.decode*/
public void decode(String jsonString) {
if (jsonString != null) {
// 序列化成功后复制给全局ConsumerOffsetManager对象
ConsumerOffsetManager obj = RemotingSerializable.fromJson(jsonString, ConsumerOffsetManager.class);
if (obj != null) {
this.offsetTable = obj.offsetTable;
}
}
}
commitLog与offset
如下图所示,producer发送消息到broker之后,会将消息具体内容持久化到commitLog文件中,再分发到topic下的消费队列consume Queue,消费者提交消费请求时,broker从该consumer负责的消费队列中根据请求参数起始offset获取待消费的消息索引信息,再从commitLog中获取具体的消息内容返回给consumer。在这个过程中,consumer提交的offset为本次请求的起始消费位置,即beginOffset;consume Queue中的offset定位了commitLog中具体消息的位置。
consume Queue中每个消息索引信息长度为20bytes,包括8位长度的offset,记录commitLog中消息内容的位移;4位长度的size,记录具体消息内容的长度;8位长度的tagHashCode,记录消息的tag的哈希值(订阅时如果指定tag,会根据HashCode快速查找订阅的消息)
nextBeginOffset
对于consumer的消费请求处理(PullMessageProcessor.processRequest()),除了待消费的消息内容,broker在responseHeader(PullMessageResponseHeader)附带上当前消费队列的最小offset(minOffset)、最大offset(maxOffset)、及下次拉取的起始offset(nextBeginOffset)。
- minOffset、maxOffset是当前消费队列consumeQueue记录的最小及最大的offset信息。
- nextBeginOffset是consumer下次拉取消息的offset信息,即consumer对该consumeQueue的消费进度。
其中nextBeginOffset是consumer在下一轮消息拉取时offset的重要依据,无论当次拉取的消息消费是否正常,nextBeginOffset都不会回滚,这是因为rocketMQ对消费异常的消息的处理是将消息重新发回broker端的重试队列(会为每个topic创建一个重试队列,以%RERTY%开头),达到重试时间后将消息投递到重试队列中进行消费重试。对消费异常的处理不是通过offset回滚,这使得客户端简化了offset的管理。
client端
offset初始化
consumer启动过程中(Consumer主函数默认调用DefaultMQPushConsumer.start()方法)根据MessageModel选择对应的offsetStore,然后调用offsetStore.load()对offset进行加载,LocalFileOffsetStore是对本地文件的加载,而RemotebrokerOffsetStore是没有本地文件的,因此load()方法没有实现。在rebalance完成对messageQueue的分配之后会对messageQueue对应的消费位置offset进行更新。
/** RebalanceImpl */
/**
doRebalance() -> rebalanceByTopic() -> updateProcessQueueTableInRebalance()
-> computePullFromWhere()
*/
private boolean updateProcessQueueTableInRebalance(final String topic, final Set mqSet,
final boolean isOrder) {
// (省略部分代码)负载均衡获取当前consumer负责的消息队列后对processQueue进行筛选,删除processQueue不必要的messageQueue
// 获取topic下consumer消息拉取列表,List
List pullRequestList = new ArrayList();
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;
}
// 删除messageQueue旧的offset信息
this.removeDirtyOffset(mq);
ProcessQueue pq = new ProcessQueue();
// 获取nextOffset,即更新当前messageQueue对应请求的offset
long nextOffset = this.computePullFromWhere(mq);
if (nextOffset >= 0) {
ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq);
if (pre != null) {
log.info("doRebalance, {}, mq already exists, {}", consumerGroup, mq);
} else {
log.info("doRebalance, {}, add a new mq, {}", consumerGroup, mq);
PullRequest pullRequest = new PullRequest();
pullRequest.setConsumerGroup(consumerGroup);
pullRequest.setNextOffset(nextOffset);
pullRequest.setMessageQueue(mq);
pullRequest.setProcessQueue(pq);
pullRequestList.add(pullRequest);
changed = true;
}
} else {
log.warn("doRebalance, {}, add new mq failed, {}", consumerGroup, mq);
}
}
}
}
Push模式下,computePullFromWhere()方法的实现类为RebalancePushImpl.class。根据配置信息consumeFromWhere进行不同的操作。ConsumeFromWhere的类型枚举如下,其中有三个已经被标记为Deprecated(基于rocketmq-all 4.6.0版本)
public enum ConsumeFromWhere {
CONSUME_FROM_LAST_OFFSET,
@Deprecated
CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST,
@Deprecated
CONSUME_FROM_MIN_OFFSET,
@Deprecated
CONSUME_FROM_MAX_OFFSET,
CONSUME_FROM_FIRST_OFFSET,
CONSUME_FROM_TIMESTAMP,
}
- CONSUME_FROM_LAST_OFFSET
从最新的offset开始消费。
获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费;如果lastOffset小于0说明是first start,没有offset信息,topic为重试topic时从0开始消费,否则请求获取该消息队列对应的消费队列consumeQueue的最大offset(maxOffset),从maxOffset开始消费
- CONSUME_FROM_FIRST_OFFSET
从第一个offset开始消费。
获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费;
否则从0开始消费。
- CONSUME_FROM_TIMESTAMP
获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费;
当lastOffset<0,如果为重试topic,获取consumeQueue的最大offset;否则获取ConsumeTimestamp(consumer启动时间),根据时间戳请求查找offset。
上述三种消费位置的设置流程有一个共同点,都请求获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset不小于0,则从lastOffset开始消费。这也是有时候设置了CONSUME_FROM_FIRST_OFFSET却不是从0开始重新消费的原因,rocketMQ减少了由于配置原因造成的重复消费。
对于lastOffset、maxOffset、时间戳查找offset都是通过MQClientAPIImpl提供的接口进行查询的,MQClientAPIImplclient对broker请求的封装类,使用Netty进行异步请求,对应的RequestCode分别为RequestCode.QUERY_CONSUMER_OFFSET、RequestCode.GET_MAX_OFFSET、RequestCode.SEARCH_OFFSET_BY_TIMESTAMP。
/** RebalancePushImpl */
public long computePullFromWhere(MessageQueue mq) {
long result = -1;
final ConsumeFromWhere consumeFromWhere = this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeFromWhere();
final OffsetStore offsetStore = this.defaultMQPushConsumerImpl.getOffsetStore();
switch (consumeFromWhere) {
case CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST:
case CONSUME_FROM_MIN_OFFSET:
case CONSUME_FROM_MAX_OFFSET:
case CONSUME_FROM_LAST_OFFSET: {
// 从broker获取当前消费队列offset
long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
if (lastOffset >= 0) {
result = lastOffset;
}
// First start,no offset
else if (-1 == lastOffset) {
if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
result = 0L;
} else {
try {
// 获取消费队列最大offset
result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);
} catch (MQClientException e) {
result = -1;
}
}
} else {
result = -1;
}
break;
}
case CONSUME_FROM_FIRST_OFFSET: {
// 先查询当前消费队列消费进度
long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
if (lastOffset >= 0) {
result = lastOffset;
}
// 当前消费队列消费进度小于0,则从0开始
else if (-1 == lastOffset) {
result = 0L;
} else {
result = -1;
}
break;
}
case CONSUME_FROM_TIMESTAMP: {
// 同样也是先查询当前消费队列消费进度
long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
if (lastOffset >= 0) {
result = lastOffset;
} else if (-1 == lastOffset) {
if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
try {
result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);
} catch (MQClientException e) {
result = -1;
}
} else {
try {
// 获取consumer启动时间
long timestamp = UtilAll.parseDate(this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeTimestamp(),
UtilAll.YYYYMMDDHHMMSS).getTime();
// 根据时间戳获取offset信息
result = this.mQClientFactory.getMQAdminImpl().searchOffset(mq, timestamp);
} catch (MQClientException e) {
result = -1;
}
}
} else {
result = -1;
}
break;
}
default:
break;
}
return result;
}
offset提交更新
consumer从broker拉取消息后,会将消息的扩展信息MessageExt存放到ProcessQueue的属性TreeMap
首先是调用ProcessQueue.removeMessage()方法,将已经消费完成的消息从msgTreeMap中根据queueOffset移除,然后判断当前msgTreeMap是否为空,不为空则返回当前msgTreeMap第一个元素,即offset最小的元素,否则返回-1。
如果removeMessage()返回的offset大于0,则更新到offsetTable中。offsetTable的结构为ConcurrentMap
/** ConsumeMessageConcurrentlyService.class */
public void processConsumeResult(
final ConsumeConcurrentlyStatus status,final ConsumeConcurrentlyContext context,final ConsumeRequest consumeRequest) {
// .... (省略部分代码)根据消费结果判断是否需要发回broker重试
// 在msgTreeMap中删除msg,标记当前消息已被消费,msgTreeMap不为空返回当前msgTreeMap中最小的offset
long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs());
// 更新offsetTable中的消费位移,offsetTable记录每个messageQueue的消费进度
// updateOffset()的最后一个参数increaseOnly为true,表示单调增加,新值要大于旧值
if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(consumeRequest.getMessageQueue(), offset, true);
}
}
/** ProcessQueue.class */
public long removeMessage(final List msgs) {
long result = -1;
final long now = System.currentTimeMillis();
try {
this.lockTreeMap.writeLock().lockInterruptibly();
this.lastConsumeTimestamp = now;
try {
if (!msgTreeMap.isEmpty()) {
result = this.queueOffsetMax + 1;
int removedCnt = 0;
// // 从msgTreeMap中删除该批次的msg
for (MessageExt msg : msgs) {
MessageExt prev = msgTreeMap.remove(msg.getQueueOffset());
if (prev != null) {
removedCnt--;
msgSize.addAndGet(0 - msg.getBody().length);
}
}
msgCount.addAndGet(removedCnt);
// 删除后当前msgTreeMap不为空,返回第一个元素,即最小的offset
if (!msgTreeMap.isEmpty()) {
result = msgTreeMap.firstKey();
}
}
} finally {
this.lockTreeMap.writeLock().unlock();
}
} catch (Throwable t) {
log.error("removeMessage exception", t);
}
return result;
}
/** RemoteBrokerOffsetStore */
public void updateOffset(MessageQueue mq, long offset, boolean increaseOnly) {
if (mq != null) {
AtomicLong offsetOld = this.offsetTable.get(mq);
if (null == offsetOld) {
// offsetTable中不存在mq对应的记录
// putIfAbsent 如果传入key对应的value已存在,则返回存在的value,不替换;如果不存在,则新增,返回null
offsetOld = this.offsetTable.putIfAbsent(mq, new AtomicLong(offset));
}
// offsetTable存在记录,替换,这里increaseOnly为true,offsetOld
到这里一条消息的消费流程已经结束,offset更新到了本地缓存offsetTable,而将offset上传到broker是由定时任务执行的。MQClientInstance.start()会启动客户端相关的定时任务,包括NameService通信、offset提交等。
/** MQClientInstance.startScheduledTask() */
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
// 提交offset至broker
MQClientInstance.this.persistAllConsumerOffset();
} catch (Exception e) {
log.error("ScheduledTask persistAllConsumerOffset exception", e);
}
}
}, 1000 * 10, this.clientConfig.getPersistConsumerOffsetInterval(), TimeUnit.MILLISECONDS);
LocalFileOffsetStore模式下,将offset信息转化成json保存到本地文件中;RemoteBrokerOffsetStore则offsetTable将需要提交的MessageQueue的offset信息通过MQClientAPIImpl提供的接口updateConsumerOffsetOneway()提交到broker进行持久化存储。
另一种情况,当应用正常关闭时,consumer的shutdown()方法会主动触发一次持久化offset到broker的操作。
client对offset的更新是在消息消费完成后将offset更新到offsetTable,再由定时任务进行持久化。这个过程有需要注意的地方。
- 由于是先消费再更新offset,因此存在消费完成后更新offset失败,但这种情况出现的概率比较低,更新offset只是写到缓存中,是一个简单的内存操作,出错的可能性较低。
- 由于offset先存到内存中,再由定时任务每隔10s提交一次,存在丢失的风险,比如当前client宕机等,从而导致更新后的offset没有提交到broker,再次负载时会重复消费。因此consumer的消费业务逻辑需要保证幂等性。
并发消费时offset的更新
问题:consumer从broker拉取的待消费消息时批量的(默认情况下pullBatchSize=32),并发消费时,offset的更新不是按大小顺序的,比如拉取消息m1到m10,m1可能是最后消费完成的,那提交的offset的正确性如何保证?m10 offset的更新不会导致m1会误认为已消费完成。
上一小节提到消费完成后,会将线程消费的批次消息从msgTreeMap中删除,并返回当前msgTreeMap的第一个元素,也就是拉取批次最小的offset,offsetTable更新的offset一直会是拉取批次中未消费的最小的offset值。也就是m1未消费完成,m10消费完成的情况下,更新到offsetTable的当前messageQueue的消费进度为m1对应的offset值。
因此,offsetTable中存放的可能不是messageQueue真正消费的offset的最大值,但是consumer拉取消息时使用的是上一次拉取请求返回的nextBeginOffset,并不是依据offsetTable,正常情况下不会重复拉取数据。当发生宕机等异常时,与offsetTable未提交宕机异常一样,需要通过业务流程来保证幂等性。业务流程的幂等性是rocketMQ一直强调的。