消息的生产
消息的生产过程
Producer可以将消息写入到某Broker中的Queue中,其经历了如下过程:
- 首先他会把所有的naneServer列表写到Broker配置文件里面
- 优点 :nameServer集群搭建非常简单
- 缺点 :Broker,必须明确指出所有 NameServer地址
- 启动NameServer,开始监听端口,等待Broker,Consumer,Producer连接
- 启动Broker,Broker会与所有的NameServer建立长连接,然后每30秒向NameServer定时发送心跳包
- Producer发送消息之前,会先向NameServer发出获取消息Topic的路由信息的请求
- Nameserver返回该Topic的路由表和Broker列表
- 路由表: key为Topic名称,value是一个QueueData(所有queue)实例列表的map
- 一个Broker中该Topic的所有Queue对应一个QueueData
- 只要涉及到该Topic的Broker,一个Broker对应一个Queue
- QueueData中包含brokerName
-
Broker列表:key为brokerName,value为BrokerData
-
一个Broker对应一个Broker实例。对吗?不对。
-
一个BrokerData对应着BrokerName相同的Master-slave集群。
-
BrokerData其中包含BrokerName及一个map
-
该map的key为brokerId,value为broker对应的地址,
-
brokerId为0表示该Broker为Master
-
非0为salve。
-
-
-
- RPC请求:远程过程调用 (RPC) 是一种协议,程序可使用这种协议向网络中的另一台计算机上的程序请求服务。
- 路由表: key为Topic名称,value是一个QueueData(所有queue)实例列表的map
- Producer根据代码中指定的Queue选择策略,从Queue列表中选出一个队列,用于后续存储消息
- Producer对消息进行一些特殊处理,例如,消息本身超过4M,则会对其进行压缩
- Producer想选择出的Queue所在的Broker发出RPC请求,将消息发送到选择的Queue上
Queue选择算法
对于无序消息,其Queue选择算法,也称为消息投递算法,常见的有两种:
轮询算法
轮询,一个接着一个。默认选择算法。该算法保证了每个Queue中可以均匀的获取到消息:
该算法存在一个问题:
由于某些原因(上一个消息投递延迟,下一个迟迟无法投递),在某些Broker上的Queue可能投递延迟较严重。从而导致Producer的缓存队列中出现较大的消息积压,影响消息的投递性能。
最小投递延迟算法
该算法会统计每次消息投递的时间延迟,然后根据统计出的结果将消息投递到时间延迟最小的Queue。如果延迟相同,则采用轮询算法投递。该算法可以有效提升消息的投递性能。
该算法也存在一个问题:消息再Queue上的分配不均匀。投递延迟小的Queue其可能存在大量的消息。(queue内消息分配不均匀导致消费者组中的消费者忙的忙死闲的闲死)而对该Queue的消费者压力会增大,降低消息的消费能力,可能会导致MQ中消息的堆积。
消息的储存
- abort:该文件在Broker启动后会自动创建,正常关闭Broker,该文件会自动消失。若在没有启动Broker情况下,发现这个文件是存在的,则说明之前Broker的关闭时非正常关闭。
- checkpoint:其中存放着commintlog、consumerqueue、index文件的最后沙盘时间
- commitlog:其中存放着commitlog文件,而消息是写在commitlog文件中的
- config:存放着Broker运行期间的一些配置数据
- consumequeue:其中存放着consumer queue文件,队列就放在这个目录中
- index:其中存放着消息索引文件indexFile
- lock:运行期间使用到的全局资源锁
Commitlog文件
目录与文件
commitlog目录中存放着很多的mappedFile文件,当前Broker中所有消息都是落盘到这些mappedFiel文件中的。
mappedFile文件容量为1G(其中存放的数据可能会小于1G,因为该文件剩余的容量不足以储存下一条消息,此时下一条消息就会储存到另一个mappedFile文件中),文件由20位十进制数构成,表示当前文件的第一条消息的起始唯一偏移量。
第一个mappedFile文件名一定为20位0构成的。因为第一个文件的第一条消息的偏移量commitlog offset为0。
当第一个文件放满时,则会自动生成第二个文件继续存放消息。假设第一个文件大小是
1073741820字节(1G=1073741824字节),第二个文件的大小为10字节,则第二个文件名就是00000000001073741824。以此类推,第n个文件就为前n-1个文件大小之和
(如果第一个文件储存了不足1G的消息,第二个文件的的偏移量就是第一个文件存放消息的大小)
需要注意的是,一个Broker中仅包含着一个commit log目录,所有的mappedFile文件都是存放在该目录中的。即无论当前Broker中存放着多少Topic的消息,这些消息都是被顺序写入到mappedFile文件中的。也就是说,这些消息在Broker中存放时并没有按照Topic进行分类存放。
一个Broker = 一个commit log目录 = n个mappedFile文件
mappedFile文件是顺序读写的文件,所以其访问效率很高
消息单元
mappedFile文件内容有一个个的消息单元构成。每个消息单元中包含如下:
- MsgLen :消息总长度MsgLen
- PhysicalOffset :消息的物理地址(物理偏移量)
- Body :消息体内容 真正的内容
- BodyLength :消息体长度
- BornHost :消息生产者
- BonTimestamp : 消息发送时间戳
- Topic : 消息的主题
- Queueld :消息所在的队列QueueId
- QueueOffset :消息在Queue中储存的偏移量
- 。。。。。。等近20项详细相关属性
需要注意到,消息单元中是包含 Queue相关属性的。所以,我们在后续的学习中,就需要十分留意commitlog与queue间的关系是什么?
一个mappedFile文件中第n+1个消息单元的commitlog offset偏移量
L(n + 1) = L(n) + MsgLen(n)(n>0)
L1 = Ln + MsgLen(n)
ConsumeQueue文件
上图 0 1 2 3 叫做queueId
点击进去查看consumequeue文件
首先 生产者把消息发送到broker里面的Commit Log文件里面的Body,保存消息的内容,同时也会Topic创建相应的Comsume Queue,存储消息在Commit Log的位置信息。
重点:
- 为了提高效率,会为每个在~/store/consumeQueue中创建一个目录,目录名为Topic名称。
- 在该Topic目录下,会在为每个该Topic的Queue创建一个目录,目录名为queueId。
- 每个目录中存放着若干consumequeue文件,consumequeue文件是commitlog的索引文件,可以根据consume queue定位到具体的消息。
consumequeue文件名也是由20位数字构成,表示当前文件的第一个索引条目的起始偏移量。与mappedFile文件名不同的是,其后续文件名是固定的。因为consumequeue文件大小是固定不变的。
索引条目
每个consumequeue文件可以包含30W个索引条目,每个索引条目包含了三个消息重要属性:
- 消息在mappedFile文件中的
- 偏移量CommitLogOffset
- 消息长度
- 消息Tag的hashCode置
这三个属性占20个字节,所以每个文件的大小是固定的30W*20字节
一个consumequeue文件中所有消息的Topic一定是相同的。但每条消息Tag可能不同的
(RocketMq存在根据tag进行查询,消息可以根据消息Tag的hashCode置去查询在Topic内的CommitLog目录下的ComsumeQueue里面索引目录进行查询)
对文件的写入
消息写入
一条消息进入到Broker后经历了以下几个过程才最终被持久化。
- Borker会根据queueId获取到该消息对应索引条目要在consumequeue目录的写入偏移量,即QueueOffset
- 将queueId、queueOffset等数据,与消息一起封装为消息单元
- 将消息单元写入到commitlog
- 同时形成消息索引条目
- 将消息索引条目分发到相应的consumequeue
消息拉取
当Conusmer来拉去消息时会经历以下几个步骤:
- Consumer获取到其要消费消息所在Queue的消费偏移量offset计算出要消费消息的消息offset
- 消费offset及消费进度,consumer对某个Queue的消费offset,即消费到了该Queue的第几条消息
- 消息offset = 消费offset + 1
- Consumer向Broker发送拉去请求,其中会包含其拉取消息的Queue、消息offset以及消息Tag
- Broker计算在该consume queue中的queueOffset
- queueOffset = 消息offset * 20字节
- 从该queueOffset处开始向后查找第一个指定Tag的索引条目
- 解析该索引条目的前8个字节,即可定位到该消息在commitlog中的commitlog offset
- 从对应的commitlog offset中读取消息单元,并发送给Consumer
性能提升
RocketMQ中,无论是消息本身还是消息索引,都是存储在磁盘上的。其不会影响消息的消费吗?当然不会。
其实RocketMQ的性能在目前的MQ产品中性能是非常高的。因为系统通过一系列相关机制人大提升了性能。
- 首先,RocketMQ对文件的读写操作是通过mmap零拷贝进行的,将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率。
- 其次,consumequeue中的数据是顺序存放的,还引入了PageCache的预读取机制,使得对consumequeue
- PageCache机制,页缓存机制,是OS对文件的缓存机制,用于加速对文件的读写操作。一般来说,程序对文件进行顺序读写的速度几乎接近于内存读写速度,主要原因是由于OS使用PageCache机制对读写访问操作进行性能优化,将一部分的内存用作PgeCache。
- 写操作:OS会先将数据写入到PageCache中,随后会以异步方式由pdflush (page dirtyflush)内核线程将Cache中的数据刷盘到物理磁盘
- 读操作:若用户要读取数据,其首先会从PageCache中读取,若没有命中,则OS在从物理磁盘上加载该数据到PageCache的同时,也会顺序对其相邻数据块中的数据进行预读取。
- PageCache机制,页缓存机制,是OS对文件的缓存机制,用于加速对文件的读写操作。一般来说,程序对文件进行顺序读写的速度几乎接近于内存读写速度,主要原因是由于OS使用PageCache机制对读写访问操作进行性能优化,将一部分的内存用作PgeCache。
文件的读取几乎接近于内存读取,即使在有消息堆积情况下也不会影响性能。
RocketMQ中可能会影响性能的是对commitlog文件的读取。因为对commitlog文件来说,读取消息时会产生大量的随机访问,而随机访问会严重影响性能。不过,如果选择合适的系统10调度算法,比如没置调度算法为Deadline(采用SSD固态硬盘的话),随机读的性能也会有所提升。
深入理解
“读操作:若用户要读取数据,其首先会从PageCache中读取,若没有命中,则OS在从物理磁盘上加载该数据到PageCache的同时,也会顺序对其相邻数据块中的数据进行预读取。”
当用户要读取数据时,操作系统首先会检查PageCache(页面缓存),这是一个位于内存中的缓存,用于存储最近访问过的数据页。如果用户请求的数据在PageCache中已经存在(命中),则操作系统可以直接从PageCache中读取数据,而无需访问物理磁盘,这样可以提高读取速度。
如果用户请求的数据不在PageCache中(未命中),操作系统就需要从物理磁盘上加载该数据到PageCache中。在这个过程中,除了加载用户请求的数据,操作系统还会顺序预读取相邻数据块中的数据到PageCache中。
预读取相邻数据块的操作是为了利用空间局部性原理,即一旦访问了某个数据块,可能会接下来访问其相邻的数据块。通过预读取相邻数据块,操作系统可以提前将这些数据加载到内存中,以便在用户访问时能够更快地响应请求。
这种预读取的机制有助于减少对物理磁盘的实际访问次数,提高数据读取的效率和性能。通过在加载用户请求数据的同时预读取相邻数据块,操作系统可以优化数据访问过程,使得后续访问相邻数据块时能够更快地获取数据。这种优化有助于提高系统整体的读取性能。
举个简单的例子来说明相邻数据块中的数据块:
假设物理磁盘上存储了一些学生的信息,每个数据块包含多个学生的记录。
当用户请求读取第一个数据块中的学生信息时,操作系统会加载这个数据块到PageCache中。
在这种情况下,相邻数据块可能是指与第一个数据块相邻的其他数据块,这些数据块可能包含接下来的学生信息记录。
例如,如果学生信息按照学号的顺序存储在磁盘上,用户请求读取学号为1001-2000的学生信息,那么操作系统可能会顺序加载包含学号为2001-3000的数据块到PageCache中,因为这些数据块与用户请求的数据块相邻,操作系统预测用户可能会继续访问这些相邻数据块中的信息。
这样,在用户访问相邻数据块时,可以直接从PageCache中获取数据,而不必再次从物理磁盘上读取,从而提高数据访问的效率和性能。
与Kafka的对比
RocketMQ的很多思想来源于Kafka,其中commitlog与consumequeue就是。
RocketMQ中的commitlog目录与consumequeue的结合就类似于Kafka中的partition分区目录。
mappedFile文件就类似于Kafka中的segment段。
Kafka中的Topic的消息被分割为一个或多个partition。partition是一个物理概念,对应到系统上就是topic目录下的一个或多个目录。每个partition中包含的文件称为segment,是具体存放消息的文件。
Kafka中消息存放的目录结构是:
- topic目录下有partition目录
- partition目录下有segment文件
- Kafka中没有二级分类标签Tag这个概念
- Kafka中无需索引文件。
因为生产者是将消息直接写在了partition中的,消费者也是直接从partition中读取数据的
indexFile
- 除了通过通常的指定Topic进行消息消费外
- RocketMQ还提供了根据key进行消息查询的功能
该查询是通过store目录中的index子目录中的indexFile进行索引实现的快速查询。当然,这个indexFile中的索引数据是在包含了key的消息被发送到Broker时写入的。如果消息中没有包含key,则不会写入。
索引条目结构
每个Broker中包含一组indexFile,每个indexFile都是以一个时间戳命名的(这个indexFile被创建时的时间戳)。每个indexFile由三部分构成:
- indexHeader
- slots槽位
- indexes索引单元。
每个indexFile文件中包含500W个slot槽【这个数量可能是根据系统的需求、性能要求以及资源限制等因素综合考虑得出的最佳选择】。而每个slot槽又可能会挂在很多的index索引单元。
indexHeader
indexHeader固定40个字节,其中存放着如下数据:
- beginTimestamp:该indexFile中第一条消息的存储时间
- endTimestamp:该indexFile中最后一条消息存储时间
- beginPhyoffset:该indexFile中第一条消息在commitlog中的偏移量commitlog offset
- endPhyoffset:该indexFile中最后一条消息在commitlog中的偏移量commitlog offset
- hashSlotCount:已经填充有index的slot数量 (并不是每个slot槽下都挂载有index索引单元,这里统计的是所有挂载了index索引单元的slot槽的数量)
- indexCount:该indexFile中包含的索引个数 (统计出当前indexFile中所有slot槽下挂载的所有index索引单元的数量之和)
slots槽位
indexFile中最复杂的是Slots与Indexes间的关系。在实际存储时,Indexes是在Slots后面的,但为了便于理解,将它们的关系展示为如下形式:
- key的hash值%500w的结果即为slot槽位,然后将该slot值修改为该index索引单元的indexNo
indexNo是一个在indexFile中的流水号,从0开始依次递增。即在一个indexFile中所有indexNo是以此递增的。indexNo在 index索引单元中是没有体现的,其是通过 indexes中依次数出来的。
- 根据这个indexNo可以计算出该index单元在indexFile中的位置。
- 不过,该取模结果的重复率是很高的,为了解决该问题,在每个index索引单元中增加了preIndexNo,用于指定该slot中当前index索引单元的前一个index索引单元。
- 而slot中始终存放的是其下最新的index索引单元的indexNo,这样的话,只要找到了slot。就可以找到其最新的index索引单元,而通过这个index索引单元就可以找到其之前的所有index索引单元
index索引单元
index索引单元默写20个字节,其中存放着以下四个属性:
- keyHash:消息中指定的业务key的hash值
- phyOffset:当前key对应的消息在commitlog中的偏移量commitlog offset
- timeDiff:当前key对应消息的存储时间与当前indexFile创建时间的时间差
- preIndexNo:当前slot下当前index索引单元的前一个index索引单元的indexNo
indexFile 的创建
indexFile的文件名为当前文件被创建时的时间戳。这个时间戳有什么用处呢?
根据业务key进行查询时,查询条件除了key之外,还需要指定一个要查询的时间戳,表示要查询不大于该时间戳的最新的消息。这个时间戳文件名可以简化查询,提高查询效率。具体后面会详细讲解。
indexFile文件是何时创建的?其创建的条件(时机)有两个:
- ·当第一条带key的消息发送来后,系统发现没有indexFile,此时会创建第一个indexFile文件
- ·当一个indexFile中挂载的index索引单元数量超出2000w个时,会创建新的indexFile。当带key的消息发送到来后,系统会找到最新的indexFile,并从其indexHeader的最后4字节中读取到indexCount。若indexCount >=2000w时,会创建新的indexFile。
由于可以推算出,一个indexFile的最大大小是:(40+500w*4+2000w*20)字节
查询流程
当消费者通过业务key来查询相应的消息时,其需要经过一个相对较复杂的查询流程。不过,在分析查询流程之前,首先要清楚几个定位计算式子:
计算指定消息key的s1ot槽位序号 | slot槽位序号 = keyhash % 500w |
计算槽位序号为n的slot在indexFile中的起始位置 | slot(n)位置 = 40 + (n-1) * 4 |
计算indexNo为m的index在indexFile中的位置 | index(m)位置 = 40 + 500w * 4 +( m-1) * 20 |