三、RocketMQ消息存储-全流程解析|存储文件介绍|内存映射

1.存储架构介绍

  • 1.消息的存储技术选型
    • 分布式KV存储(LevelDB、RocksDB、Redis)
    • NewSQL存储(TiDB)
    • 文件系统(RocketMQ、Kafka、RabbitMQ)
    • 一个好的文件系统是比KV存储要快的
  • 2.图解
    • Producer顺序的写入commitlog文件。Consumer消费消息时,是随机读commitlog文件。
    • 读的时候,通过构建的三个consumerQueue读取。consumerQueue存储索引数据,每个索引数据包含commitLogOffset、messageSize、tagHashCode三个属性。ConsumerQueue包含三个属性,maxOffset:消息落地后最大的偏移量、consumerOffset:消费者消费进度、minoffset:如果消息没有被清除过,为零。若被清除过,则是清楚后的偏移量值。

2.流程解析

1.消息写入存储全流程解析

图解:

1.SendMessageProcessor#sendMessage

  • handleRetryAndDLQ:如果是主题是消费重试的topic,即重试消息写入时,重试的消费次数达到上限后,就会进入死信队列

2.CommitLog#putMessage

  • 1. 有多个工作者线程并行处理,需要上锁,默认使用自旋锁。依赖于messageStoreConfig#useReentrantLockWhenPutMessage配置。
    • PutMessageReentrantLock:重入锁
    • PutMessageSpinLock:自旋锁
    • mappedFileQueue.getLastMappedFile(0),创建映射文件:详见3.4.1创建映射文件
    • mappedFile.appendMessage(msg, this.appendMessageCallback),消息写入,详见3.4.3消息的写入

3. handleDiskFlush(result, putMessageResult, msg)-刷盘机制

  • 1. ServiceThread,刷盘线程的父类
    • waitForRunning:在run函数里控制循环的节奏(支持通过wakeup唤醒)
    • wakeup:外部的线程唤醒本线程

4. ReputMessageService#run()-构建consumeQueue 和 index文件

  • 获取消息从pagecache中,消息区间从reputFromOffset到目前写入到pagecache的位置
    • 1.pagecahe:指物理文件对应的内存映射buffer;
    • 2.写入到pagecache的位置:分mmap+pagecahe方式 和 堆外内存池 + pagecache方式 获取
  • 获取ConsumeQueue的文件对象通过消息中主题 & queueId
    • 根据消息 主题与 队列 ID ,先获取对应的 ConumeQueue 文件 ,其逻辑 比较简单,因为每一个消息主题对应一个消息消费队列目录 然后主题下每一个消息队列对应一个文件夹,然后取出该文件夹最后的 ConsumeQueue 文件即可

5. ReferenceResource

MappedFile父类,作用是记录MappedFile中的引用次数为正表示资源可用,刷盘前加一,然后将wrotePosotion的值赋给committedPosition,再减一。

主要函数:

  • hold函数:引用,使得引用次数 +1
  • release:释放引用,引用次数-1
  • shutdown:关闭资源,清理

吐槽:

hold函数里面else是不会出现的.

3.存储文件

RockedtMQ存储路径为${ROCKET_HOME}/store,主要存储文件如下图所示:

  • 1.commitLog:消息存储目录
  • 2.config:运行期间的一些配置信息,主要包括以下信息:
    • 1.consumerFilter.json
    • 2.consumerOffset.json:集群消费模式消息消费进度。
    • 3.delayOffset.json:延迟消息队列拉取进度。
    • 4.subscriptionGroup.json:消息消费组配置信息
    • 5.topics.json:topic配置属性
  • 3.consumequeue:消息消费队列存储目录。
  • 4.index:消息索引文件存储目录。
  • 5.abort:如果存在abort文件说明Broker非正常关闭,该文件默认启动时创建,正常退出之前删除。
  • 6.checkpoint:文件检查点,存储commitlog文件最后一次刷盘时间戳、consumequeue最后一次刷盘时间戳、index索引文件最后一次刷盘时间戳。

 

  • 3.1.CommitLog文件

1.一个消息的日志文件,是如何存到磁盘中?rocketmq vs kafka

  • 1.Kafka:一个topic下多个Queue,消息存入到的多个Queue的对应的日志文件,每个Queue对应一个PrivateLog文件,各个Queue间的log文件是独立的。
  • 2.把所有的Queue对应的消息都存储到一个log,sharelog文件中。保证消息的顺序写入。

2.消息存储文件,所有消息主题的消息都存储在CommitLog文件中。每一条消息长度不同,消息体结构如下图

    • producer顺序的写入commitlog文件。Consumer消费消息时,是随机读commitlog文件。
    • commitlog文件默认大小是1G。RocketMQ主要通过MappedByteBuffer对文件进行读写操作,采用MappedByteBuffer这种内存映射的方式有几个限制,其中之一是一次只能映射1.5~2G 的文件至用户态的虚拟内存,这也是为何RocketMQ默认设置单个CommitLog日志数据文件为1G的原因。
    • 文件命名格式:20位数字,默认每个文件大小1G。文件名值为本文件的起始偏移量(物理偏移量),即是1G的字节数。即第一个文件0;第二个文件1g=1024*1024*1024= 1,073,741,824Bytes,文件名即1073741824。
      • 1.假设1073742827为物理偏移量(物理偏移量即全局偏移量),则其对应的相对偏移量为1003(1003=1073742827-1073741824),并且该偏移量位于第二个commitlog中。
    • 每一个commitlog文件都有一个MappedFile对应。MappedFile的管理是通过MappedFileQueue其中的字段mappedFiles(CopyOnWriteArrayList类型)存储了MappedFile。
  • 3.2.ConsumeQueue文件
    • 作用?消费者消费消息时,通过该主题的物理偏移量从commitlog中查询消息。消费者关心的是一个主题下的所有消息,但同一主题的消息不连续的存储在commitlog中,若消费者直接从消息存储文件(commitlog)中去遍历查找订阅主题下的消息,效率将及其地下。RocketMQ为了提高消息检索,设计了消息消费队列文件(consumequeue),该文件可以看成是CommitLog关于消息消费的”索引”文件。
    • consumequeue的文件目录结构,第一级目录为消息主题,第二级目录为主题的消息队列。设置单个consumequeue文件大小为(MessageStoreConfig.mappedFileSizeConsumeQueue)260字节,存储13(260/20)个消息索引。
    • 每一个consumequeue的条目,其存储格式为:
    • commitlog offset(8)

      size(4):消息大小

      TagHashCode(8):消息tag的hashCode

    • 单个ConsumeQeue文件中默认包含30W个条目,单个文件的长度为30W*20字节。
    • consumerOffset.json文件:集群消费模式消息消费进度
    •  
      • 在broker端存储的进度文件:topic@消费者名称:{queueId:消费进度}
    • subscriptionGroup.json:消息消费组配置信息
  • 3.3.ConsumeQueue文件

1.作用?通过消息的uniq_key 或 消息的索引键从commitlog中查询消息。详见3.4消息查询。

2.Index索引文件目录结构:

  • Index索引文件结构:
    • 1.IndexHead头部,包含40个字节,记录该indexFile的统计信息,其结构如下:
      • 1.beginTimestamp:该索引 文件中包含消息 的最小存储时间 。
      • 2.endTimestamp :该索引文件中包含消息的最大存储时间 。
      • 3.beginPhyoffset :该索引文件中包含消息的最小物理偏移量(commitlog 文件偏移量)
      • 4.endPhyoffset :该索引文件中包含消息的最大物理偏移量(commitlog 文件偏移量)
      • 5.hashslotCount: hashslot个数,并不是hash槽使用的个数,在这里意义不大
      • 6.indexCount:Index条目列表当前已使用的个数,Index条目在Index条目列表中按顺序存储
    • 2.Hash 槽,一个 IndexFile 默认包含 500 万个 Hash 槽,每个 Hash 槽存储的是落在该Hash 槽的 hashcode 最新的 In dex 的索引 。
    • 3.Index条目列表,默认一个索引文件包含 2000 万个条目,每一个 Index 条目结构如下:
      • 1.hashcode: key 的 hashcode 。
      • 2.phyoffset:消息对应的物理偏移量 。
      • 3.timedif:该消息存储时间与第一条消息的 时间戳的差值,小于 0 该消息无效 。
      • 4.prelndexNo:该条目 的前一条记录 的 Index 索引, 当 出现 hash 冲突 时 , 构建的链表结构 。
    • 4.将消息索引键与消息偏移量映射关系写入到IndexFile过程分析(消息索引key、物理偏移量、消息存储时间):
      • 1.根据key算出key的hashcode,然后keyhash对hash槽数量取余定位到hashcode对应的hash槽下标,该下标的物理地址为IndexHeader头部(40字节) + 下标*每个hash槽的大小(4字节)。定位hash槽算法,若不同key的hashcode相同或不同的key不同的hashcode但对hash槽数量取余后结果相同都会引发Hash冲突,那indexFile是如何解决这个问题?
      • 2.读取hash槽中存储的数据,若hash槽存储的数据小于0或大于当前索引文件中的索引条目使用个数,将slotvalue设置为0
      • 3.计算待存储消息的时间与第一条消息时间戳的差值,并转换成秒
      • 4.将索引条目存储到indexFile中
        • 1.计算新添条目的起始物理偏移量,等于头部字节长度(40字节)+hash槽数量*单个hash槽大小(4字节)+当前index条目数*单个index条目大小20字节
        • 2.依次将hashcode、消息物理偏移量、消息存储时间、当前定位的hash槽的值(pre index no)存入MappedByteBuffer
        • 3.将当前Index文件包含的条目数量存入hash槽中,并覆盖原hash槽的值
      • 5.更新索引头信息。更新当前index条目数:原值+1
  • 3.4.消息查询

1.消息的key

  • 1.uniq_key:由生产者发送消息时客户端生成;客户端操作时存放在msg的property中,key为uniq_key;用户可通过返回生产者的SendResult对象的offsetMsgId获取
  • 2.keys:由用户传入,客户端构建message对象放入的key,可包含多个key,用空格隔开;客户端操作时存放在msg的property中,key为KEYS;
  • 3.msgId,由broker写入commitlog时产生,有broker写入commitlog成功后返回客户端;用户可通过返回生产者的SendResult对象的msgId获取。

2.broker生成的消息ID

  • 1.按照MessageID查询(依赖CommitLog)
    • 1.MQAdminImpl.viewMessage(String MessageId)
    • 2.MQAdminImpl.viewMessage(final String addr, final long phyoffset, final long timeoutMillis)
    • 3.QueryMessageProcessor.viewMessageById(ChannelHandlerContext ctx, RemotingCommand request)

3.客户端SDK生成的UniqueKey

  • 1.按照uniqueKey/Keys查询(依赖Index和CommitLog)
    • 1.MQAdminImpl.queryMessage(String topic, String key, int maxNum, long begin, long end,boolean isUniqKey)
    • 2.MQAdminImpl.queryMessage(final String addr,final QueryMessageRequestHeader requestHeader,final long timeoutMillis,final InvokeCallback invokeCallback,final Boolean isUnqiueKey)
    • 3.QueryMessageProcessor.queryMessage(ChannelHandlerContext ctx, RemotingCommand request)

4.内存映射

RocketMQ通过内存映射文件提高IO访问性能,commitLog、consumeQueue、indexFile的单个文件都被设计为固定长度。

  • 4.1消息写入流程(CommitLog.putMessage逻辑)

  • 1.获取映射文件
    • 1.DefaultMessageStore#putMessage(MessageExtBrokerInner msg)
    • 2.CommitLog#putMessage(final MessageExtBrokerInner msg)
    • 3.MappedFileQueue#getLastMappedFile()
      • CopyOnWriteArrayList<MappedFile> mappedFiles:线程安全,读多写少;该mappedFiles是由broker启动时调用load()方法加载commitlog文件的存储目录下的commitlog文件为mappedFile对象至mappedFiles而来。
  • 2.创建映射文件:检查获取的映射文件,获取的映射文件可能为null or 写满(1.broker刚启动时,并且服务器是新的环境,从未有过commitlog文件,这时候获取的映射文件是空的;第3步获取的映射文件可能已写满了)。
  • 4.1创建映射文件

A.创建映射文件代码流:

  • 1.CommitLog#putMessage(final MessageExtBrokerInner msg)

  • 2.MappedFileQueue#getLastMappedFile(final long startOffset, boolean needCreate)
  • 3.AllocateMappedFileService#putRequestAndReturnMappedFile(String nextFilePath, String nextNextFilePath, int fileSize)

B.AllocateRequest介绍:

  • 1.AllocateRequest是内部类,代表分配请求。它实现了Comparable接口的compareTo(AllocateRequest other)方法,用于自定义分配请求在优先级队列的优先级。
  • 2. AllocateRequest的成员变量

 

fileSize:默认是1G

mappedFile:发送创建请求时,值是null,创建完成后,会把创建后的mappedFile放入此属性中。

CountDownLatch:因只需要等待mappedFile映射文件A创建完成,通过使用countDownLatch等待控制。

C.AllocateMappedFileService(分配映射文件的服务,是一个服务线程)介绍:

  • 1.AllocateMappedFileService的服务线程是有优先级的,先处理谁后处理谁,优先级策略就是由compareTo方法实现。
    • Rocketmq的一个设计,为提高性能,一次发出两个创建mappedFile的请求,比如当前仅仅需要创建一个mappedFile映射文件A用来写消息,但是会把要创建的下一个映射文件B的信息形成request请求也提交给线程进行创建。实际中只要阻塞等待第一个mappedfile映射文件A创建完成即可,第一个才是真正需要写入消息的文件。然后mappedFile映射文件B交由后台创建,不用关注。映射文件A是真正需要的,映射文件B是后台创建的,这两个文件的创建需要有一个优先级,是由compareTo实现的。简单理解,会根据文件名称哪个小就优先创建。
  • 2.AllocateMappedFileService字段属性介绍:
  • 3.AllocateMappedFileService等待通知模型:
  • D. MappedFile的创建
  • 创建commitlog文件对应的MappedFile有两种方式:1.直接new 2.通过传入TransientStorePool:堆外内存池;
    • 堆外内存池使用方式:messageStore.getMessageStoreConfig().isTransientStorePoolEnable()—>this.writeBuffer = transientStorePool.borrowBuffer();假如池中的buffer不够了如何处理?不支持扩容,大小不变,默认是创建5个堆外内存,取走后减少,用完后归还,如果不够的话,获取返回null。
      • 可用buffer的计算:int canSubmitRequests = this.messageStore.getTransientStorePool().availableBufferNums() - this.requestQueue.size();
    • 方式1:new MappedFile(req.getFilePath(), req.getFileSize())
  • MappedFile文件的消息写入也有两种方式,与创建是一一对应。绿色箭头是方式一,橘色箭头是方式二。生产者发送消息时,可以通过这两类buffer进行消息的写入。
    • 方式一:通过new RandomAccessFile获取fileChannel,再通过fileChannel.map方法创建mappedByteBuffer,fileChannel和mappedByteBuffer都是映射文件中的属性。mappedByteBuffer就是映射文件中的一种buffer。通过flush直接刷盘。
    • 方式二: 通过堆外内存池获取writeBuffer,也属于MappedFile中的一种buffer。通过commit方法提交到fileChannle,提交操作后,文件通道对应的mappedByteBuffer就有了此消息数据。再通过FileChanle.flush写入到磁盘中。
  • RocketMQ对文件的写入和读取都是通过操作pagecahe来实现,当不开启堆外内存池情况下,读写都通过mappedByteBuffer来实现;当开启堆外内存池时,通过堆外内存池的buffer写入,而通过mappedByteBuffer实现读取;综述,mappedByteBuffer,代表的是操作系统的pageCache。

E.内存映射文件的预热

  • 内存文件映射适用于对大文件的读写, 内存映射文件将某一段的虚拟地址和文件对象的某一部分建立起映射关系,此时并没有拷贝数据到内存中去,而是当进程代码第一次引用这段代码内的虚拟地址时,若触发了缺页异常,这时候OS根据映射关系直接将文件的相关部分数据拷贝到进程的用户私有空间中去,将物理地址与虚拟地址做映射,耗费性能。即通过fileChannel.map方法创建mappedByteBuffer,只是一个虚拟的内存地址,在内存中并没有真正的物理内存。当mappedByteBuffer进行消息写入时候,先通过虚拟地址查找物理内存,发现缺页(物理内存上没有这个内存页),然后从磁盘中读文件数据(消息写入时不需要读文件),放入物理内存,然后将物理内存与虚拟地址做映射,这样数据才会正在读到内存。
  • 内存映射文件(mappedByteBuffer)需要预热,要写入假值0。为何要写入假值0?防止真正消息写入时内存缺页,做物理内存与虚拟地址做映射,耗费性能。
  • LibC.INSTANCE.mlock作用:预热时,写入内存的假值,防止被gc掉,调用mlock,把映射文件的内存地址锁定在磁盘中,无论内存是否够用,这块内存不许动,不要被交换到swap空间
  • LibC.INSTANCE.madvise作用:在执行map方法时,已经做过物理内存与虚拟地址的映射,再调用madvise是否为多次一举?

4.2.消息的写入

A.写入消息代码流:

  • 1.CommitLog#putMessage(final MessageExtBrokerInner msg)
  • 2.MappedFile#appendMessage(final MessageExtBrokerInner msg, final AppendMessageCallback cb)
  • 3.DefaultAppendMessageCallback#doAppend(final long fileFromOffset, final ByteBuffer byteBuffer, final int maxBlank,                                            final MessageExtBrokerInner msgInner)

B.写入消息介绍:

通过写入writeBuffer和mappedByteBuffer两个buffer完成。

    •  

C.代码解析

result = mappedFile.appendMessage(msg, this.appendMessageCallback);

4.3.堆外内存池

  • 为什么要使用TransientStorePool?一般有两种,有两种方式进行读写
    • (1)第一种,Mmap+PageCache的方式,读写消息都走的是pageCache,这样子读写都在pagecache里面不可避免会有锁的问题,在并发的读写操作情况下,会出现缺页中断降低,内存加锁,污染页的回写。异步刷盘的第一种方式和同步刷盘方式
    • (2)第二种,DirectByteBuffer(堆外内存)+PageCache的两层架构方式,这样子可以实现读写消息分离,写入消息时候写到的是DirectByteBuffer——堆外内存中,读消息走的是PageCache(对于,DirectByteBuffer是两步刷盘,一步是刷到PageCache,还有一步是刷到磁盘文件中),带来的好处就是,避免了内存操作的很多容易堵的地方,降低了时延,比如说缺页中断降低,内存加锁,污染页的回写。

4.4.MMap

概述:内存映射文件的效率比标准IO高的重要原因就是因为少了把数据从内核空间拷贝到用户进程私有空间这一步。

内存映射&零拷贝体现在两个点:1.磁盘文件的读取以及写入2.网络传输时,cpu不再参与拷贝。

 

4.5ByteBuffer

  • Java NIO中有三种ByteBuffer
    • HeapByteBuffer:ByteBuffer.allocate()使用的就是这种缓冲区,叫堆缓冲区,因为它是在JVM堆内存的,支持GC和缓存优化。但是它不是页对齐的,也就是说如果要使用JNI的方式调用native代码时,JVM会先将它拷贝到页对齐的缓冲空间。
    • DirectByteBuffer:ByteBuffer.allocateDirect()方法被调用时,JVM使用C语言的malloc()方法分配堆外内存。由于不受JVM管理,这个内存空间是页对齐的且不支持GC,和native代码交互频繁时使用这种缓冲区能提高性能。不过内存分配和销毁的事就要靠你自己了。
    • MappedByteBuffer:FileChannel.map()调用返回的就是这种缓冲区,这种缓冲区用的也是堆外内存,本质上其实就是对系统调用mmap()的封装,以便通过代码直接操纵映射物理内存数据。

5.刷盘机制

6.问题

1. 当topic数量增多到100+时,kafka的单个broker的TPS降低了1个数量级,而RocketMQ在海量topic的场景下,依然保持较高的TPS?见3.1 CommitLog文件

2. CommitLog,producer写是”顺序写”,但consumer读有一部分是”随机读”对性能的影响?

通过内存映射文件,一个commitlog对应一个mappedFile,即通过MappedFile,就很好的解决了大文件随机读的性能问题。每次读取,通过pagecache,极大提升效率。但对于历史消息,超出pagecache范围后,需要读取磁盘获取消息,如长时间的消息积压。

3. 消息会丢失吗?

刷盘采用同步刷盘策略;主从同步机制采用replica角色的slave,半数复制完成即可。但当slave超过10s未响应,采用主从同步退化为异步机制。感觉类似mysql的主从同步机制。

4.为什么使用堆外内存?结果mq,为什么要用TransientStorePool?

https://www.cnblogs.com/qingchen521/p/9177357.html

4.2堆外内存值

5.Rocketmq的一个设计,为提高性能,一次发出两个创建mappedFile的请求,比如当前仅仅需要创建一个mappedFile映射文件A用来写消息,但是会把要创建的下一个映射文件B的信息形成request请求也提交给线程进行创建。应用侧只需关心映射文件A生成即可,并不关心B是否生成。详见AllocateMappedFileService分析

6.rocketMQ中使用的锁有哪些?PutMessageReentrantLock:重入锁;PutMessageSpinLock:自旋锁

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值