关联比赛: 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 战队_天池技术圈-阿里云天池
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。