国际惯例,先报成绩,熬了无数个夜晚,最后依旧被绝杀出了第一页,最终排名第 21 名。前十名的成绩分布为 413.69~416.94,我最终的耗时是 422.43。成绩虽然不是特别亮眼,但与众多参赛选手使用 C++ 做为参赛语言不一样,我使用的是 Java,一方面是我 C++ 的能力早已荒废,另外一方面是我想验证一下使用 Java 编写存储引擎是否与 C++ 差距巨大(固然,主要仍是前者 QAQ)。因此在本文中,我除了介绍总体的架构以外,还会着重笔墨来探讨 Java 编写存储类型应用的一些最佳实践,文末会给出 github 的开源地址。java
比赛整体分红了初赛和复赛两个阶段,总体要求实现一个简化、高效的 kv 存储引擎node
初赛要求支持 Write、Read 接口。git
public abstract void write(byte[] key, byte[] value);
public abstract byte[] read(byte[] key);
复制代码
复赛在初赛题目基础上,还须要额外实现一个 Range 接口。github
public abstract void range(byte[] lower, byte[] upper, AbstractVisitor visitor);
复制代码
程序评测逻辑 分为2个阶段: 1)Recover 正确性评测: 此阶段评测程序会并发写入特定数据(key 8B、value 4KB)同时进行任意次 kill -9 来模拟进程意外退出(参赛引擎须要保证进程意外退出时数据持久化不丢失),接着从新打开 DB,调用 Read、Range 接口来进行正确性校验shell
2)性能评测数据库
语言限定:C++ & JAVA,一块儿排名apache
关于文件 IO 操做的一些基本常识,我已经在专题文章中进行了介绍,若是你没有浏览那篇文章,建议先行浏览一下:文件IO操做的一些最佳实践。再回归赛题,先对赛题中的几个关键词来进行解读。数组
key 为固定的 8 字节,所以可以使用 long 来表示。缓存
value 为 4kb,这节省了咱们很大的工做量,由于 4kb 的整数倍落盘是很是磁盘 IO 友好的。bash
value 为 4kb 的另外一个好处是咱们再内存作索引时,可使用 int 而不是 long,来记录数据的逻辑偏移量:LogicOffset = PhysicalOffset / 4096,能够将 offset 的内存占用量减小一半。
首先赛题明确表示会进行 kill -9 并验证数据的一致性,这加大了咱们在内存中作 write buffer 的难度。但它并无要求断电不丢失,这间接地阐释了一点:咱们可使用 pageCache 来作写入缓存,在具体代码中我使用了 PageCache 来充当数据和索引的写入缓冲(二者策略不一样)。同时这点也限制了参赛选手,不能使用 AIO 这样的异步落盘方式。
赛题分为了随机写,随机读,顺序读三个阶段,每一个阶段都会从新 open,且不会发生随机写到一半校验随机读这样的行为,因此咱们在随机写阶段不须要在内存维护索引,而是直接落盘。随机读和顺序读阶段,磁盘均存在数据,open 阶段须要恢复索引,可使用多线程并发恢复。
同时,赛题还有存在一些隐性的测评细节没有披露给你们,但经过测试,咱们能够得知这些信息。
虽然咱们可使用 PageCache,但评测程序在每一个阶段以后都使用脚本清空了 PageCache,而且将这部分时间也算进了最终的成绩之中,因此有人感到奇怪:三个阶段的耗时相加比输出出来的成绩要差,其实那几秒即是清空 PageCache 的耗时。
#清理 pagecache (页缓存)
sysctl -w vm.drop_caches=1
#清理 dentries(目录缓存)和 inodes
sysctl -w vm.drop_caches=2
#清理pagecache、dentries和inodes
sysctl -w vm.drop_caches=3
复制代码
这一点启发咱们,不能毫无节制的使用 PageCache,也正是由于这一点,必定程度上使得 Direct IO 这一操做成了本次竞赛的银弹。
这一个隐性条件可谓是本次比赛的关键,由于它涉及到 Range 部分的架构设计。本次比赛的 key 共计 6400w,可是他们的分布都是均匀的,在《文件IO操做的一些最佳实践》 一文中咱们已经提到了数据分区的好处,能够大大减小顺序读写的锁冲突,而 key 的分布均匀这一特性,启发咱们在作数据分区时,能够按照 key 的搞 n 位来作 hash,从而确保 key 两个分区之间总体有序(分区内部无序)。实际我尝试了将数据分红 102四、2048 个分区,效果最佳。
赛题要求 64 个线程 Range 两次全量的数据,限时 1h,这也启发了咱们,若是不对数据进行缓存,想要在 1h 内完成比赛是不可能的,因此,咱们的架构设计应该尽可能以 Range 为核心,兼顾随机写和随机读。Range 部分也是最容易拉开差距的一个环节。
首先须要明确的是,随机写指的是 key 的写入是随机的,但咱们能够根据 key hash,将随机写转换为对应分区文件的顺序写。
/** * using high ten bit of the given key to determine which file it hits. */
public class HighTenPartitioner implements Partitionable {
@Override
public int getPartition(byte[] key) {
return ((key[0] & 0xff) << 2) | ((key[1] & 0xff) >> 6);
}
}
复制代码
明确了高位分区的前提再来看总体的架构就变得明朗了
全局视角
分区视角
内存视角
内存中仅仅维护有序的 key[1024][625000]
数组和 offset[1024][625000]
数组。
上述两张图对总体的架构进行了一个很好的诠释,利用数据分布均匀的特性,能够将全局数据 hash 成 1024 个分区,在每一个分区中存放两类文件:索引文件和数据文件。在随机写入阶段,根据 key 得到该数据对应分区位置,并按照时序,顺序追加到文件末尾,将全局随机写转换为局部顺序写。利用索引和数据一一对应的特性,咱们也不须要将 data 的逻辑偏移量落盘,在 recover 阶段能够按照恢复 key 的次序,反推出 value 的逻辑偏移量。
在 range 阶段,因为咱们事先按照 key 的高 10 为作了分区,因此咱们能够认定一个事实,patition(N) 中的任何一个数据必定大于 partition(N-1) 中的任何一个数据,因而咱们能够采用大块读,将一个 partition 总体读进内存,供 64 个 visit 线程消费。到这儿便奠基了总体的基调:读盘线程负责按分区读盘进入内存,64 个 visit 线程负责消费内存,按照 key 的次序随机访问内存,进行 Visitor 的回调。
介绍完了总体架构,咱们分阶段来看一下各个阶段的一些细节优化点,有一些优化在各个环节都会出现,未避免重复,第二次出现的同一优化点我就不赘述了,仅一句带过。
主要看数据落盘,后讨论索引落盘。磁盘 IO 类型的比赛,第一步即是测量磁盘的 IOPS 以及多少个线程一次读写多大的缓存可以打满 IO,在固定 64 线程写入的前提下,16kb,64kb 都可以达到最理想 IOPS,因此理所固然的想到,能够为每个分区分配一个写入缓存,凑齐 4 个 value 落盘。可是这次比赛,要作到 kill -9 不丢失数据,不能简单地在内存中分配一个 ByteBuffer.allocate(4096 * 4);
, 而是能够考虑使用 mmap 内存映射出一片写入缓冲,凑齐 4 个刷盘,这样在 kill -9 以后,PageCache 不会丢失。实测 16kb 落盘比 4kb 落盘要快 6s 左右。
索引文件的落盘则没有太大的争议,因为 key 的数据量为固定的 8B,因此 mmap 能够发挥出它写小数据的优点,将 pageCache 利用起来,实测 mmap 相比 filechannel 写索引要快 3s 左右,相信若是把 polardb 这块盘换作其余普通的 ssd,这个数值还要增长。
一开始审题不清,在随机写以后误觉得会马上随机读,实际上每一个阶段都是独立的,因此不须要在写入时维护内存索引;其次,以前的架构图中也已经说起,不须要写入连带 key+offset 一块儿写入文件,recover 阶段能够按照恢复索引的顺序,反推出 data 的逻辑偏移,由于咱们的 key 和 data 在同一个分区内的位置是一一对应的。
recover 阶段的逻辑实际上包含在程序的 open 接口之中,咱们须要再数据库引擎启动时,将索引从数据文件恢复到内存之中,在这之中也存在一些细节优化点。
因为 1024 个分区的存在,咱们可使用 64 个线程 (经验值) 并发地恢复索引,使用快速排序对 key[1024][625000]
数组和 offset[1024][625000]
进行 sort,以后再 compact,对 key 进行去重。须要注意的一点是,不要使用结构体,将 key 和 offset 封装在一块儿,这会使得排序和以后的二分效率很是低,这之中涉及到 CPU 缓存行的知识点,不了解的读者能够翻阅我以前的博客: 《CPU Cache 与缓存行》
// wrong
public class KeyOffset {
long key;
int offset;
}
复制代码
整个 recover 阶段耗时为 1s,跟 cpp 选手交流后发现恢复流程比之慢了 600ms,这中间让我以为比较诡异,加载索引和排序不该该这么慢才对,最终也没有优化成功。
随机读流程没有太大的优化点,优化空间实在有限,实现思路即是先根据 key 定位到分区,以后在有序的 key 数据中二分查找到 key/offset,拿到 data 的逻辑偏移和分区编号,即可以愉快的随机读了,随机读阶段没有太大的优化点,但仍然比 cpp 选手慢了 2-3s,多是语言没法越过的差距。
Range 环节是整个比赛的大头,也是拉开差距的分水岭。前面咱们已经大概提到了 Range 的总体思路是一个生产者消费者模型,n 个生成者负责从磁盘读数据进入内存(n 做为变量,经过 benchmark 来肯定多少合适,最终实测 n 为 4 时效果最佳),64 个消费者负责调用 visit 回调,来验证数据,visit 过程就是随机读内存的过程。在 Range 阶段,剩余的内存还有大概 1G 左右,因此我分配了 4 个堆外缓冲,一个 256M,从而能够缓存 4 个分区的数据,而且,我为每个分区分配了一个读盘线程,负责 load 数据进入缓存,供 64 个消费者消费。
具体的顺序读架构能够参见下图:
大致来看,即是 4 个 fetch 线程负责读盘,fetch thread n 负责 partitionNo % 4 == n
编号的分区,完成后通知 visit 消费。这中间充斥着比较多的互斥等待逻辑,并未在图中体现出来,大致以下:
大块的加载读进缓存,最大程度复用,是 ReadSeq 部分的关键。顺序读两轮的成绩在 196~198s 左右,相比 C++ 又慢了 4s 左右。
这儿是个分水岭,介绍完了总体架构和四个阶段的细节实现,下面就是介绍下具体的优化点了。
因为此次比赛将 drop cache 的时间算进了测评程序之中,因此在没必要要的地方应当尽可能避免 pageCache,也就是说除了写索引以外,其余阶段不该该出现 pageCache。这对于 Java 选手来讲多是不小的障碍,由于 Java 原生没有提供 Direct IO,须要本身封装一套 JNA 接口,封装这套接口借鉴了开源框架 jaydio 的思路,感谢@尘央的协助,你们能够在文末的代码中看到实现细节。这一点能够说是拦住了一大票 Java 选手。
Direct IO 须要注意的两个细节:
这一点在《文件IO操做的一些最佳实践》中有所说起,堆外内存的两大好处是减小了一分内存拷贝,而且对 gc 友好,在 Direct IO 的实现中,应该配备一套堆外内存的接口,才能发挥出最大的功效。尤为在 Range 阶段,一个缓存区的大小便对应一个 partition 数据分区的大小:256M,大块的内存,更加适合用 DirectByteBuffer 装载。
-server -Xms2560m -Xmx2560m -XX:MaxDirectMemorySize=1024m -XX:NewRatio=4 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:-UseBiasedLocking
复制代码
众所周知 newRatio 控制的是 young 区和 old 区大小的比例,官方推荐参数为 -XX:NewRatio=1
,不少不注意的 Java 选手可能没有意识去修改它,会在无形中被 gc 拖累。通过和@阿杜的讨论,最终得出的结论:
在比赛中这显得尤其重要,-XX:NewRatio=4
放大老年代能够有效的减小 cms gc 的次数,将 126 次 cms gc,降低到最终的 5 次。
不管是 apache 的 ObjectPool 仍是 Netty 中的 Recycler,仍是 RingBuffer 中预先分配的对象,都在传达一种思想,对于那些反复须要 new 出来的东西,均可以池化,分配内存再回收,这也是一笔不小的开销。在这次比赛的场景下,不必大费周章地动用对象池,直接一个 ThreadLocal 便可搞定,事实上我对 key/value 的写入和读取都进行了 ThreadLocal 的缓存,作到了永远再也不循环中分配对象。
不管是网络 IO 仍是磁盘 IO,io worker 线程的时间片都显得尤其的难得,在个人架构中,range 阶段主要分为了两类线程:64 个 visit 线程并发随机读内存,4 个 io 线程并发读磁盘。木桶效应,咱们很容易定位到瓶颈在于 4 个 io 线程,在 wait/notify 的模型中,为了尽量的减小 io 线程的时间片流失,能够考虑使用 while(true) 进行轮询,而 visit 线程则能够 sleep(1us) 避免 cpu 空转带来的总体性能降低,因为评测机拥有 64 core,因此这样的分配算是较为合理的,为此我实现了一个简单粗暴的信号量。
public class LoopQuerySemaphore {
private volatile boolean permit;
public LoopQuerySemaphore(boolean permit) {
this.permit = permit;
}
// for 64 visit thread
public void acquire() throws InterruptedException {
while (!permit) {
Thread.sleep(0,1);
}
permit = false;
}
// for 4 fetch thread
public void acquireNoSleep() throws InterruptedException {
while (!permit) {
}
permit = false;
}
public void release() {
permit = true;
}
}
复制代码
正确的在 IO 中 acquireNoSleep,在 Visit 中 acquire,可让成绩相比使用普通的阻塞 Semaphore 提高 6s 左右。
线上机器的抖动在所不免,避免 IO 线程的切换也并不只仅可以用依靠 while(true) 的轮询,一个 CPU 级别的优化即是腾出 4 个核心专门给 IO 线程使用,彻底地避免 IO 线程的时间片争用。在 Java 中这也不难实现,依赖万能的 github,咱们能够轻松地实现 Affinity。github 传送门:github.com/OpenHFT/Jav…
使用方式:
try (final AffinityLock al2 = AffinityLock.acquireLock()) {
// do fetch ...
}
复制代码
这个方式可让你的代码快 1~2 s,而且保持测评的稳定性。
我在最终版本的代码中,几乎彻底抛弃了 FileChannel,事实上,在不 Drop Cache 的场景下,它已经能够发挥出它利用 PageCache 的一些优点,而且优秀的 Java 存储引擎都主要使用了 FileChannel 来进行读写,在少许的场景下,使用了 MMAP 做为辅助,毕竟,MMAP 在写小数据量文件时存在其价值。
另外须要注意的一点,在跟@96年的亚普长谈的一个夜晚,发现 FileChannel 中出人意料的一个实现,在分配对内内存时,它仍然会拷贝一份堆外内存,这对于实际使用 FileChannel 的场景须要额外注意,这部分意料以外分配的内存很容易致使线上的问题(实际上已经遇到了,和 glibc 的 malloc 相关,当 buffer 大于 128k 时,会使用 mmap 分配一块内存做为缓存)
说回 FileChannel,MMAP,最容易想到的是 RocketMQ 之中对二者灵活的运用,不知道在其余 Java 实现的存储引擎之中,是否是能够考虑使用 Direct IO 来提高存储引擎的性能呢?咱们能够设想一下,利用有限而且少许的 PageCache 来保证一致性,在主流程中使用 Direct IO 配合顺序读写是否是一种能够配套使用的方案,不只仅 PolarDB,算做是参加本次比赛给予个人一个启发。
虽然无缘决赛,但使用 Java 取得这样的成绩还算不是特别难过,在 6400w 数据随机写,随机读,顺序读的场景下,Java 能够作到仅仅相差 C++ 不到 10s 的 overhead,我却是以为彻底是能够接受的,哈哈。还有一些小的优化点就不在此赘述了,欢迎留言与我交流优化点和比赛感悟。
github 地址:github.com/lexburner/k…
欢迎关注个人微信公众号:「Kirito的技术分享」,关于文章的任何疑问都会获得回复,带来更多 Java 相关的技术分享。