天池中间件大赛百万队列存储设计总结【复赛】

维持了 20 天的复赛终于告一段落了,国际惯例先说结果,复赛结果不太理想,一度从第 10 名掉到了最后的第 36 名,主要是写入的优化卡了 5 天,一直没有进展,最终排名也是定格在了排行榜的第二页。痛定思痛,这篇文章将本身复赛中学习的知识,成功的优化,未成功的优化都罗列一下。java

最终排名

赛题介绍

题面描述很简单:使用 Java 或者 C++ 实现一个进程内的队列引擎,单机可支持 100 万队列以上。linux

public abstract class QueueStore {
    abstract void put(String queueName, byte[] message);
    abstract Collection<byte[]> get(String queueName, long offset, long num);
}
复制代码

编写如上接口的实现。git

put 方法将一条消息写入一个队列,这个接口须要是线程安全的,评测程序会并发调用该接口进行 put,每一个queue 中的内容按发送顺序存储消息(能够理解为 Java 中的 List),同时每一个消息会有一个索引,索引从 0 开始,不一样 queue 中的内容,相互独立,互不影响,queueName 表明队列的名称,message 表明消息的内容,评测时内容会随机产生,大部分长度在 58 字节左右,会有少许消息在 1k 左右。算法

get 方法从一个队列中读出一批消息,读出的消息要按照发送顺序来,这个接口须要是线程安全的,也即评测程序会并发调用该接口进行 get,返回的 Collection 会被并发读,但不涉及写,所以只须要是线程读安全就能够了,queueName 表明队列的名字,offset 表明消息的在这个队列中的起始索引,num 表明读取的消息的条数,若是消息足够,则返回 num 条,不然只返回已有的消息便可,若消息不足,则返回一个空的集合。apache

评测程序介绍数组

  1. 发送阶段:消息大小在 58 字节左右,消息条数在 20 亿条左右,即发送总数据在 100G 左右,总队列数 100w
  2. 索引校验阶段:会对全部队列的索引进行随机校验;平均每一个队列会校验1~2次;(随机消费)
  3. 顺序消费阶段:挑选 20% 的队列进行所有读取和校验; (顺序消费)
  4. 发送阶段最大耗时不能超过 1800s;索引校验阶段和顺序消费阶段加在一块儿,最大耗时也不能超过 1800s;超时会被判断为评测失败。
  5. 各个阶段线程数在 20~30 左右

测试环境为 4c8g 的 ECS,限定使用的最大 JVM 大小为 4GB(-Xmx 4g)。带一块 300G 左右大小的 SSD 磁盘。对于 Java 选手而言,可以使用的内存能够理解为:堆外 4g 堆内 4g。缓存

赛题剖析

首先解析题面,接口描述是很是简单的,只有一个 put 和一个 get 方法。须要注意特别注意下评测程序,发送阶段须要对 100w 队列,每一次发送的量只有 58 字节,最后总数据量是 100g;索引校验和顺序消费阶段都是调用的 get 接口,不一样之处在于前者索引校验是随机消费,后者是对 20% 的队列从 0 号索引开始进行全量的顺序消费,评测程序的特性对最终存储设计的影响是相当重要的。安全

复赛题目的难点之一在于单机百万队列的设计,据查阅的资料显示微信

  • Kafka 单机超过 64 个队列/分区,Kafka 分区数不宜过多
  • RocketMQ 单机支持最高 5 万个队列

至于百万队列的使用场景,只能想到 IOT 场景有这样的需求。相较于初赛,复赛的设计更加地具备不肯定性,排名靠前的选手可能会选择截然不同的设计方案。数据结构

复赛的考察点主要有如下几个方面:磁盘块读写,读写缓冲,顺序读写与随机读写,pageCache,稀疏索引,队列存储设计等。

因为复赛成绩并非很理想,优化 put 接口的失败是致使失利的罪魁祸首,最终成绩是 126w TPS,而第一梯队的 TPS 则是到达了 200 w+ 的 TPS。鉴于此,不太想像初赛总结那样,按照优化历程罗列,而是将本身作的方案预研,以及设计思路分享给你们,对文件 IO 不甚了解的读者也能够将此文当作一篇科普向的文章来阅读。

思路详解

肯定文件读写方式

做为忠实的 Java 粉丝,天然选择使用 Java 来做为参赛语言,虽然最终的排名是被 Cpp 大佬所垄断,但着实无奈,毕业后就把 Cpp 丢到一边去了。Java 中的文件读写接口大体能够分为三类:

  1. 标准 IO 读写,位于 java.io 包下,相关类:FileInputStream,FileOuputStream
  2. NIO 读写,位于 java.nio 包下,相关类:FileChannel,ByteBuffer
  3. Mmap 内存映射,位于 java.nio 包下,相关类:FileChannel,MappedByteBuffer

标准 IO 读写不具有调研价值,直接 pass,因此 NIO 和 Mmap 的抉择,成了第一步调研对象。

第一阶段调研了 Mmap。搜索一圈下来发现,几乎全部的文章都一致认为:Mmap 这样的内存映射技术是最快的。不少没有接触过内存映射技术的人可能还不太清楚这是一种什么样的技术,简而言之,Mmap 可以将文件直接映射到用户态的内存地址,使得对文件的操做再也不是 write/read,而转化为直接对内存地址的操做。

public void test1() throws Exception {
    String dir = "/Users/kirito/data/";
    ensureDirOK(dir);
    RandomAccessFile memoryMappedFile;
    int size = 1 * 1024 * 1024;
    try {
        memoryMappedFile = new RandomAccessFile(dir + "testMmap.txt", "rw");
        MappedByteBuffer mappedByteBuffer = memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, size);
        for (int i = 0; i < 100000; i++) {
            mappedByteBuffer.position(i * 4);
            mappedByteBuffer.putInt(i);
        }
        memoryMappedFile.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}
复制代码

如上的代码呈现了一个最简单的 Mmap 使用方式,速度也是没话说,一个字:快!我怀着将信将疑的态度去找了更多的佐证,优秀的源码老是第一参考对象,观察下 RocketMQ 的设计,能够发现 NIO 和 Mmap 都出如今了源码中,但更多的读写操做彷佛更加青睐 Mmap。RocketMQ 源码 org.apache.rocketmq.store.MappedFile 中两种写方法同时存在,请教 @匠心零度 后大概得出结论:RocketMQ 主要的写是经过 Mmap 来完成。

两种写入方式

可是在实际使用 Mmap 来做为写方案时遇到了两大难题,单纯从使用角度来看,暴露出了 Mmap 的局限性:

  1. Mmap 在 Java 中一次只能映射 1.5~2G 的文件内存,但实际上咱们的数据文件大于 100g,这带来了第一个问题:要么须要对文件作物理拆分,切分红多文件;要么须要对文件映射作逻辑拆分,大文件分段映射。RocketMQ 中限制了单文件大小来避免这个问题。

文件作物理拆分

  1. Mmap 之因此快,是由于借助了内存来加速,mappedByteBuffer 的 put 行为实际是对内存进行的操做,实际的刷盘行为依赖于操做系统的定时刷盘或者手动调用 mappedByteBuffer.force() 接口来刷盘,不然将会致使机器卡死(实测后的结论)。因为复赛的环境下内存十分有限,因此使用 Mmap 存在较难的控制问题。

rocketmq存在定时force线程

通过这么一折腾,再加上资料的搜集,最终肯定,Mmap 在内存较为富足而且数据量小的场景下存在优点(大多数文章的结论认为 Mmap 适合大文件的读写,私觉得是不严谨的结论)。

第二阶段调研 Nio 的 FileChannel,这也是我最终肯定的读写方案。

因为每一个消息只有 58 字节左右,直接经过 FileChannel 写入必定会遇到瓶颈,事实上,若是你这么作,复赛连成绩估计都跑不出来。另外一个说法是 ssd 最小的写入单位是 4k,若是一次写入低于 4k,实际上耗时和 4k 同样。这里涉及到了赛题的一个重要考点:块读写。

云盘ssd写入性能

根据阿里云的 ssd 云盘介绍,只有一次写入 16kb ~ 64kb 才能得到理想的 IOPS。文件系统块存储的特性,启发咱们须要设置一个内存的写入缓冲区,单个消息写入内存缓冲区,缓冲区满,使用 FileChannel 进行刷盘。通过实践,使用 FileChannel 搭配缓冲区发挥的写入性能和内存充足状况下的 Mmap 并没有区别,而且 FileChannel 对文件大小并没有限制,控制也相对简单,因此最终肯定使用 FileChannel 进行读写。

肯定存储结构和索引结构

因为赛题的背景是消息队列,评测 2 阶段的随机检测以及 3 阶段的顺序消费一次会读取多条连续的消息,而且 3 阶段的顺序消费是从队列的 0 号索引一直消费到最后一条消息,这些因素都启发咱们:应当将同一个队列的消息尽量的存到一块儿。前面一节提到了写缓冲区,便和这里的设计很是契合,例如咱们能够一个队列设置一个写缓冲区(比赛中 Java 拥有 4g 的堆外内存,100w 队列,一个队列使用 DirectByteBuffer 分配 4k 堆外内存 ,能够保证缓冲区不会爆内存),这样同一个缓冲区的消息一块儿落盘,就保证了块内消息的顺序性,即作到了”同一个队列的消息尽量的存到一块儿“。按块存取消息目前看来有两个优点:

  1. 按条读取消息=>按块读取消息,发挥块读的优点,减小了 IO 次数
  2. 全量索引=>稀疏索引。块内数据是连续的,因此只须要记录块的物理文件偏移量+块内消息数便可计算出某一条消息的物理位置。这样大大下降了索引的数量,稍微计算一下能够发现,彻底可使用一个 Map 数据结构,Key 为 queueName,Value 为 List 在内存维护队列块的索引。若是按照传统的设计方案:一个 queue 一个索引文件,百万文件必然会超过默认的系统文件句柄上限。索引存储在内存中既规避了文件句柄数的问题,速度也没必要多数,文件 IO 和 内存 IO 不是一个量级。

因为赛题规定消息体是非定长的,大多数消息 58 字节,少许消息 1k 字节的数据特性,因此存储消息体时使用 short+byte[] 的结构便可,short 记录消息的实际长度,byte[] 记录完整的消息体。short 比 int 少了 2 个字节,2*20亿消息,能够减小 4g 的数据量。

稠密索引

稠密索引是对全量的消息进行索引,适用于无序消息,索引量大,数据能够按条存取。

稀疏索引

稀疏索引适用于按块存储的消息,块内有序,适用于有序消息,索引量小,数据按照块进行存取。

因为消息队列顺序存储,顺序消费的特性,加上 ssd 云盘最小存取单位为 4k(远大于单条消息)的限制,因此稀疏索引很是适用于这种场景。至于数据文件,能够作成参数,根据实际测试来判断究竟是多文件效果好,仍是单文件,此方案支持 100g 的单文件。

内存读写缓冲区

在稀疏索引的设计中,咱们提到了写入缓冲区的概念,根据计算能够发现,100w 队列若是一个队列分配一个写入缓冲区,最多只能分配 4k,这刚好是最小的 ssd 写入块大小(但根据以前 ssd 云盘给出的数据来看,一次写入 64k 才能打满 io)。

一次写入 4k,这致使物理文件中的块大小是 4k,在读取时一次一样读取出 4k。

// 写缓冲区
private ByteBuffer writeBuffer = ByteBuffer.allocateDirect(4 * 1024);
// 用 short 记录消息长度
private final static int SINGLE_MESSAGE_SIZE = 2;

public void put(String queueName,byte[] message){
    // 缓冲区满,先落盘
    if (SINGLE_MESSAGE_SIZE + message.length  > writeBuffer.remaining()) {
        // 落盘
        flush();
    }
    writeBuffer.putInt(SINGLE_MESSAGE_SIZE);
    writeBuffer.put(message);
    this.blockLength++;
}
复制代码

不足 4k 的部分能够选择补 0,也能够跳过。评测程序保证了在 queue 级别的写入是同步的,因此对于同一个队列,咱们没法担忧同步问题。写入搞定以后,一样的逻辑搞定读取,因为 get 操做是并发的,2阶段和3阶段会有 10~30 个线程并发消费同一个队列,因此 get 操做的读缓冲区能够设计成 ThreadLocal<ByteBuffer> ,每次使用时 clear 便可,保证了缓冲区每次读取时都是崭新的,同时减小了读缓冲区的建立,不然会致使频繁的 full gc。读取的伪代码暂时不贴,由于这样的 get 方案不是最终方案。

到这里总体的设计架构已经出来了,写入流程和读取流程的主要逻辑以下:

写入流程:

put流程

读取流程:

读取流程

内存读缓存优化

方案设计通过好几回的推翻重来,才算是肯定了上述的架构,这样的架构优点在于很是简单明了,实际上个人初版设计方案的代码量是上述方案代码量的 2~3 倍,但实际效果却不理想。上述架构的跑分红绩大概能够达到 70~80w TPS,只能算做是第三梯队的成绩,在此基础上,进行了读取缓存的优化才达到了 126w 的 TPS。在介绍读取缓存优化以前,先容我介绍下 PageCache 的概念。

PageCache

Linux 内核会将它最近访问过的文件页面缓存在内存中一段时间,这个文件缓存被称为 PageCache。如上图所示。通常的 read() 操做发生在应用程序提供的缓冲区与 PageCache 之间。而预读算法则负责填充这个PageCache。应用程序的读缓存通常都比较小,好比文件拷贝命令 cp 的读写粒度就是 4KB;内核的预读算法则会以它认为更合适的大小进行预读  I/O,好比 16-128KB。

因此通常状况下咱们认为顺序读比随机读是要快的,PageCache 即是最大的功臣。

回到题目,这简直 nice 啊,由于在磁盘中同一个队列的数据是部分连续(同一个块则连续),实际上一个 4KB 块中大概能够存储 70 多个数据,而在顺序消费阶段,一次的 offset 通常为 10,有了 PageCache 的预读机制,7 次文件 IO 能够减小为 1 次!这但是不得了的优化,可是上述的架构仅仅只有 70~80w 的 TPS,这让我产生了疑惑,通过多番查找资料,最终在 @江学磊 的提醒下,才定位到了问题。

linux io

两种可能致使比赛中没法使用 pageCache 来作缓存

  1. 因为我使用 FIleChannel 进行读写,NIO 的读写可能走的正是 Direct IO,因此根本不会通过 PageCache 层。
  2. 测评环境中内存有限,在 IO 密集的状况下 PageCache 效果微乎其微。

虽说不肯定究竟是何种缘由致使 PageCache 没法使用,可是个人存储方案仍然知足顺序读取的特性,彻底能够本身使用堆外内存本身模拟一个“PageCache”,这样在 3 阶段顺序消费时,TPS 会有很是高的提高。

一个队列一个读缓冲区用于顺序读,又要使得 get 阶段不存在并发问题,因此我选择了复用读缓冲区,而且给 get 操做加上了队列级别的锁,这算是一个小的牺牲,由于 2 阶段不会发生冲突,3 阶段冲突几率也并不大。改造后的读取缓存方案以下:

读取流程-优化

通过缓存改造以后,使用 Direct IO 也能够实现相似于 PageCache 的优化,而且会更加的可控,不至于形成频繁的缺页中断。通过这个优化,加上一些 gc 的优化,能够达到 126w TPS。总体方案算是介绍完毕。

其余优化

还有一些优化对总体流程影响不大,拎出来单独介绍。

2 阶段的随机索引检测和 3 阶段的顺序消费能够采起不一样的策略,2 阶段能够直接读取所须要的数据,而不须要进行缓存(由于是随机检测,因此读缓存确定不会命中)。

将文件数作成参数,调整参数来判断究竟是多文件 TPS 高仍是单文件,实际上测试后发现,差距并非很大,单文件效果略好,因为是 ssd 云盘,又不存在磁头,因此真的不太懂原理。

gc 优化,能用数组的地方不要用 List。尽可能减小小对象的出现,能够用数组管理基本数据类型,小对象对 gc 很是不友好,不管是初赛仍是复赛,Java 比 Cpp 始终差距一个垃圾回收机制。必须保证全程不出现 full gc。

失败的优化与反思

本次比赛算是留下了不小的遗憾,由于写入的优化一直没有作好,读取缓存作好以后我 2 阶段和 3阶段的总耗时相加是 400+s,算是不错的成绩,可是写入耗时在 1300+s。我上述的方案采用的是多线程同步刷盘,但也尝试过以下的写入方案:

  1. 异步提交写缓冲区,单线程直接刷盘
  2. 异步提交写缓冲区,设置二级缓冲区 64k~64M,单线程使用二级缓冲区刷盘
  3. 同步将写缓冲区的数据拷贝至一个 LockFreeQueue,单线程平滑消费,以打满 IOPS
  4. 每 16 个队列共享一个写入缓冲区,这样控制写入缓冲区能够达到 64k,在刷盘时进行排序,将同一个 queue 的数据放置在一块儿。

但都以失败了结,没有 get 到写入优化的要领,算是本次比赛最大的遗憾了。

还有一个失误在于,评测环境使用的云盘 ssd 和个人本地 Mac 下的 ssd 存储结构差距太大,加上 mac os 和 Linux 的一些差距,致使本地成功的优化在线上彻底体现不出来,仍是租个阿里云环境比较靠谱。

另外一方面的反思,则是对存储和 MQ 架构设计的不熟悉,对于 Kafka 和 RocketMQ 所作的一些优化也都是现学现用,不太肯定用的对不对,致使走了一些弯路,而比赛中认识的一个 96 年的小伙子王亚普,相比之下对中间件知识理解的深度和广度实在令我钦佩,实在还有不少知识须要学习。

参赛感悟

第一感觉是累,第二感觉是爽。相信不少选手和我同样是工做党,白天工做,只能腾出晚上的时间去搞比赛,对于966 的我真是太不友好了,初赛时间延长了一次还算给缓了一口气,复赛一眨眼就过去了,想翻盘都没机会,实在是遗憾。爽在于此次比赛真的是汗快淋漓地实践了很多中间件相关的技术,初赛的 Netty,复赛的存储设计,都是难以忘怀的回忆,比赛中也认识了很多朋友,有学生党,有工做党,感谢大家不厌其烦的教导与发人深省的讨论,从不一样的人身上是真的能够学到不少本身缺失的知识。

据消息说,阿里中间件大赛颇有多是最后一届,不管是由于什么缘由,做为参赛者,我都感到深深的可惜,但愿还能有机会参加下一届的中间件大赛,也期待能看到更多的相同类型的赛事被各大互联网公司举办,和大佬们同台竞技,一边认识更多新朋友的感受真棒。

虽然最终无缘决赛,但仍是期待进入决赛的 11 位选手能带来一场精彩的答辩,也好解答我始终优化失败的写入方案。后续会考虑吸取下前几名 JAVA 的优化思路,整理成最终完善的方案。 目前方案的 git 地址,仓库已公开:code.aliyun.com/250577914/q…

欢迎关注个人微信公众号:「Kirito的技术分享」,关于文章的任何疑问都会获得回复,带来更多 Java 相关的技术分享。

关注微信公众号
相关文章
相关标签/搜索