作者:Ninety Percent
悸动
32 岁,码农的倒数第二个本命年,平淡无奇的生活总觉得缺少了点什么。
想要去创业,却害怕家庭承受不住再次失败的挫折,想要生二胎,带娃的压力让我想着还不如去创业;所以我只好在生活中寻找一些小感动,去看一些老掉牙的电影,然后把自己感动得稀里哗啦,去翻一些泛黄的书籍,在回忆里寻找一丝丝曾经的深情满满;去学习一些冷门的知识,最后把自己搞得晕头转向,去参加一些有意思的比赛,捡起那 10 年走来,早已被刻在基因里的悸动。
那是去年夏末的一个傍晚,我和同事正闲聊着西湖的美好,他们说看到了阿里云发布云原生编程挑战赛,问我要不要试试。我说我只有九成的把握,另外一成得找我媳妇儿要;那一天,我们绕着西湖走了好久,最后终于达成一致,Ninety Percent 战队应运而生,云原生 MQ 的赛道上,又多了一个艰难却坚强的选手。
人到中年,仍然会做出一些冲动的决定,那种屁股决定脑袋的做法,像极了领导们的睿智和 18 岁时我朝三暮四的日子;夏季的 ADB 比赛,已经让我和女儿有些疏远,让老婆对我有些成见;此次参赛,必然是要暗度陈仓,卧薪尝胆,不到关键时刻,不能让家里人知道我又在卖肝。
开工
你还别说,或许是人类的本性使然,这种背着老婆偷偷干坏事情的感觉还真不错,从上路到上分,一路顺风顺水,极速狂奔;断断续续花了大概两天的时间,成功地在 A 榜拿下了 first blood;再一次把第一名和最后一名同时纳入囊中;快男总是不会让大家失望了,800 秒的成绩,成为了比赛的 base line。
第一个版本并没有做什么设计,基本上就是拍脑门的方案,目的就是把流程跑通,尽快出分,然后在保证正确性的前提下,逐步去优化方案,避免一开始就过度设计,导致迟迟不能出分,影响士气。
整体设计
先回顾下赛题:Apache RocketMQ 作为一款分布式的消息中间件,历年双十一承载了万亿级的消息流转,其中,实时读取写入数据和读取历史数据都是业务常见的存储访问场景,针对这个混合读写场景进行优化,可以极大的提升存储系统的稳定性。
基本思路是:当 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 从二维数组中获取到对应的缓存上下文对象;该对象中维护了一个缓存块列表、以及最后一个缓存块的写入指针位置;如果最后一个缓存块的余量足够放下当前的数据,则直接将数据写入缓存块;如果放不下,则申请一个新的缓存块,放在缓存块列表的最后,同时将写不下的数据放到新缓存块中;若申请不到新的缓存块,则直接按缓存写入失败进行处理。
在写完缓存后,需要将缓存的位置信息回