RocketMQ5源码(七)分层存储

前言

本文基于rocketmq5.2.0,分析5.x新特性-分层存储Tiered Store)。

分层存储,简单来说是将原来存储在本地磁盘的数据文件,存储到其他外部设备中,后文简称原来的数据文件(commitlog、consumequeue、index等)为主存

相关历史文章参考:

  1. rocketmq4.x普通消息发送(主存流程和格式,包括commitlog、consumequeue、index);

另外,写完这一篇,我可能要停笔一阵子了,找工作去了,看八股文想yue。

5.2版本新特性

1、分层存储-Index存储重构

ISSUE-7545(RIP-65)。

5.2版本对分层存储的Index索引存储进行了重构,所以本文不基于5.1.1分析。

2、引入Rocksdb存储部分数据

ISSUE-7064(RIP-66)。

问题:config目录下的配置,有一个变化就需要全量同步persist持久化

举例,如更新topic配置,只变更一个topic ,就需要全量持久化整个topics.json(所有topic配置)。

如果topic数量巨多,内存和io压力就大。

5.2版本基于rocksdb存储可以改善这个问题。

不过默认broker配置还是采用老方式(default),如果要使用rocksdb,需要配置storeType=defaultRocksDB。

此外,rocksdb存储不仅仅针对config目录下的配置,还支持ConsumeQueue存储,不深入分析。

3、Controller采用sofa-jraft代替DLedger

ISSUE-7300(RIP-67)。

之前分析过Controller的作用,就是在master-slave模式下支持自动提升slave为master,见RocketMQ5源码(二)controller模式

controller依赖raft,在5.2中raft实现使用sofa-jraft代替了dledger。

sofa-jraft在nacos里也有用到,见Nacos源码(三)Sofa-JRaftNacos源码(四)1.4.1配置中心-Derby集群模式

不过同样的,默认controller还是会采用controllerType=DLedger

背景

要解决什么问题

在[RIP-57]中提到,RocketMQ所有消息都按照append-only的方式存储在本地磁盘的CommitLog。

虽然这种存储模式能带来很高的吞吐量,但是也面临一些问题:

  1. 消息仅支持固定保留时间(fileReservedTime),不支持Topic级别TTL;
  2. MessageStore仅支持同步读消息的api,当消息不在pagecache时(冷读),工作线程会阻塞读,然后堵住其他请求;
  3. topic分区与broker绑定,无法迁移,想在减少集群大小时不丢失历史数据;

总的来说,分层存储就是将消息数据按照一定条件同步到另一个地方去

rocketmq提供接口定义(TieredFileSegment),业务方按照自己的需求实现存储接口,比如使用OSS存储。

Kafka分层存储

Kafka 高级版本也支持分层存储,只不过官方不建议用于生产环境 ,仅仅作为预览特性

broker使用分层存储demo配置:

  1. remote.log.storage.system.enable:开启分层存储;
  2. remote.log.storage.manager.class.name远程存储实现类
  3. remote.log.storage.manager.class.path:远程存储实现类classpath;
  4. remote.log.metadata.manager.class.name:元数据存储实现类,内置demo实现TopicBasedRemoteLogMetadataManager;
  5. rsm.config.dir:远程存储相关配置,这里LocalTieredStorage将数据存储到当前文件系统/tmp/kafka-remote-storage;
ini 复制代码
remote.log.storage.system.enable=true
remote.log.storage.manager.class.name=org.apache.kafka.server.log.remote.storage.LocalTieredStorage
remote.log.storage.manager.class.path=/PATH/TO/kafka-storage-x.x.x-test.jar
remote.log.metadata.manager.class.name=org.apache.kafka.server.log.remote.metadata.storage.TopicBasedRemoteLogMetadataManager
rsm.config.dir=/tmp/kafka-remote-storage

kafka开启分层存储,需要在topic纬度配置,具体参数解释见官网。

arduino 复制代码
bin/kafka-topics.sh --create --topic tieredTopic \
--bootstrap-server localhost:9092 \
--config remote.storage.enable=true \
--config local.retention.ms=1000 --config retention.ms=3600000 \
--config segment.bytes=1048576 --config file.delete.delay.ms=1000

producer发送n条消息,触发segment.bytes阈值,/tmp/kafka-remote-storage中产生多个数据文件。

一、使用案例

1、Broker配置

使用分层存储,只需要更改broker配置。

ini 复制代码
brokerClusterName=DefaultCluster
brokerName=broker-a
brokerId=0
deleteWhen=04
fileReservedTime=48
brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
namesrvAddr=127.0.0.1:9876
# 主存
storePathRootDir=/data/rmq_data/broker-a
storePathCommitLog=/data/rmq_data/broker-a/commitlog
# 分层存储
messageStorePlugIn=org.apache.rocketmq.tieredstore.TieredMessageStore
tieredMetadataServiceProvider=org.apache.rocketmq.tieredstore.metadata.TieredMetadataManager
tieredBackendServiceProvider=org.apache.rocketmq.tieredstore.provider.posix.PosixFileSegment
tieredStoreFilePath=/data/rmq_data/broker-a-tieredstore
  1. messageStorePlugIn :开启MessageStore插件TieredMessageStore是分层存储插件;(类比kafka的remote.log.storage.system.enable)
  2. tieredMetadataServiceProvider分层存储-元数据存储TieredMetadataStore 实现类,官方实现TieredMetadataManager ,默认将元数据存储在 {storePathRootDir}/config/tieredStoreMetadata.json;(类比kafka的remote.log.metadata.manager.class.name
  3. tieredBackendServiceProvider分层存储-消息数据存储TieredFileSegment 实现类,官方实现demo -PosixFileSegment ,存储到当前文件系统的tieredStoreFilePath指定路径下;(类比kafka的remote.log.storage.manager.class.name

使用producer发送消息,观察数据变化。

2、消息数据

消息数据如何组织在于TieredFileSegment实现 ,这里还是以PosixFileSegment实现为例。

tieredStoreFilePath 路径下可以看到另一份消息数据 ,按照Cluster/Broker/Topic/queueId/数据类型/数据文件的方式存储。

数据类型有三种:

  1. COMMITLOG存储Message消息;
  2. CONSUME_QUEUE存储消费队列consumequeue;
  3. INDEX存储查询索引;

对于COMMIT_LOG,区别于主存:

主存将所有topic和queue的Message都混合存储在一个定长滚动的commitlog中,

而分层存储后和consumequeue一样,按照topic+queue纬度分片存储。

对于CONSUME_QUEUE,和主存目录结构类似:

对于INDEX,实现方式与前两者不同,所以在这里不能立即看到。

3、元数据

元数据 {storePathRootDir}/config/tieredStoreMetadata.json

  1. xxxSegmentTable:数据文件的元数据;
  2. queueMetadataTable:MessageQueue的元数据;
  3. topicMetadataTable:Topic的元数据;

数据文件的元数据,包含比如路径、初始偏移量、各种时间、状态、大小等等。

MessageQueue的元数据,包含broker、topic、queueId、最大最小偏移量。

二、组件概览

1、MessageStore

MessageStore 是rocketmq-store模块的核心api,负责读写消息

BrokerController#initializeMessageStore:broker启动创建MessageStore。

默认存储实现是DefaultMessageStore,但是支持MessageStoreFactory包装DefaultMessageStore

MessageStoreFactory 支持根据messageStorePlugIn 配置创建扩展MessageStore实现(AbstractPluginMessageStore ),包装原始MessageStore

MessageStore扩展实现都持有内层MessageStore(next) ,所以外层可以有选择性的调用底层DefaultMessageStore。

比如什么时候从底层DefaultMessageStore读消息,什么时候从当前MessageStore读消息。

2、TieredMessageStore(分层存储)

分层存储实现TieredMessageStore就是基于这种方式,包装了DefaultMessageStore。

TieredMessageStore有四个重要组件:

  1. TieredDispatcher :分层存储数据写入的入口
  2. TieredMessageFetcher:负责处理读请求;
  3. TieredMetadataStore:实现元数据存储,默认实现TieredMetadataManager;
  4. TieredFlatFileManager :负责实际消息数据存储

3、TieredDispatcher(写)

分层存储的写入,不是由文件过期清理触发,也不是由MessageStore写消息直接触发。

TieredDispatcher 实现CommitLogDispatcher

CommitLogDispatcher 的作用是,在消息写入后,由DefaultMessageStore的reput线程触发回调

(见DefaultMessageStore.ReputMessageService#doReput

DefaultMessageStore在构造时会加入几个Dispatcher,

比如CommitLogDispatcherBuildConsumeQueue 用于构造consumequeue,只有消息写入consumequeue才对消费者可见。

broker消息接收线程,收到消息后,写commitlog(pagecache、刷盘、ha复制);

DefaultMessageStore#doDispatch:

reput线程(ReputMessageService),感知到commitlog的物理offset变大。

封装一个DispatchRequest,循环所有CommitLogDispatcher执行dispatch。

由于TieredMessageStore在构造时将TieredDispatcher注入了DefaultMessageStore,所以会收到消息写入回调。

4、TieredFlatFileManager(实际数据)

TieredFlatFileManager 管理所有实际消息数据

分层存储基于queue纬度管理消息数据 ,每个MessageQueue 对应一个CompositeQueueFlatFile

MessageQueue还是common包下的,broker+topic+queueId。

CompositeQueueFlatFile包含:队列元数据和三个数据服务。

IndexStoreService作为Index存储服务是全局单例,与commitlog和consumequeue不同。这个后面单独看。

无论是commitlog还是consumequeue,最终都会由一个TieredFlatFile管理实际存储。

TieredFlatFile 管理n个TieredFileSegment存储数据文件。

TieredFileSegment正是用户需要实现的存储实现,比如demo的PosixFileSegment存储在本地文件系统。

所以分层存储的数据文件组织结构大概就是这样。

TieredFlatFile 类似于主存中的MappedFileQueue,用于管理n个定长文件,对上层屏蔽操作多个文件的细节。

TieredCommitLog 类似于主存中的CommitLog ,暴露commitlog相关api,用TieredFlatFile做底层存储。

5、TieredMessageFetcher(读)

TieredMessageFetcher负责处理读请求。

并非单纯调用底层存储TieredFlatFileManager ,还有一层所谓readAheadCache本地缓存。

6、TieredMetadataManager(元数据)

TieredMetadataManager是默认的元数据管理实现。

继承ConfigManager ,所以会将数据都写入主存 {storePathRootDir}/config/tieredStoreMetadata.json

元数据主要用于描述两类数据:

  1. 分层存储需要用到的queue和topic的额外属性,比如topic级别的保留时间(虽然现在没有实现);
  2. TieredFileSegment的路径和元数据;

TieredFlatFileManager#recoverTieredFlatFile:

元数据的作用很多,以启动为例。

通过元数据服务能够迭代所有topic下的queue,重新恢复所有queue对应CompositeQueueFlatFile

三、读-拉消息

1、Default or Tiered

TieredMessageStore#getMessageAsync:存储层,拉消息方法入口,决定是否走分层存储。

  1. 如果是系统topic走主存,比如:pop消费产生的reviveTopic消息、定时topic消息等等;
  2. fetchFromCurrentStore根据配置项tieredStorageLevel决定是否走主存;
  3. 查询分层存储,如果分层存储文件不存在查询异常降级走主存

TieredMessageStore#fetchFromCurrentStore:

如果tieredStorageLevel=FORCE,强制走分层,这可以方便debug;

如果tieredStorageLevel=DISABLE,强制走主存;

如果queue对应分层存储文件不存在,或查询offset超出分层consumequeue的写入进度(分层存储写入延迟) ,走主存。

在分层存储存在的前提下,根据level和主存的关系决定。

level=NOT_IN_DISK(默认),offset不在主存磁盘上,走分层存储

level=NOT_IN_MEM,offset不在主存pagecache里(内存),走分层存储;

其他走主存。

2、读缓存

TieredMessageFetcher会用caffeine维护一份缓存:

  1. 写后10s过期
  2. 基于内存考虑的容量限制,最大容量=0.3(readAheadCacheSizeThresholdRate)*最大堆内存;
  3. recordStats开启metrics统计;

缓存key,MessageCacheKey=分层存储文件(broker+topic+queueId)+offset。

缓存value,即查询出来的单条消息。

TieredMessageFetcher#getMessageAsync:根据队列定位到分层存储文件,进入分层存储查询逻辑。

TieredMessageFetcher读逻辑和Cache Aside类似。

只不过因为拉消息一般是连续有序 的,即基于offset递增,可以引入prefetch预读

比如客户端基于queueOffset=100,maxCount=32拉消息,当缓存命中时可以继续向后再拉n条。

TieredMessageFetcher#getMessageFromCacheAsync:

Step1,优先从cache中获取n条消息

TieredMessageFetcher#getMessageFromCacheAsync:

Step2,如果cache miss(list is empty) ,一条消息都没命中,且预读线程正在处理这个offset(future.isDone=false)

等预读结束后,再次发起getMessageFromCacheAsync流程。

TieredMessageFetcher#getMessageFromCacheAsync:

Step3,针对Step2预读完成(future.isDone)的情况,再次尝试从缓存中读取。

TieredMessageFetcher#getMessageFromCacheAsync:

Step4,针对缓存命中的情况(读到一条就算命中),组装结果返回。

此外,如果这批消息最后一条之后还有未读消息 (lastGetOffset < result.getMaxOffset),发起预读

TieredMessageFetcher#getMessageFromCacheAsync:

Step5,针对缓存未命中的情况。

基于2倍(readAheadMinFactor)maxCount(客户端批量拉消息条数,比如32)数量,

调用底层分层存储查询消息,最终将数据放入cache。

因为基于2倍数量拉消息,也算一次小预读,所以放入一个future。

(见Step2,遇到future未完成,可以等future完成后再次读可以命中预读到的缓存)

3、读Tiered Store

TieredMessageFetcher#getMessageFromTieredStoreAsync:

  1. 读consumequeue n条;
  2. 根据consumequeue批量读commitlog n条;
  3. 结合consumequeue和commitlog,组装查询结果n条;

读consumequeue

再回顾一下consumequeue记录的格式。

每条consumequeue记录对应一条commitlog消息定长20byte

  1. 8byte代表commitlog的物理offset;
  2. 4byte代表消息长度;
  3. 8byte一般情况下是tag的hashcode,特殊情况如定时消息是目标投递时间;

通过查询逻辑offset 乘以定长20,可以轻松找到一条consumequeue记录。

CompositeFlatFile#getConsumeQueueAsync:

读取位置offset=逻辑offset乘以20,

length读取长度=数量乘以20。

TieredConsumeQueue#readAsync:consumequeue操作底层TieredFlatFile读数据。

这个后面看,是统一存储的api。

读commitlog

和传统拉消息的重要不同点在于。

传统主存 只有consumequeue是按照queue分片存储的,commitlog是所有topic和queue混合存储的一个逻辑文件,拉消息必须循环offset,一个一个从commitlog读消息。

分层存储的消息数据可以按照queue分片存储(和kafka那种一样,当然取决于用户Segement实现),即根据队列offset拉取n条消息,可以批量从commitlog读。

假设topic=A,queueId=1,queueOffset =100,batchSize=2拉取两条消息。

传统拉消息,读consumequeue可以读连续40byte,但是读commitlog只能分两次随机读

分层存储拉消息,读commitlog可以批量读,因为commitlog也可以和consumequeue一样,在queue纬度分片存储。

所以读取commitlog的入参和读取consumequeue的入参逻辑一致,读取位置=第一个物理offset读取长度=所有消息长度和(即最后一个物理offset-第一个物理offset+最后一条消息长度)

TieredMessageFetcher#getMessageFromTieredStoreAsync:读commitlog。

计算逻辑和上面描述一致,除此之外,批量读commitlog还做了内存控制。

截断读取长度,不能超过readAheadMessageSizeThreshold(128M),防止oom,但至少读取一条消息。

CompositeFlatFile#getCommitLogAsync:和consumequeue完全一致,读底层TieredFlatFile。

组装结果

TieredMessageFetcher#getMessageFromTieredStoreAsync:

根据consumequeue和commitlog两段buffer,可以组装结果集,这个容易理解,忽略了。

4、读TieredFlatFile

和主存MappedFileQueue类似。

TieredFlatFile#readAsync:

  1. getSegmentIndexByOffset,根据offset找TieredFileSegment采用二分
  2. TieredFileSegment#readAsync,传入offset读取长度

比如offset=67109000,length=20。

Step1,根据offset=67109000,二分定位到segement[1],

Step2,TieredFileSegment#readAsync,传入offset=67109000-67108864=136,length=20。

TieredFileSegment#readAsync:确保offset和length不溢出,调用底层read0。

PosixFileSegment#read0:

官方案例PosixFileSegment,从本地文件读取position位置开始length长度buffer。

注意,这里没有用独立线程,所以处理线程任然是broker拉消息请求线程

5、prefetch预读

TieredMessageFetcher#getMessageFromCacheAsync:

对于caffeine缓存命中的情况,会发起prefetch预读。

TieredMessageFetcher#prefetchMessage:触发预读的限制条件包括:

  1. 拉消息数量超过1条;
  2. readAheadFactor大于1,这个值在初始化为2后,会在CompositeQueueFlatFile纬度动态调整;
  3. inflightRequest isAllDone,代表消费组的上一次预读已经完成(或者预读offset->offset+size区间无重叠,或者未发生预读,具体见getInflightRequest,不赘述);

TieredMessageFetcher#prefetchMessage:

调整readAheadFactor,

如果缓存miss,readAheadFactor-1,减少预读数量,从当前请求offset开始,

如果缓存hit,readAheadFactor+1,增加预读数量,从上一次预读offset之后开始。

TieredMessageFetcher#prefetchMessage:

最终根据各种配置参数,决定分x批,每批拉y条消息,放入缓存。

TieredMessageFetcher#prefetchMessageThenPutToCache:和普通拉消息一致。

需要注意,预读线程从头到尾都会使用处理拉消息请求线程

底层TieredFileSegment的read0必须切换为自己的io线程组,否则一旦进入分层存储,触发预读将导致拉消息延迟。

(案例中的PosixFileSegment的read0未切换线程不可取)

四、写

1、概览

TieredStore写大致由4组线程协作处理。

Step1,实时转储commitlog:

写消息请求处理线程将commitlog写入后,触发reput线程dispatch逻辑。

TieredDispatcher尝试根据offset读取主存commitlog并写入分层存储,写入成功后将DispatchRequest缓存到一个DispatchRequestWriteMap。

Step2,定时转储commitlog:

TieredDispatcher后台会定时扫描所有TieredFlatFile,逻辑同Step1。

Step3,转储ConsumeQueue和Index:

每隔1秒将writeMap倒入readMap,TieredDispatcher单线程消费readMap,构造consumequeue和index。

2、实时转储commitlog

主存commitlog、consumequeue、index处理完毕后,进入分层存储逻辑。

TieredDispatcher#dispatch:reput线程。

写commitlog也和读一样会过滤系统topic,忽略。

getOrCreateFlatFileIfAbsent ,reput往往是创建分层存储FlatFile的入口 ,同时底层也会将新创建的FlatFile元数据写入tieredStoreMetadata.json

detectFallBehind如果readMap/writeMap堆积超过1w条 ,即consumequeue构建慢而发生堆积,直接返回,不构建commitlog,由TieredDispatchExecutor后台线程定时处理;

request.offset==file.dispatchOffset(commitlog写入进度) ,在上面detectFallBehind 这个case处理完毕后,分层存储构建追上消息发送速度 ,可以由reput线程准实时写入二级存储commitlog

TieredDispatcher#dispatch:准实时写入commitlog。

在compositeFlatFileLock的保护下,

查询commitlog并写入分层存储(逻辑后面和consumequeue一起看),最后放入writeMap,供TieredDispatcher线程消费。

3、定时转储commitlog

readMap/writeMap来不及消费构建分层consumequeue,所以分层commitlog写再快也没意义,还会加重read/writeMap堆积占用内存。

reput线程为了不阻塞主流程(写主存consumequeue),选择不写分层commitlog,由后台线程定时扫描所有分层存储文件FlatFile,定时转储commitlog。

TieredDispatcher#initScheduleTask:

TieredDispatcher启动后,开启定时任务,每隔10s扫描所有FlatFile

TieredDispatcher#dispatchFlatFileAsync:每个FlatFile提交一个任务到TieredDispatchExecutor线程组。

TieredDispatcher#dispatchFlatFile:同样,detectFallBehind如果分层consumequeue构建太慢,不做处理。

TieredDispatcher#dispatchFlatFile:

从dispatchOffset(commitlog写入进度)开始持续写入commitlog,一次最多处理2500条消息(tieredStoreGroupCommitCount)。

后续逻辑和实时写逻辑一致,不再赘述。

4、定时转储consumequeue

TieredDispatcher 还是个线程(ServiceThread),用于构建分层存储的consumequeue和index。

实时/定时分层commitlog写入成功后,对应FlatFile会生成新的DispatchRequest,放入writeMap。

TieredDispatcher#run:每隔1s跑一次。

TieredDispatcher#buildConsumeQueueAndIndexFile:

  1. swapDispatchRequestList:将writeMap灌入readMap,单线程消费;
  2. copySurvivorObject:消费完,剩余readMap再灌入writeMap,等下一批处理;

TieredDispatcher#buildConsumeQueueAndIndexFile:

遍历FlatFile,遍历DispatchRequest,写入consumequeue和index。

5、FlatFile写入

commitlog/consumequeue

无论是appendCommitLog还是appendConsumeQueue,其底层都是一样的,只是在commitlog和consumequeue模型上还有一些额外操作。

CompositeFlatFile#appendCommitLog:写commitlog会增加dispatchOffset。

TieredCommitLog#append:commitlog从buffer中可以读到message的写入时间。

CompositeFlatFile#appendConsumeQueue:写consumequeue,校验一下请求offset与offset写入进度一致。

TieredConsumeQueue#append:

将DispachRequest中的属性按照consumequeue格式(20byte)写入buffer,调用底层append。

写入主流程

从整体上来看,无论是commitlog还是consumequeue,都是先写内存buffer,由后台commit线程批量刷盘。

从每个queue的FlatFile纬度来看,要先找到最后一个写入的segment,append先追加写入segment的内存buffer。后台线程扫描这些待刷盘的buffer,执行批量刷盘。

TieredFileSegment

TieredFileSegment辅助用户实现Segment:

  1. 线程安全处理;
  2. segement元数据维护;
  3. 维护写buffer;

用户只需要实现read0、write0对接不同的三方api即可。

java 复制代码
public abstract class TieredFileSegment implements Comparable<TieredFileSegment>, TieredStoreProvider {
    // 文件路径 broker/topic/queue
    protected final String filePath;
    // 当前segment的起始offset
    protected final long baseOffset;
    // commitlog/consumequeue/index
    protected final FileSegmentType fileType;
    protected final TieredMessageStoreConfig storeConfig;
    // segment最大容量
    private final long maxSize;
    private final ReentrantLock bufferLock = new ReentrantLock();
    private final Semaphore commitLock = new Semaphore(1);
    // segment是否写满
    private volatile boolean full = false;
    private volatile boolean closed = false;
    // segment中最小的存储时间
    private volatile long minTimestamp = Long.MAX_VALUE;
    // segment中最大的存储时间
    private volatile long maxTimestamp = Long.MAX_VALUE;
    // 刷盘进度
    private volatile long commitPosition = 0L;
    // append进度
    private volatile long appendPosition = 0L;
    // commitlog写入物理offset对应逻辑offset
    private volatile long dispatchCommitOffset = 0L;
    // 用于commitlog,标识一个segment的结束
    private ByteBuffer codaBuffer;
    // 待刷盘buffer
    private List<ByteBuffer> bufferList = new ArrayList<>();
    private FileSegmentInputStream fileSegmentInputStream;
    // 刷盘future
    private CompletableFuture<Boolean> flightCommitRequest = CompletableFuture.completedFuture(false);
}

文件大小

对于不同的文件类型,有不同的文件容量控制。默认commitlog-1g,consumequeue-100m,index-无限。

baseOffset

一个FlatFile中有多个segement,baseOffset是当前segment在整个FlatFile中的物理偏移量。

position

segment一般情况下不是同步刷盘(调用用户实现commit0),有两个position状态需要维护。

appendPosition是写入内存进度,commitPosition是刷盘进度。

bufferList

写内存就是将buffer加入bufferList链表。

append

TieredFlatFile#append:

  1. getFileToWrite,找到写入segment;
  2. segment#append,写入buffer;
  3. 如果buffer满了(BUFFER_FULL),且需要commit(只有定时转储commitlog需要commit),同步等待一次commit,再次append;
  4. 如果文件写满了,getFileToWrite执行滚动,append到下一个segment;

找可写入segment

TieredFlatFile#getFileToWrite:找到可写入的segment。

Case1,返回最后一个segment。(通过元数据,启动阶段就可以加载所有segment)

Case2,最后一个segment写满了,需要同步commit刷盘并更新segment元数据,创建新segment;

Case3,segment不存在,即queue还没有消息,直接创建新的segment;

所有新创建的segment都加入待刷盘segment列表。

segment append

TieredFileSegment#append:

  1. 如果文件满了,即appendPosition+buffer大小超出maxSize,返回FILE_FULL,由外部FlatFile处理滚动;
  2. 如果buffer过大 (bufferList.size超过2500,待刷盘buffer超出32MB),即还未来得及定时刷盘,这里触发一次异步刷盘
  3. bufferList.size超出1w,流控,不可append,返回BUFFER_FULL;

TieredFileSegment#append:正常情况,更新时间戳信息,增加append进度,复制buffer加入bufferList。

commit

TieredFlatFileManager#doScheduleTask:每隔60s,common线程组调度一次刷盘任务

此时才能真正将数据从内存buffer写入外部存储,所以从写入数据到从外部存储看到数据至少要经过60s。

TieredFlatFileManager#doCommit:common线程组遍历所有FlatFile,每个FlatFile提交commitlog和consumequeue刷盘任务到commit线程组。

TieredFlatFile#commit:无论是commitlog还是consumequeue,底层都走FlatFile刷盘。

遍历所有待刷盘segment,执行commit,最后更新segment的元数据。

TieredFileSegment#commitAsync:

Step1,将bufferList转换为一个InputStream,对用户屏蔽多个buffer的处理。

TieredFileSegment#commitAsync:

Step2,调用用户实现commit0,传入InputStream,当前刷盘进度,buffer总长度;

Step3,处理用户commit0结果,如果刷盘成功,更新commit刷盘位点,如果刷盘失败,回滚InputStream;

Step4,commit0抛出异常,走异常处理;(比如调用oss服务异常怎么处理)。

PosixFileSegment#commit0:官方Segment案例,commit0写入本地磁盘,注意使用了独立线程池。

TieredFileSegment#handleCommitException:

如果commit0发生异常,需要确定本次commit到底最终是否成功,比如网络超时等情况,怎么处理。

本质上,这里的实现是根据当前segment文件大小来判断的。

TieredFileSegment#getCorrectFileSize:用户有两种方式告诉上层到底刷盘进度是多少

如果commit0抛出TieredStoreException,从异常返回position获取文件大小;

否则走子类实现getSize获取文件大小。

TieredFileSegment#correctPosition:无论如何,最终刷盘进度都会设置为文件大小。

只不过,如果刷盘进度+stream中buffer长度=文件大小,认为刷盘成功,返回true,清空stream;

否则,认为刷盘失败,返回false,回滚stream;

总得来说,要确保刷盘位点正确,完全依赖于文件大小,其实未必可靠。

五、分层存储过期清理

1、定时清理

TieredFlatFileManager#doScheduleTask:

每隔30s,common线程组调度发起分层存储过期文件清理。

TieredFlatFileManager#doCleanExpiredFile:

保留时间默认72小时(tieredStoreFileReservedTime)

每个FlatFile提交到TieredCleanFileExecutor线程池处理。

TieredFlatFileManager#doCleanExpiredFile:

清理分为两步,cleanExpiredFile 先更新过期segment元数据,destroyExpiredFile删除过期segment。

CompositeFlatFile#cleanExpiredFile/destroyExpiredFile:

每步又分成两步,先处理commitlog再处理consumequeue。

2、元数据

commitlog和consumequeue更新元数据逻辑基本一致。

TieredFlatFile#cleanExpiredFile:

Step1,循环所有segment的元数据,根据segment中的最大存储时间来判断是否过期,将过期segment的baseOffset加入集合。

TieredFlatFile#cleanExpiredFile:

Step2,将所有过期segment的元数据标记为closed并持久化。

TieredFlatFile#recoverMetadata:

一旦元数据被标记为closed,即status=STATUS_DELETED(2),就无法在重启后恢复,即使未真实删除segment。

3、数据

TieredFlatFile#destroyExpiredFile:commitlog和consumequeue都一样,迭代所有被标记为DELETED的segment元数据,执行用户实现的destroyFile方法,如果删除成功,删除对应元数据。

TieredCommitLog#destroyExpiredFile:对于commitlog还有一个滚动逻辑

如果当前写入的segment的最大写入时间超过24h (commitLogRollingInterval),且文件大小超过128MB(commitLogRollingMinimumSize),执行滚动生成新的segment。

所以commitlog在分层存储中不是定长1G文件

六、Index

1、回顾主存index

Index提供根据时间、消息key、消息id查询消息的能力。

每个Index文件都是一个定长文件 ,以时间戳命名

不会按照queue分片存储,所有topic-queue的数据存储在一个索引文件中,和commitlog一样。

Index文件分为三部分:

  1. Header:文件头,用于描述这个Index文件的元数据信息,包含:1-消息存储时间范围,2-消息commitlog的物理offset范围,3-slot数量,4-index数量;
  2. Slot区域:一个HashTable,每个节点占用4字节,存储索引头节点位置,可以找到一个IndexItem;
  3. Index区域 :实际索引数据,每个IndexItem包含20字节hash-消息key的哈希物理offset-定位commitlog消息 ,timeDiff-与当前索引文件第一个消息存储时间的距离,slotValue-指向下一个IndexItem

索引写入,采用头插法,可顺序写(org.apache.rocketmq.store.index.IndexFile#putKey):

  1. hash(key)%500w定位Slot,得到Slot.slotValue=x;
  2. 基于当前Index区域写入位置,追加写入新IndexItem,新IndexItem.slotValue指向之前的头节点x;
  3. 更新Slot.slotValue=新IndexItem位置;

索引查询,需要随机读(DefaultMessageStore#queryMessage):

  1. hash(key)%500w定位Slot,得到Slot.slotValue=x;
  2. 根据slotValue找到索引IndexItem位置,即header大小+slot区域大小+slotValue乘以20
  3. 根据IndexItem中的slotValue重复第二步(随机读);
  4. 根据2和3拿到所有的物理offset,查询消息;

ISSUE-7545,对于分层存储,在发生hash冲突的情况下(其实根据业务key查询,比如订单号,其实很容易发生冲突),随机读会引发大量io(比如调用oss),响应时间不能保证。

所以5.2分层存储会以新的格式存储Index文件 ,目的就是为了让同一个slot的索引项IndexItem连续存储,让一个slot的多次io变成一次io。

2、概览

IndexFile

对于分层存储,IndexFile有三种状态:

  1. UNSEALED :初始状态,类似主存索引文件格式(顺序写),存储在本地磁盘上,路径为{storePath}/tiered_index_file/{时间戳}:
  2. SEALED :本地Index文件写满,正在或已经生成压缩格式Index文件,还未上传到外部存储,路径为{storePath}/tiered_index_file/compacting/{时间戳}:
  3. UPLOAD已经上传到外部存储,即用户实现的Segment;

总的来说,在分层存储中,Index文件会存在于3个地方,对应3种状态。

Index会在普通index文件写满之后,先压缩,再写入segment,这与commitlog和consumequeue都不同(这两个是近实时写入)。

IndexStoreFile是一个IndexFile的具体实现,在不同状态下维护了不同的文件形式。

重启恢复时(见IndexStoreService#recover):

tiered_index_file路径下的文件,都会以初始index文件方式加载,体现为一个MappedFile(主存的定长文件实现类);

tiered_index_file/compacting路径下的文件,会被删除;

segment外部存储,可以通过元数据恢复。

IndexStoreService

IndexStoreService管理多个索引文件。

TieredFlatFileManager#getTieredIndexService:IndexStoreService是个单例对象

在构造时FlatFile的path固定为broker/rmq_sys_INDEX/0 ,即index在外部存储中不会按照topic-queue分片,不同的segment只有baseOffset的区别。

IndexItem

IndexItem索引项,追加写格式32byte(UNSEALED ),压缩格式28byte(SEALEDUPLOAD)。

压缩格式比追加写格式少了itemIndex,即主存slotValue。

IndexItem除了主存索引项的4个属性之外(20byte),还额外包含了3个属性(12byte):

  1. topicId:在分层存储下,broker为每个topic被分配了一个唯一id ,顺序递增,存储在元数据文件中,topicMetadataTable
  2. queueId:即topic下的queueId,也存储在元数据文件中,queueMetadataTable
  3. size:消息长度;

3、写

TieredDispatcher#buildConsumeQueueAndIndexFile:

TieredDispatcher单线程消费writeMap,先写consumequeue,然后写index。

CompositeQueueFlatFile#appendIndexFile:解析DispatchRequest,调用IndexStoreService

IndexStoreService#putKey:

调用当前IndexFile写入,如果文件写满,滚动到新的IndexFile。

IndexStoreFile#putKey:

当前写入IndexFile一定是处于UNSEALED状态,未压缩在本地磁盘。

如果索引数量超出最大容量(和主存配置一致2kw),状态变更为SEALED等待压缩上传,返回文件已满,让外部IndexStoreService创建新的UNSEALED状态IndexFile。

IndexStoreFile#putKey:循环每个key,写入索引。

逻辑和主存完全一致只是index和header区域的模型有变化

header:去除了offset区间,加了个魔数;

index:加了topicId、queueId和size;

4、过期清理/压缩/上传

IndexStoreService还是一个线程,用于处理Index文件的生命周期。

IndexStoreService#run:

  1. 处理过期segment,默认72h,和commitlog/consumequeue一致,先标记元数据过期,再调用用户segment#destroyFile;
  2. 找到下一个待压缩文件;
  3. 压缩上传;
  4. 更新压缩时间戳;

IndexStoreService#getNextSealedFile:

一般情况下,当本地磁盘tiered_index_file下存在两个及以上UNSEALED文件时,返回第一个文件执行压缩上传。

所以当IndexFile写满发生滚动,才会触发压缩上传

每次压缩上传成功,compactTimestamp会更新为上一次处理的IndexFile。

重启后,tiered_index_file下的第一个文件时间戳-1会作为compactTimestamp重新加载到内存。

IndexStoreService#doCompactThenUploadFile:大体分为三块

  1. 压缩原始IndexFile
  2. 调用用户segment#commit0将压缩buffer整体写入外部存储
  3. 切换table中的IndexFile为已上传的IndexFile删除本地IndexFile

IndexStoreFile#doCompaction:重点在于index文件压缩,压缩的格式决定了后期查询分层存储的io次数。

IndexStoreFile#compactToNewFile:循环处理所有slot,最后写入header。

压缩文件和原始文件相比:

  1. header未变化;
  2. 每个slot从原来的4byte扩大为8byte
  3. index区域只保留实际索引数量,且索引项从32变为28byte。

IndexStoreFile#compactToNewFile:对于每个slot

  1. 迭代slot中所有IndexItem顺序写入buffer,丢弃每个Item中最后4byte不需要的slotValue;
  2. 写入slot的8byte,前4byte还是和原来一样是第一个IndexItem的位置,后4byte是连续IndexItem的长度;

可以想象,通过这种压缩存储方式,查询时就能批量读取一个slot中的连续IndexItem,从而能避免多次io。

5、读

TieredMessageStore#queryMessageAsync:判断是否走分层存储查询

  1. 在force级别下,强制走分层存储查询;
  2. 非force级别下,比较主存首条消息时间和查询区间,分别走主存和分层存储查询,合并后返回;
  3. 主存查询本质是同步api,分层存储查询可异步;

TieredMessageFetcher#queryMessageAsync:

Step1,根据查询条件得到所有索引项

Step2,循环索引项,找到queue对应FlatFile;

Step3,根据索引项的offset和size,读commitlog的segment,得到消息;(如果底层segment#read0采用独立线程池,这边result.addMessage有线程安全问题,我提了ISSUE-7872应该可以修复)

IndexStoreService#queryAsync:根据时间区间范围找到所有IndexFile,执行每个IndexFile查询。

IndexStoreFile#queryAsync:每个IndexFile根据当前状态,选择从不同的文件中读数据。

UPLOAD ,已上传,走segment查询;

SEALED/UNSEALED ,未上传,都走未压缩的tiered_index_file下的文件查询,这个逻辑和主存index类似不用看,就是需要随机读;

IndexStoreFile#queryAsyncFromSegmentFile:已上传IndexFile,调用用户实现segment读取。

  1. 第一次io,hash(key)%500w定位slotPosition读取slotBuffer(8byte)
  2. 第二次io,根据slotBuffer读取连续的IndexBuffer,slotBuffer前4byte是IndexBuffer的开始位置,后4byte是IndexBuffer的长度;
  3. 最后反序列化IndexBuffer,匹配hash和时间,返回;

七、实现一个Segment

借助阿里云oss简单实现一个segment,只是作为补充案例,不要直接用于生产

注:其实这里如何实现方式很多,根据topic使用不同实现也可以,只需要用户实现segment做一层代理即可。

xml 复制代码
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.10.2</version>
</dependency>

1、实现要点

第一点,segment的读写要用独立线程池。

以拉消息为例,在5.xtiered store出现之后,冷读的一种处理方式是提供一套异步api,避免阻塞业务线程。

比如commitlog不在pagecache中,那就需要读本地磁盘产生io操作,这会阻塞业务线程。

如果segment在实现上使用独立线程池,那么pull消息线程就不会被冷读阻塞。

pull消息业务线程只做内存操作,比如各种校验计算、读本地缓存、读pagecache等等。

如果需要segment读数据,只要提交到segment线程即可,由segment线程调用存储得到buffer,再提交给netty的io线程。

除此之外,如果segment不采用异步读,prefetch预读触发就会阻塞本次拉消息请求,那预读也没意义了。

第二点,异常处理。

读发生异常,其实不影响,无非是拉不到消息。

而写发生异常,需要纠正segment元数据的size,即文件到底写到了什么position。

比如oss写超时,客户端设置position。

从底层实现上看,如果发生异常,根据当前segment文件大小决定position。

而发生异常时,获取文件大小有两种方式,上面提到了:

1、优先采用通过手动抛出TieredStoreException指定的position;

2、调用segment#getSize获取;

这里我没仔细处理。

2、构造Segment

AliyunFileSegment,继承TieredFileSegment,用于接入分层存储。

  1. fullPath,代表oss中的key(理解为文件存储路径)。这里我没有将不同类型的文件区分路径(见官方PosixFileSegment),而是用文件类型+offset作为文件名。这里的含义是,路径如何组织可以由实现者随便定义 ,segment元数据在启动后会重新调用TieredFileSegment构造,只要保证同样的元数据对应同一份文件即可
  2. client,用于调用三方api,一般情况下都可以做单例;
scala 复制代码
public class AliyunFileSegment extends TieredFileSegment {

    private static final Logger LOGGER = LoggerFactory.getLogger(TieredStoreUtil.TIERED_STORE_LOGGER_NAME);

    private final Client client;

    private final String fullPath;

    public AliyunFileSegment(TieredMessageStoreConfig storeConfig,
        FileSegmentType fileType, String filePath, long baseOffset) {
        super(storeConfig, fileType, filePath, baseOffset);
        client = Client.getInstance(storeConfig); // 单例client
        String dir = "rmq_data/" + storeConfig.getBrokerClusterName() + "/" + filePath;
        String name = fileType.toString() + "_" + TieredStoreUtil.offset2FileName(baseOffset);
        // rmq_data/cluster/broker/topic/queue/file_type_offset
        this.fullPath = dir + "/" + name;
    }
}

Client,调用阿里云oss的二次封装。

  1. 采用独立线程池asyncExecutor负责调用三方api;
  2. 读取TieredMessageStoreConfig中的配置项,构造三方sdk对象com.aliyun.oss.OSS;
arduino 复制代码
private static class Client {

    private static final Logger LOGGER = LoggerFactory.getLogger(TieredStoreUtil.TIERED_STORE_LOGGER_NAME);

    private static volatile Client client = null;

    private final OSS oss;

    private final String bucket;

    private final ExecutorService asyncExecutor = Executors.newFixedThreadPool(8);

    private Client(TieredMessageStoreConfig config) {
        this.oss = new OSSClientBuilder()
            .build(config.getObjectStoreEndpoint(), config.getObjectStoreAccessKey(), config.getObjectStoreSecretKey());
        this.bucket = config.getObjectStoreBucket();
    }

    private static Client getInstance(TieredMessageStoreConfig config) {
        if (client == null) {
            synchronized (Client.class) {
                if (client == null) {
                    client = new Client(config);
                }
            }
        }
        return client;
    }
}

3、实现api

因为rocketmq在框架层已经维护了元数据、内存buffer、批量commit等逻辑,

在实现TieredFileSegment的时候,只需要对接三方api即可。

AliyunFileSegment这里直接调用client。

typescript 复制代码
@Override
public String getPath() {
    return this.fullPath;
}

@Override
public long getSize() {
    try {
        ObjectMetadata metadata = client.getMetadata(this.fullPath);
        return metadata.getContentLength();
    } catch (Exception e) {
        return 0;
    }
}

@Override
public boolean exists() {
    return true;
}

@Override
public void createFile() {
}

@Override
public void destroyFile() {
    try {
        client.delete(this.fullPath);
    } catch (Exception e) {

    }
}

@Override
public CompletableFuture<ByteBuffer> read0(long position, int length) {
    return client.getAsync(this.fullPath, position, length);
}

@Override
public CompletableFuture<Boolean> commit0(FileSegmentInputStream inputStream, long position, int length,
    boolean append) {
    return client.appendAsync(fullPath, position, inputStream).thenApply(none -> true);
}

AliyunFileSegment.Client#appendAsync:写。

采用独立线程池asyncExecutor

构造AppendObjectRequest,设置objectKey=path,传入InputStream(记得底层是TieredFileSegment的bufferList)。

arduino 复制代码
public CompletableFuture<Void> appendAsync(String path, long position, InputStream stream) {
    return CompletableFuture.runAsync(() -> {
        try {
            this.append(path, position, stream);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }, this.asyncExecutor);
}

 private void append(String path, long position, InputStream inputStream) throws IOException {
    AppendObjectRequest appendObjectRequest = new AppendObjectRequest(bucket, path, inputStream);
    appendObjectRequest.setPosition(position);
    AppendObjectResult result = oss.appendObject(appendObjectRequest);
}

AliyunFileSegment.Client#getAsync:读。

采用独立线程池asyncExecutor

构造GetObjectRequest,设置objectKey=path,range=读buffer范围。

解析返回OSSObject,返回ByteBuffer。

csharp 复制代码
public CompletableFuture<ByteBuffer> getAsync(String path, long position, int length) {
    return CompletableFuture.supplyAsync(() -> {
        try {
            return this.getSync(path, position, length);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }, asyncExecutor);
}

private ByteBuffer getSync(String path, long position, int length) throws IOException {
    OSSObject object = null;
    try {
        GetObjectRequest getObjectRequest = new GetObjectRequest(bucket, path);
        getObjectRequest.setRange(position, position + length - 1);
        object = oss.getObject(getObjectRequest);
        byte[] byteArray = IOUtils.readStreamAsByteArray(object.getObjectContent());
        ByteBuffer byteBuffer = ByteBuffer.allocate(length);
        byteBuffer.put(byteArray);
        byteBuffer.flip();
        byteBuffer.limit(length);
        return byteBuffer;
    } finally {
        if (object != null) {
            try {
                object.close();
            } catch (Exception ignore) {

            }
        }
    }
}

4、配置broker

配置segment实现类和oss客户端需要的参数信息。

ini 复制代码
messageStorePlugIn=org.apache.rocketmq.tieredstore.TieredMessageStore
tieredMetadataServiceProvider=org.apache.rocketmq.tieredstore.metadata.TieredMetadataManager
tieredStoreFilePath=/data/rmq_data/broker-a-tieredstore

tieredBackendServiceProvider=org.apache.rocketmq.tieredstore.provider.aliyun.AliyunFileSegment
objectStoreAccessKey=x
objectStoreSecretKey=x
objectStoreBucket=x
objectStoreEndpoint=x

5、实现效果

一个queue纬度下是一个FlatFile。

一个FlatFile包含三类文件commitlog、consumequeue、index(需要写满本地indexfile才能上传,所以这里没看到)。

最好是将不同类型文件分目录存储,我这里是举例说明,也可以不按照这种方式实现。

元数据还是用的官方实现存储在本地config/tieredStoreMetadata.json。

在启动阶段,通过元数据可以找到oss上的文件(path+fileType+offset),

通过segment元数据中的size可以知道该segment的写入位置,后续可以继续追加写入。

总结

本文分析了rocketmq5.2的分层存储实现。

总的来说,目前rocketmq的分层存储还没有完全完善,现在也只是看着玩。

比如topic级别的ttl,实际上还没有api支持,只能手动维护元数据文件。

而且api还在不停的修改之中吧,比如5.2就对index部分做了重构。

存储概览

在分层存储出现之前,rocketmq的消息只能保存在本地磁盘,如果磁盘容量不足或到达保留时间(默认fileReservedTime=72小时),就会物理删除。

分层存储,就是将commitlog、consumequeue、index等三类文件,存储到外部设备上,比如各种OSS。

对于Tiered Store来说有两类数据。

第一类是元数据,元数据用于描述数据。

TieredMetadataStore接口描述了元数据存储需要的api,官方提供的模式实现类TieredMetadataManager将元数据都存储在config/tieredStoreMetadata.json中。

第二类是数据,包括commitlog、consumequeue、index。

TieredFlatFileManager 基于queue纬度管理n个CompositeQueueFlatFile

每个CompositeQueueFlatFile包含commitlog、consumequeue和index。

commitlog和consumequeue 都有一个FlatFile 包含n个TieredFileSegment,一个Segment可以理解为一个外部存储中的文件。

index较为特殊,除了FlatFile之外,还可能包含两种本地index文件(tiered_index_file和compacting)。

Segment的生命周期都由rocketmq管理,默认情况下三类segment的保留时间都是72h(tieredStoreFileReservedTime)。

segment过时将调用segment#destroyFile,至于怎么删除由实现者决定。

但是在元数据上,segment会被标记为删除,重启后也不会再加载,即逻辑删除

除此之外,commitlog还有一个滚动逻辑

如果当前写入的segment的最大写入时间超过24h (commitLogRollingInterval),且文件大小超过128MB(commitLogRollingMinimumSize),执行滚动生成新的segment,所以commitlog在分层存储中不是一个定长文件。

(注:commitlog默认最大1G,consumequeue默认最大100MB,index没有最大限制,由本地tiered_index_file整体压缩后上传)

commitlog/consumequeue写

reput会触发分层存储写逻辑,过滤系统topic(如延迟topic、reviveTopic等),先写commitlog,再写consumequeue和index。

根据broker+topic+queue能找到queue纬度的CompositeQueueFlatFile

无论是commitlog还是consumequeue,底层都是写FlatFile中的当前TieredFileSegment

总体来说,segment写入分为两个阶段,先写内存buffer再写外部存储(调用用户实现segment#commit0)。

TieredFileSegment如果写满了(比如commitlog是1g),会滚动一个新的segment。

TieredFileSegment会将数据先写入内存bufferList

bufferList到达阈值,,2500条buffer或buffer超过32MB,会直接触发异步刷盘。

bufferList未到达阈值,由后台线程定时刷盘(每隔60s),即外部存储能真正看见数据最少要延迟60s

用户代码segment#commit0执行异常 ,比如发生网络超时,框架需要判断究竟写入外部存储是否成功

目前rocketmq采用的方式是,根据segment的文件大小来判断异常之后的写入position

如果异常类型是TieredStoreException,则取TieredStoreException.position;

其他情况,都会重新调用segment#getSize(用户实现)获取文件大小。

拉消息

拉消息会过滤系统toipic,默认(tieredStorageLevel=NOT_IN_DISK)情况下。

如果offset不在主存中,走分层存储 ,如果分层存储发生异常,再降级走主存

分层存储拉消息逻辑和Cache Aside类似,先读本地缓存(caffeine),如果缓存未命中,再走segment#read0(用户逻辑)读。

只不过因为拉消息一般是连续有序 的,即基于offset递增,可以引入prefetch预读

比如客户端基于queueOffset=100,maxCount=32拉消息。

当缓存命中,可以继续向后再拉n条。

当缓存未命中,放大2倍拉n条。

未命中缓存/预读,通过offset二分查找定位segment,调用segment#read0(用户实现)得到buffer。

index写

Index的写入方式与commitlog和consumequeue都不同

分层存储consumequeue写入成功后,触发index写入。

Index文件会先以非压缩格式 ,写入本地文件系统storePath/tiered_index_file路径下。

UNSEALED,非压缩格式,索引项比主存多了topicId、queueId、size(消息大小)。

分层存储会为topic和queue记录元数据到tieredStoreMetadata.json,会为topic分配序号topicId,queueId则是本身的queueId。

按照非压缩格式存储的index文件的问题在于,假设根据业务key查询消息,业务key重复概率较高(比如同一个订单的消息)而导致哈希冲突,此时需要经过n次随机读才能完整读出一个slot下的所有索引项。

如果是在pagecache还好,但是对于外部存储来说,这种随机读就不能接受。

所以未压缩的本地index文件会在写满后,经过压缩,再上传至外部存储(segment)。

SEALED ,压缩格式,特点是同一个slot的所有索引项存放在一段连续区域中 ,而slot从原来的4byte变为8byte,指向一段连续buffer,从而能解决随机读问题。

SEALED 文件,属于一个临时文件 ,存放在tiered_index_file/compacting目录下。

UPLOAD文件,compacting本地压缩文件最终会上传至外部存储,调用用户实现的segment#commit0(整块压缩文件buffer),上传成功后会删除UNSEALED和SEALED文件。

index读

用户指定业务key+时间范围查询消息,需要先读index再读commitlog。

根据时间范围,在非强制走分层存储的情况下,可能需要读主存后再读分层存储,合并后返回。

分层存储下,会根据时间范围圈定查找的index文件。

如果index文件是SEALED或UNSEALED ,则走本地未压缩的文件查询

如果index文件是UPLOAD ,则走外部存储查询(用户segment#read0)。

参考资料

  1. Kafka分层存储
  2. RIP-57 5.x分层存储
  3. RIP-65 5.2分层存储Index重构
相关推荐
我叫啥都行5 分钟前
计算机基础知识复习9.7
运维·服务器·网络·笔记·后端
Monodye21 分钟前
【Java】网络编程:TCP_IP协议详解(IP协议数据报文及如何解决IPv4不够的状况)
java·网络·数据结构·算法·系统架构
一丝晨光28 分钟前
逻辑运算符
java·c++·python·kotlin·c#·c·逻辑运算符
无名指的等待7121 小时前
SpringBoot中使用ElasticSearch
java·spring boot·后端
Tatakai251 小时前
Mybatis Plus分页查询返回total为0问题
java·spring·bug·mybatis
武子康1 小时前
大数据-133 - ClickHouse 基础概述 全面了解
java·大数据·分布式·clickhouse·flink·spark
.生产的驴1 小时前
SpringBoot 消息队列RabbitMQ 消费者确认机制 失败重试机制
java·spring boot·分布式·后端·rabbitmq·java-rabbitmq
Code哈哈笑2 小时前
【C++ 学习】多态的基础和原理(10)
java·c++·学习
chushiyunen2 小时前
redisController工具类
java
A_cot2 小时前
Redis 的三个并发问题及解决方案(面试题)
java·开发语言·数据库·redis·mybatis