写点什么

【参赛总结】第二届云原生编程挑战赛 - 冷热读写场景的 RocketMQ 存储系统设计 - Ninety Percent 战队

作者:阿里云天池
  • 2024-08-12
    浙江
  • 本文字数:8180 字

    阅读完需:约 27 分钟

关联比赛:  2021第二届云原生编程挑战赛1:针对冷热读写场景的RocketMQ存储系统设计

相逢

凌晨三四点,冒着严寒做着核酸大家心事重重,表情凝重不堪生活,是黑暗冰冷的荒原,乐观,如满天星河般绚烂初冬的清晨,黄叶翩翩快到来吧,那久违的笑脸快过去吧,这该死的新冠

我吹过你吹过的风,这算不算相拥我走过你走过的路,这叫不叫相逢簇拥的人群,错位的时空共享过的基站,沐浴过的霓虹 800 米,或无所事事,或行色匆匆 10 分钟,看天高云淡,却暗流涌动

时光流逝,网格纵横不经意的交叉,在同一片天空红黄绿码,万人同梦看不见的坚强,有同一种感动她叫成都,2500 万的并发,心跳怦怦这是中国,140M 个对象,携手同工疫无反顾,患难与共天涯咫尺,云上相逢一衣带水,原始见终

悸动

32 岁,码农的倒数第二个本命年,平淡无奇的生活总觉得缺少了点什么;

想要去创业,却害怕家庭承受不住再次失败的挫折,想要生二胎,带娃的压力让我想着还不如去创业;所以我只好在生活中寻找一些小感动,去看一些老掉牙的电影,然后把自己感动得稀里哗啦,去翻一些泛黄的书籍,在回忆里寻找一丝丝曾经的深情满满;去学习一些冷门的知识,最后把自己搞得晕头转向,去参加一些有意思的比赛,捡起那 10 年走来,早已被刻在基因里的悸动;

那是夏末的一个傍晚,我和同事正闲聊着西湖的美好,他们说想去瞧瞧,我便说阿里有免费的机票,他们问是否可靠,我说:阿里挺可靠,不过我只有九成的把握,另外一成层得找我媳妇儿要;那一天,Ninety Percent 战队应运而生,云原生 MQ 的赛道上,又多了一个艰难却坚强的选手;

人到中年,仍然会做出一些冲动的决定,那种屁股决定脑袋的做法,像极了领导们的睿智和 18 岁时我朝三暮四的日子;夏季的 ADB 比赛,已经让我和女儿有些疏远,让老婆对我有些成见;此次 MQ 一战,必然是要暗度陈仓,卧薪尝胆,不到关键时刻,不能让家里人知道我又在卖肝;

开工

你还别说,或许是人类的本性使然,这种背着老婆偷偷干坏事情的感觉还真不错,从上路到上分,一路顺风顺水,极速狂奔;断断续续花了大概两天的时间,成功地在 A 榜拿下了 first blood;再一次把第一名和最后一名同时纳入囊中;快男总是不会让大家失望了,800 秒的成绩,成为了比赛的 base line;

第一个版本并没有做什么设计,基本上就是拍脑门的方案,目的就是把流程跑通,尽快出分,然后在保证正确性的前提下,逐步去优化方案,避免一开始就过度设计,导致迟迟不能出分,影响士气;

整体设计


当 append 方法被调用时,会将传入的相关参数包装成一个 Request 对象,put 到请求队列中,然后当前线程进入等待状态;

聚合线程会循环从请求队列里面消费 Request 对象,放入一个列表中,当列表长度到达一定数量时,就将该列表放入到聚合队列中;这样在后续的刷盘线程中,列表中的多个请求,就能进行一次性刷盘了,增大刷盘的数据块的大小,提升刷盘速度;当刷盘线程处理完一个请求列表的持久化逻辑之后,会依次对列表中个各个请求进行唤醒操作,使等待的测评线程进行返回;

内存级别的元数据结构设计


<![endif]--> 首先用一个二维数组来存储各个 topicId+queueId 对应的 DataMeta 对象,DataMeta 对象里面有一个 MetaItem 的列表,每一个 MetaItem 代表的一条消息,里面包含了消息所在的文件下标、文件位置、数据长度、以及缓存位置

SSD 上数据的存储结构


总共使用了 15 个 byte 来存储消息的元数据,消息的实际数据和元数据放在一起,这种混合存储的方式虽然看起来不太优雅,但比起独立存储,可以减少一半的 force 操作;

数据恢复

依次遍历读取各个数据文件,按照上述的数据存储协议生成内存级别的元数据信息,供后续查询时使用;

数据消费

数据消费时,通过 topic+queueId 从二维数组中定位到对应的 DataMeta 对象,然后根据 offset 和 fetchNum,从 MetaItem 列表中找到对应的 MetaItem 对象,通过 MetaItem 中所记录的文件存储信息,进行文件加载;总的来说,第一个版本在大方向上没有太大的问题,使用 queue 进行异步聚合和刷盘,让整个程序更加灵活,为后续的一些功能扩展打下了很好的基础;

缓存

60 个 G 的 AEP,我垂涎已久,国庆七天,没有出远门的计划,一定要好好卷一卷 llpl;下载了 llpl 的源码,一顿看,发现比我想象的要简单得多,本质上和用 unsafe 访问普通内存是一模一样的;卷完 llpl,缓存设计方案呼之欲出;

缓存分级

缓存的写入用了队列进行异步化,避免对主线程造成阻塞(到比赛后期才发现云 SSD 的奥秘,就算同步写也不会影响整体的速度,后面我会讲原因);程序可以用作缓存的存储介质有 AEP 和 Dram,两者在访问速度上有一定的差异,赛题所描述的场景中,会有大量的热读,因此我对缓存进行了分级,分为了 AEP 缓存和 Dram 缓存,Dram 缓存又分为了堆内缓存、堆外缓存、MMAP 缓存(后期加入),在申请缓存时,优先使用 Dram 缓存,提升高性能缓存的使用频度;

Dram 缓存最后申请了 7G,AEP 申请了 61G,Dram 的容量占比为 10%;本次比赛总共会读取(61+7)/2+50=84G 的数据,根据日志统计,整个测评过程中,有 30G 的数据使用了 Dram 缓存,占比 35%;因为前 75G 的数据不会有读取操作,没有缓存释放与复用动作,所以严格意义上来讲,在写入与查询混合操作阶段,总共使用了 50G 的缓存,其中滚动使用了 30-7/2=26.5G 的 Dram 缓存,占比 53%;

10%的容量占比,却滚动提供了 53%的缓存服务,说明热读现象非常严重,说明缓存分级非常有必要;

但是,现实总是残酷的,这些看似无懈可击的优化点在测评中作用并不大,毕竟这种优化只能提升查询速度,在读写混合阶段,读缓存总耗时是 10 秒或者是 20 秒,对最后的成绩其实没有任何影响!很神奇吧,后面我会讲原因;

缓存结构


当获取到一个缓存请求后,会根据 topic+queueId 从二维数组中获取到对应的缓存上下文对象;该对象中维护了一个缓存块列表、以及最后一个缓存块的写入指针位置;如果最后一个缓存块的余量足够放下当前的数据,则直接将数据写入缓存块;如果放不下,则申请一个新的缓存块,放在缓存块列表的最后,同时将写不下的数据放到新缓存块中;若申请不到新的缓存块,则直接按缓存写入失败进行处理;

在写完缓存后,需要将缓存的位置信息回写到内存中的 Meta 中;比如本条数据是从第三个缓存块中的 123B 开始写入的,则回写的缓存位置为:(3-1)*每个缓存块的大小+123;

在读取缓存数据时,按照 meta 数据中的缓存位置新,定位到对应的缓存块、以及块内位置,进行数据读取(需要考虑跨块的逻辑);

由于缓存的写入是单线程完成的,对于一个 queueId,前面的缓存块的消息一定早于后面的缓存块,所以当读取完缓存数据后,就可以将当前缓存块之前的所有缓存都释放掉(放入缓存资源池),这样 75G 中被跳过的那 37.5G 的数据也能快速地被释放掉;

缓存功能加上去后,成绩来到了 520 秒左右,程序的主体结构也基本完成了,接下来就是精装了。

优化

缓存准入策略

一个 32k 的缓存块,是放 2 个 16k 的数据合适,还是放 16 个 2k 的数据合适?毫无疑问是后者,将小数据块尽量都放到缓存中,可以使得最后只有较大的块才会查 ssd,减少查询时 ssd 的 io 次数;

那么阈值为多少时,可以保证小于该阈值的数据块放入缓存,能够使得缓存刚好被填满呢?(若不填满,缓存利用率就低了,若放不下,就会有小块的数据无法放缓存,读取时必须走 ssd,io 次数就上去了);

一般来说,通过多次参数调整和测评尝试,就能找到这个阈值,但是这种方式不具备通用性,如果总的可用的缓存大小出现变化,就又需要进行尝试了,不具备生产价值;

这个时候,中学时代的数学知识就派上用途了,如下图


由于消息的大小实际是以 100B 开始的,为了简化,直接按照从 0B 进行了计算,这样会导致算出来的阈值偏大,也就是最后会出现缓存存不下从而小块走 ssd 查询的情况,所以我在算出来的阈值上减去了 100B*0.75(由于影响不大,基本是凭直觉拍脑门的);如果要严格计算真正准确的阈值,需要将上图中的三角形面积问题,转换成梯形面积问题,但是感觉意义不大,因为 100B 本来就只有 17K 的 1/170,比例非常小,所以影响也非常的小,

梯形面积和三角形面积的比为:(17K+100)(17K-100)/(17k17K)=0.999965,完全在数据波动的范围之内;

在程序运行时,根据动态计算出来的阈值,大于该阈值的就直接跳过缓存的写入逻辑,最后不管缓存配置为多大,都能保证小于该阈值的数据块全部写入了缓存,且缓存最后的利用率达到 99.5%以上;

共享缓存

在刚开始的时候,按照算出来的阈值进行缓存规划,仍然会出现缓存容量不足的情况,实际用到的缓存的大小总是比总缓存块的大小小一些,通过各种排查,才恍然大悟,每个 queueId 所拥有的最后一个缓存块大概率是不会被写满的,宏观上来说,平均只会被写一半;一个缓存块是 32k,queueId 的数量大概是 20w,那么就会有 20w*32k/2=3G 的缓存没有被用到;3G/2=1.5G(前 75G 之后随机读一半,所以要除以 2),就算是顺序读大块,1.5G 也会带来 5 秒左右的耗时,更别说随机读了,所以不管有多复杂,这部分缓存一定要用起来;

既然自己用不完,那就共享出来吧,整体方案如下:



在缓存块用尽时,对所有的 queueId 的最后一个缓存块进行自增编号,然后放入到一个一维数组中,缓存块的编号,即为该块在以为数字中的下标;然后根据缓存块的余量大小,放到对应的余量集合中,余量大于等于 2k 小于 3k 的缓存块,放到 2k 的集合中,以此类推,余量大于最大消息体大小(赛题中为 17K)的块,统一放在 maxLen 的集合中;

当某一次缓存请求获取不到私有的缓存块时,将根据当前消息体的大小,从共享缓存集合中获取共享缓存进行写入;比如当前消息体大小为 3.5K,将会从 4K 的集合中获取缓存块,若获取不到,则继续从 5k 的集合中获取,依次类推,直到获取到共享缓存块,或者没有满足任何满足条件的缓存块为止;

往共享缓存块写入缓存数据后,该缓存块的余量将发生变化,需要将该缓存块从之前的集合中移除,然后放入新的余量集合中(若余量级别未发生变化,则不需要执行该动作);

访问共享缓存时,会根据 Meta 中记录的共享缓存编号,从索引数组中获取到对应的共享块,进行数据的读取;

在缓存的释放逻辑里,会直接忽略共享缓存块(理论上可以通过一个计数器来控制何时该释放一个共享缓存块,但实现起来比较复杂,因为要考虑到有些消息不会被消费的情况,且收益也不会太大(因为二阶段缓存是完全够用的),所以就没做尝试)

MMAP 缓存

测评程序的 jvm 参数不允许选手自己控制,这是拦在选手面前的一道障碍,由于老年代和年轻代之间的比例为 2 比 1,那意味着如果我使用 3G 来作为堆内缓存,加上内存中的 Meta 等对象,老年代基本要用 4G 左右,那就会有 2G 的新生代,这完全是浪费,因为该赛题对新生对新生代要求并不高;

所以为了避免浪费,一定要减少老年代的大小,那也就意味着不能使用太多的堆内缓存;由于堆外内存也被限定在了 2G,如果减小堆内的使用量,那空余的缓存就只能给系统做 pageCache,但赛题的背景下,pageCache 的命中率并不高,所以这条路也是走不通的。

有没有什么内存既不是堆内,申请时又不受堆外参数的限制?自然而然想到了 unsafe,当然也想到官方导师说的那句:用 unsafe 申请内存直接取消成绩。。。 这条路只好作罢

花了一个下午的时间,通读了 nio 相关的代码,意外发现 MappedByteBuffer 是不受堆外参数的限制的,这就意味着可以使用 MappedByteBuffer 来替代堆内缓存;由于缓存都会频繁地被进行写与读,如果使用 Write_read 模式,会导致刷盘动作,就得不偿失了,自然而然就想到了 PRIVATE 模式(copy on write),在该模式下,会在某个 4k 区首次写入数据时,和 pageCache 解耦,生成一个独享的内存副本;所以只要在程序初始化的时候,将 mmap 写一遍,就能得到一块独享的,和磁盘无关的内存了;

所以我将堆内缓存的大小配置成了 32M(因为该功能已经开发好了,所以还是要意思一下,用起来),堆外申请了 1700M(算上测评代码的 300M,差不多 2G)、mmap 申请了 5G;总共有 7G 的 Dram 作为了缓存(不使用 mmap 的话,大概只能用到 5G),内存中的 Meta 大概有 700M 左右,所以堆内的内存差不多在 1G 左右,2G+5G+1G=8G,操作系统给 200M 左右基本就够了,所以还剩 800M 没用,这 800M 其实是可以用来作为 mmap 缓存的,主要是考虑到大家都只能用 8G,超过 8G 容易被挑战,所以最后最优成绩里面总的内存的使用量并没有超过 8G;

基于末尾填补的 4K 对齐

由于 ssd 的写入是以 4K 为最小单位的,但每次聚合的消息的总大小又不是 4k 的整数倍,所以这会导致每次写入都会有额外的开销;

比较常规的方案是进行 4k 填补,当某一批数据不是 4k 对齐时,在末尾进行填充,保证写入的数据的总大小是 4k 的整数倍;听起来有些不可思议,额外写入一些数据会导致整体效益更高?

是的,推导逻辑是这样的:“如果不填补,下次写入的时候,一定会写这未满的 4k 区,如果填补了,下次写入的时候,只有 50%的概率会往后多写一个 4k 区(因为前面填补,导致本次数据后移,尾部多垮了一个 4k 区)”,所以整体来说,填补后会赚 50%;

或者换一个角度,填补对于当前的这次写入是没有副作用的(也就多 copy<4k 的数据),对于下一次写入也是没有副作用的,但是如果下一次写入是这种情况,就会因为填补而少写一个 4k;


基于末尾剪切的 4K 对齐

填补的方案确实能带来不错的提升,但是最后落盘的文件大概有 128G 左右,比实际的数据量多了 3 个 G,如果能把这 3 个 G 用起来,又是一个不小的提升;

自然而然就想到了末尾剪切的方案,将尾部未 4k 对齐的数据剪切下来,放到下一批数据里面,剪切下来的数据对应的请求,也在下一批数据刷盘的时候进行唤醒;

方案如下:


填补与剪切共存

剪切的方案固然优秀,但在一些极端的情况下,会存在一些消极的影响;比如聚合的一批数据整体大小没有操作 4k,那就需要扣留整批的请求了,在这一刻,这将变向导致刷盘线程大幅降低、请求线程大幅降低;对于这种情况,剪切对齐带来的优势,无法弥补扣留请求带来的劣势(基于直观感受),因此需要直接使用填补的方式来保证 4k 对齐;

严格意义上来讲,应该有一个扣留线程数代价、和填补代价的量化公式,以决定何种时候需要进行填补,何种时候需要进行剪切;但是其本质太过复杂,涉及到非同质因子的整合(要在磁盘吞吐、磁盘 io、测评线程耗时三个概念之间做转换);做了一些尝试,效果都不是很理想,没能跑出最高分;

当然中间还有一些边界处理,比如当 poll 上游数据超时的时候,需要将扣留的数据进行填充落盘,避免收尾阶段,最后一批扣留的数据得不到处理;

SSD 的预写

得此优化点者,得前 10,该优化点能大幅提升写入速度(280m/s 到 320m/s),这个优化点很多同学在一些技术贴上看到过,或者自己意外发现过,但是大部分人应该对本质的原因不甚了解;接下来我便循序渐进,按照自己的理解进行 yy 了;

假设某块磁盘上被写满了 1,然后文件都被删除了,这个时候磁盘上的物理状态肯定都还是 1(因为删除文件并不会对文件区域进行格式化);

然后你又新建了一个空白文件,将文件大小设置成了 1G(比如通过 RandomAccessFile.position(1G));这个时候这 1G 的区域对应的磁盘空间上仍然还是 1,因为在生产空白文件的时候也并不会对对应的区域进行格式化;

但是,当我们此时对这个文件进行访问的时候,读取到的会全是 0;这说明文件系统里面记载了,对于一个文件,哪些地方是被写过的,哪些地方是没有被写过的(以 4k 为单位),没被写过的地方会直接返回 0;这些信息被记载在一个叫做 inode 的东西上,inode 当然也是需要落盘进行持久化的;

所以如果我们不预写文件,inode 会在文件的某个 4k 区首次被写入时发生性变更,这将造成额外的逻辑开开销以及磁盘开销;

因此,在构造方法里面一顿 for 循环,按照预估的总文件大小,先写一遍数据,后续写入时就能起飞了;

这种优化点虽然没有什么技术含量,但是也是对选手知识广度的一种考验;

大消息体的优化策略

由于磁盘的读写都是以 4k 为单位,这就意味着读取一个 16k+2B 的数据,极端情况下会产生 16k+2*4k=24k 的磁盘 io,会多加载将近 8k 的数据;

显然如果能够在读取的时候都按 4k 对齐进行读取,且加载出来的数据都是有意义的(后续能够被用到),就能解决而上述的问题;我依次做了以下优化(有些优化点在后面被废弃掉了,因为它和一些其他更好的优化点冲突了)

1、大块置顶<![endif]--> 由于每一批聚合的消息都是 4k 对齐的落盘的(剪切扣留方案之前),所以我将每批数据中最大的那条消息放在了头部(基于缓存规划策略,大消息大概率是不会进缓存的,消费时会从 ssd 读取),这样这条消息至少有一端是 4k 对齐的,读取的时候能缓解 50%的对齐问题,该种方式在剪切扣留方案之前确实带来了 3 秒左右的提升 2、消息顺序重组通过算法,让大块数据尽量少地出现两端不对齐的情况,减少读取时额外的数据加载量;比如针对下面的例子:



在整理之前,加载三个大块总共会涉及到 8 个 4k 区,整理之后,就变成了 6 个;由于自己在算法这一块儿实在太弱了,加上这是一个 NP 问题,折腾了几个小时,效果总是差强人意,最后只好放弃;

3、基于内存的 pageCache 在数据读取阶段,每次加载数据时,若加载的数据两端不是 4k 对齐的,就主动向前后延伸打到 4k 对齐的地方;然后将首尾两个 4k 区放到内存里面,这样当后续要访问这些 4k 区的时候,就可以直接从内存里面获取了;

该方案最后的效果和预估的一样差,一点惊喜都没有;因为只会有少量的数据会走 ssd,首尾两个 4k 里面大概率都是那些不需要走 ssd 的消息,所以被复用的概率极小

4、部分缓存既然自己没能力对消息的存储顺序进行调整优化,那就把那些两端不对齐的数据剪下来放到缓存里面吧



某条消息在落盘的时候,若某一端(也有可能是两端)没有 4k 对齐,且在未对齐的 4k 区的数据量很少,就将其剪切下来存放到缓存里,这样查询的时候,就不会因为这少量的数据,去读取一个额外的 4k 区了;

剪切的阈值设置成了 1k,由于数据大小是随机的,所以从宏观上来看,剪切下来的数据片的平均大小为 0.5k,这意味着只需要使用 0.5k 的缓存,就能减少 4k 的 io,是常规缓存效益的 8 倍,加上缓存部分的余量分级策略,会导致有很多碎片化的小内存用不到,该方案刚好可以把这些碎片内存利用起来

测评线程的聚合策略

每次聚合多少条消息进行刷盘合适?是按消息条数进行聚合,还是按照消息的大小进行聚合?

刚开始的时候并没有想那么多,通过日志得知总共有 40 个线程,所以就写死了一次聚合 10 条,然后四个线程进行刷盘;但这会带来两个问题,一个是若线程数发生变化,性能会大幅下降;第二是在收尾阶段,会有一些跑得慢的线程还有不少数据未写入的情况,导致收尾时间较长,特别是加入了尾部剪切与扣留逻辑后,该现象尤为严重;

为了解决收尾耗时长的问题,我尝试了同步聚合的方案,在第一次写入之后的 500ms,对写入线程数进行统计,然后分组,后续就按组进行聚合;这种方式可以完美解决收尾的问题,因为同一个组里面的所有线程都是同时完成写入任务的,大概是因为每个线程的写入次数是固定的吧;但是使用这种方式,尾部剪切+扣留的逻辑就非常难融合进来了;加上在程序一开始就固定线程数,看起来也有那么一些不优雅;所以我就引入了“线程控制器”的概念


聚合策略迭代-针对剪切扣留方案的定向优化

假设当前动态计算出来的聚合数量是 10,对于聚合出来的 10 条消息,如果本批次被扣留了 2 条,下次聚合时应该聚合多少条?

在之前的策略里面,还是会聚合 10 条,这就意味着一旦出现了消息扣留,聚合逻辑就会产生抖动,会出现某个线程聚合不到指定的消息数据量的情况(这种情况会有 poll 超时方式进行兜底,但是整体速度就慢了)

所以聚合参数不能是一个单纯的、统一化的值,得针对不同的刷盘线程的扣留数,进行调整,假设聚合数为 n,某个刷盘线程的上批次扣留数量为 m,那针对这个刷盘线程的下批次的聚合数量就应该是 n-m;

那么问题就来了,聚合线程(生产者)只有一个,刷盘线程(消费者)有好几个,都是抢占式地进行消费,没办法将聚合到的特定数量的消息,给到指定的刷盘线程;所以聚合消息队列需要拆分,拆分成以刷盘线程为维度;

由于改动比较大,为了保留以前的逻辑,就引入了聚合数量的“严格模式”的概念,通过参数进行控制,如果是“严格模式”,就使用上述的逻辑,若不是,则使用之前的逻辑;

设计图如下



将聚合队列换成了聚合队列数组,在非严格模式下,数组里面的原始指向的是同一个队列对象,这样很多代码逻辑就能统一;

聚合线程需要先从扣留信息队列里面获取一个对象,然后根据扣留数和最新的聚合参数,决定要聚合多少条消息,聚合好消息后,放到扣留信息所描述的队列中


查看更多内容,欢迎访问天池技术圈官方地址:【参赛总结】第二届云原生编程挑战赛-冷热读写场景的RocketMQ存储系统设计 - Ninety Percent 战队_天池技术圈-阿里云天池

用户头像

还未添加个人签名 2024-03-12 加入

还未添加个人简介

评论

发布
暂无评论
【参赛总结】第二届云原生编程挑战赛-冷热读写场景的RocketMQ存储系统设计 - Ninety Percent 战队_阿里云天池_InfoQ写作社区