内容简介:参加本文是解题报告,源码地址不采用LSM-tree模型,利用WisckKey论文的思想,做key、value分离。如下图所示。
参加 天池大赛-阿里云第一届PolarDB数据库性能大赛 ,比赛以NVME Optane SSD为背景,在此之上开发单机存储引擎比拼性能,支持C++和 Java 语言。我的完赛成绩是Java语言排名第一,总排名20(共1653人参赛,队伍名称:neoremind),与C++第一差距在2.1%(<9s)。众所周知,类似的系统如果想榨干硬件,那么越贴近底层越好,Java存在一些天然的劣势,但是作为这么多年的资深JAVAer,还是想挑战一把。
本文是解题报告,源码地址 https://github.com/neoremind/2018-polar-race ,其中的架构设计是通用的,只是实现语言是Java,如果换做C++成绩应该会提升。
1. 赛题介绍
使用Intel Optane SSD作为存储,cgroup限制内存3G(针对Java)。实现一个简化、高效的kv存储引擎,支持Write、Read、Range接口。
评测程序分为2个阶段:
1. 正确性评测
此阶段评测程序会并发写入特定数据(key 8B、value 4KB)同时进行任意次kill -9来模拟进程意外退出(参赛引擎需要保证进程意外退出时数据持久化不丢失),接着重新打开DB,调用Read、Range接口来进行正确性校验。
2. 性能评测
2.1 随机写入:64个线程并发随机写入,每个线程使用Write各写100万次随机数据(key 8B、value 4KB)。
2.2 随机读取:64个线程并发随机读取,每个线程各使用Read读取100万次随机数据。
2.3 顺序读取:64个线程并发顺序读取,每个线程使用Range全局顺序迭代DB数据2次。
补充下,
1)每个阶段结束后都会清page cache,清理时间也算在总时长里。
2)Read、Range会验证key、value是否match,Range验证是否保序。
2. 实现前的思考和最终成绩
题目要求开发一个单机KV引擎,保证高吞吐写,低延迟点查,范围查询,同时保证crash consistency。第一个蹦出来的想法就是WAL+LSM-tree实现的leveldb和rocksdb,但是这两个单机引擎都是针对通用的场景,众所周知,LSM-tree的架构把random write转成sequential write,多层的compaction和lookup,存在写放大和读放大,在HDD时代,这个虽然是劣势,但是比起磁盘随机写比顺序写高1000x的代价,也是值了,在SDD时代,这个代价并没有那么高,更何况SDD的并发读写性能优秀,所以在比赛中直接用LSM-tree不可取。
回归题目,抓重点,1)定长kv,2)大value 4K,3)64并发查询。
顺着LSM-tree的思路,联想到一篇论文 “WiscKey: Separating Keys from Values in SSD-Conscious Storage” ,这是Wisconsin大学在2016年发表的论文,主要讲了如何在SSD上优化leveldb,包括减少读写放大,最大化利用bandwidth,充分利用SSD的一些特点,包括顺序IO高吞吐、随机并发性能出色等。这种在SSD上优化的kv分离存储结构的思想,很契合题目: 大Value和并发查询,我结合这个论文的见解和思想,整合出了解题的存储设计和引擎实现。而剩下的定长kv要求,则进一步简化了实现难度。
比赛的目标是比耗时最短,所以就变成如何榨干IO,达到最大的吞吐。
先计算下,随机写总量,(4k+8byte)*64*100w=256G,随机读256G,Range两次扫512G。根据 Intel Optane SSD官方 给出的一些数据,顺序写2G/s,顺序读2.4G/s,随机读 55w IOPS,随机写50w IOPS,读写延迟10us。比赛结果顺序写和随机读的吞吐和IOPS都 比官方值略高 ,实测顺序写2.2G/s,随机读2.5G/s,顺序读还会更快一些。理论计算极限大概410s左右。
第一名C++选手的完赛成绩:
413.69s(Write 116s + Read 103s + Range 193s) Write throughput:2.21G/s,Read throughput:2.49G/s,Range throughput:2.65G/s
基本已经榨干磁盘。
我是用Java实现的第一名,完赛成绩:
422.31s(Write 116s + Read 109s + Range 196s) Write throughput:2.21G/s,Read throughput:2.35G/s,Range throughput:2.61G/s
3. 存储设计
不采用LSM-tree模型,利用WisckKey论文的思想,做key、value分离。如下图所示。
wal(Write Ahead Log)存key和value在vlog中的offset,vlog是顺序写入的value,wal和vlog都是append-only的定长写入,所以wal只用存vlog的sequence,vlog seq用4byte Int表示存储,大尾端/小尾端程序自己定,然后乘以4096就是在vlog文件中的偏移量。关于vlog的gc问题题目不要求删除故不考虑。
wal和vlog都是顺序IO写入,不存在LSM-tree模型的写放大问题。
由于kv分离,写入必须lock,有锁就会限制性能,由于是随机写入,所以按照分治的思路,减少冲突即可,数据要分片sharding。我的策略是按照key的字典序分成1024个分片。把key的第一个字节8byte+第二个字节的前2个bit取出,转成int,经过分区函数就可以路由到正确的分片上。
4. 实现分析-Write
1024分片,单分片加锁写,流程如下
synchronized(lock) { write vlog 4k; write wal 8byte + 4byte vlog seq; vlog seq++; }
可选择的IO方式有buffer io和direct io,而buffer io又可以考虑vfs read/write和mmap两种方式。
wal采用mmap方式写入,好处在于,第一,减少了一次内存拷贝,Java的FileChannel写入会经过byte[]->offheap direct memory->kernel page cache->disk的通路,而mmap则直接byte[]->内存映射地址(也算page cache)->disk,mmap直接是把文件映射到user space可访问的内存,读写都直接操作内存,不write through disk,仅需要一次mmap系统调用即可,close db的时候truncate掉后面的无用bytes;第二,crash consistency的保证,由于题目仅进行kill -9,不进行掉电,利用Page Cache,不同步刷盘保证数据一致性,而第二次启动由于vlog seq都是递增的,所以读wal遇到0x00000000,则可以丢弃后面的数据。
总的wal文件大小=(8byte key+4byte vlog seq)*64并发*100w=768MB。由于评测程序足够随机,每个wal大小=768MB/1024=750KB,所以每个分片可以直接通过mmap,映射为 12byte<<16
大小的文件,通过ensure capacity来做re-mmap,这样可以兼容正确性测试中的文件大小不平均,正确性程序的写入集中在5个分片中。
单个vlog文件大小=4k*6400w/1024=256MB。
vlog如果采用Java的FileChannel的,则相比C++多一次从heap到offheap direct memory的拷贝,走Page Cache后续 echo 3 >/proc/sys/vm/drop_caches
也算时间,故采用direct io。由于JDK并不提供direct io的API,可选择的有直接用 JNA类库 ,通过JNI方式调用,或者采用 封装了JNA的jaydio 。我这里直接用了JNA的API。
顺序IO的问题解决了,那么上大块IO才能真正打满带宽,采用direct io的同时为了保证crash consistency,需要有个mmap在前面顶着,这里采用攒齐4个value,即16k value再写盘的策略,不断的擦写mmap同一块内存区域,正常关闭则删除掉mmap的临时文件,否则下次初始化的时候需要append这个mmap的文件内容到wal做recover。
全程young gc 4次,无full gc。Write on-CPU火焰图,86% IO + 4% 锁消耗 + 其他。基本达到目标。
5. 实现分析-Read
初始化数据库,在上面的写入分析中已经说明了部分,wal和vlog都需要做一些工作保证crash consistency。下一步就是如何建立索引,支持point lookup。
分析来看,总的wal文件大小768MB,索引完全可以放到内存,每个分片建索引流程如下:
1)load wal形成key 8byte + vlog seq 4byte
2)排序这12个byte,先按照key字典序排序,再按照vlog seq取最大的,来应对duplicate key情况。
3)把 排序 好的数据放到内存中。
这个过程每个分片是独立的,可以并行化。这个环节是Java做的不够好的地方,64并发load共耗时1.5-2.5s,有抖动,而且不及C++的300ms,慢了不少。排序的话采用字节一个个比较,或者把key转成unsigned long再比较差距不大,分析来看应该是由于cache line的原因。由于后一个阶段还需要Range,为了避免这个过程再重复,所以可以选择性的把已排序、去重好的key+vlog seq持久化到磁盘,做一个wal.sort文件。
由于key 8byte + vlog seq 4byte定长,所以在内存里用二分查找即可。我的实现采用了offheap内存,通过JDK的Unsafe来malloc和free内存,避免放到heap中的old region,存在GC overhead。内存二分查找开销非常低,占总耗时1%左右。
一次point lookup需要一次内存二分查找+一次磁盘IO。从磁盘读4k value只需要把vlog seq * 4096还原成实际的文件offset即可,通过offset从vlog读4k数据,也需要考虑buffer io还是direct io,在集团内部赛的时候,评测程序读和写key的顺序是一致的,有局部性效应,采用buffer io,走Page Cache,操作系统的预读read ahead会起作用,会快不少。而外部赛,修正了这个问题,所以buffer io,read ahead反而是糟糕的,预读了很多无用的数据到Page Cache浪费了带宽。
读采用direct io,Java通过JNA使用direct io,需要先通过 int posix_memalign(void **memptr, size_t alignment, size_t size)
函数对齐内存(内存暂叫做MemPointer),然后通过 pread(fd, MemPointer, 4096, offset)
系统调用来读取到MemPointer地址,然后拷贝到user space的heap中,这个过程是需要加锁的,如果无锁化就需要池化MemPointer,实测两种方案差距不大。
这里有一个小点可以避免频繁的Young GC,64个线程通过ThreadLocal读4k value,避免频繁的分配内存(感谢 @岛风同学 的提醒才想到这点,岛风是另外一位Java选手,他的分享见链接)。
这部分是所有3个环节中和C++差距最大的,吞吐2.35G/s,小于C++的2.49G/s,这140MB/s的差距可以归结为建索引慢,通过JNA走JNI的direct io不如直接使用系统调用。
全程young gc 4-5次,无full gc。
6. 实现分析-Range
这是本次比赛拉开差距的环节。题目64并发Range2次,128次full scan,基本不可能,一般有两种思路解决,第一搭车模型,64并发wait,异步线程visit回调;第二64线程齐头并进visit。我这里采用了前者,利用java.util.concurrent并发编程库实现了一个AccumulativeRunner的 工具 类。
基本思想就是并发的Range请求达到,然后都submit一个Range Task并且wait阻塞,后台有两个触发条件,满足一定Range Task数量,或者超时了例如5s,就trigger一次Range进行full scan,然后回调所有Range请求的visitor,scan完统一的notify Range请求线程解除阻塞。再进行第二次range。时序图如下,
接下来,考量如何高效的进行一次full scan,一开始采用“滑动窗口+并发随机IO查询”的思想,想利用SSD的并发随机IO特性,结果不够理想,第一,走buffer io,利用Page Cache读取数据不如一次性大块IO的load到内存里吞吐高,第二,Java的FileChannel内部有把position的锁,制约性能发挥。
之后放弃了这种模型,转为“滑动窗口+并发内存查询”的思想,效果不错,接近打满带宽。由于key按照字典序分了1024个分片,基本思想就是顺序遍历1024个分片,每个分片放到内存中访问,无缝的衔接每个分片,走完即可。每个分片的访问都分为3个步骤。
1)prefetch预读:wal排序好建立索引,vlog load到内存。
2)Range读取:iterate排序好的wal,针对每个key和vlog seq找value,就变成了内存访问,也就是“并发内存查询”的精髓。
3)评测程序visit:评测程序需要验证有序、值正确等,也有一定消耗。
为了无缝衔接1024个分片做上述3个步骤,使用滑动窗口,如下图,滑动窗口分为5类:
- – 已访问结束的
- – 正在Range读取的和visit的
- – prefetch预读结束,准备被读取的
- – prefetching中
- – 未访问的
滑动窗口最大容量是3个分片,占用内存最大=vlog(256MB*3)+wal索引(750KB*3)=770MB,这样可以保证不会打破cgroup的内存限制。
prefetch预读,Range读取,评测程序visit三者的瓶颈最终应该在prefetch上,才会打满带宽。所以对于后两个环节,benchmark everything实测确实可能拖后腿,上老办法,串行转并行,做一个多级流水线的架构。
prefetch预读每个分片,就是wal排序好建立索引和load vlog并缓存的过程,这两个过程可以并行。
建立索引,load wal或者wal.sort文件到内存,和read阶段一样,排序好的key和vlog seq放到堆外内存做索引,为Range读取使用。
load vlog并缓存,这个过程可以加并发,单分片vlog大小是256MB,采用8并发*32M大块IO读的方式并行load。load vlog可以选择mmap/file channel/direct io load,实测direct io load和file channel差距不大,最终load vlog文件采用direct io。缓存可以采用offheap DirectBuffer/Unsafe手工分配的内存/heap中,offheap direct memory/Unsafe差距不大,如果使用DirectBuffer,那么后续内存查询get操作非线程安全,所以需要转换为address,通过Unsafe的copyMemory API来访问,进行无锁化;由于load vlog采用direct io,所以这里池化MemPointer,预先在内存中分配出3窗口*8并发共24个32MB的MemPointer,之后read就可以并发无锁的读内存地址了。
Range读取,批量读取256个kv,2个并行即可,然后把结果放到一个无锁队列中。评测程序visit函数在单独的线程中完成,poll无锁队列,针对读取到的kv数据,4个并行的完成64个visitor的回调。这样这两个步骤不至于拖后腿。
对于已经访问结束的分片,需要释放资源,包括wal索引和vlog缓存,以及归还MemPointer到池中。
7. Java实现的特殊性
Java相比C++在比赛中存在一定劣势,这也是直接导致尽管Java排名第一,但是总排名20的原因,虽然和C++第一名的差距只有2.1%,不到9s,但是能做到这样的成绩自己也是比较满意了。
JVM相比C++的劣势包括:
1)不够贴近底层,隔着一层JVM,靠JVM解释字节码执行,虽然有JIT帮热点代码优化为native code执行,但终究不够直接。
2)有GC overhead,如果缓存放heap,必然gc频繁,影响吞吐;并发GC和用户线程可以缓解,必须控制只young gc,不full gc,这个比赛是比IO,所以CPU理论都够用,观察看占400%-600%的CPU是中位数。
3)使用操作系统API不够方便,比如direct io原生JDK不支持,mmap释放内存不方便,线程bind CPU core不方便等。
作为Java选手要克服上面的困难,必然要使出一些大杀器,下面依次总结下。
1、mmap
帮助写入阶段写wal,保证crash consistency。JDK提供原生的API,但是释放相对麻烦。
2、direct io
JNA封装,或者使用jaydio,拼接小IO为大块IO写入。FileChannel在本次比赛都没有使用,原因就是内部有个position lock并且走buffer io,在这个场景不适合,但是大多数Java涉及IO的场景,NIO的FileChannel都是首选。
3、堆外内存
offheap可以用DirectBuffer,或者Unsafe的malloc、free。
4、gc控制
比赛用参数如下,
-server -XX:-UseBiasedLocking -Xms2000m -Xmx2000m -XX:NewSize=1400m -XX:MaxMetaspaceSize=32m -XX:MaxDirectMemorySize=1G -XX:+UseG1GC
write和read阶段young gc都很少,主要为range阶段使用,由于使用了多级流水线架构,所以吃内存比较严重,young gc相对频繁,但没有full gc所以可接受。
5、池化技术
DirectMemory先分配好,然后池化,使用时候反复擦写,可以复用资源。read阶段用ThreadLocal复用value避免频繁young gc。
6、锁控制
kv分离的写入,必然加锁。read阶段的direct io load同一块内存,然后返回给user space的过程也需要加锁,尽量小的控制锁粒度,分散锁的冲突,就像ConcurrentHashMap思想一样,就可以把锁的消耗降到最低。
7、并发利器
java.util.concurrent要用好,Range阶段的搭车模型,并行load vlog,滑动窗口都用到了线程池、lock、condition、mutex等。同时一些无锁并发的类库例如ConcurrentLinkedQueue, jctools的MpmcArrayQueue , disruptor的无锁队列 也可以尝试,比赛中都有实验,其实无锁就足够了,瓶颈在IO,这些可以忽略。
8、减少上下文切换
由于比赛使用了Alijdk,而Alijdk有个Wisp API,可以做Java协程,在一些资源释放无需等待的场景可以使用,亲试后通过 vmstat -w 1
命令看cs列确实少了一些,但是对提高成绩没有很大帮忙。
8. 单机数据库引擎思考
第一,个人认为影响一个数据库性能的方面, 存储架构设计 大于 引擎实现质量 大于 语言选型 ,所以Java在这道题目和C++没有质的区别。
第二,在平台应用服务领域,Java优势在于工程化,类库丰富,设计模式等特性,所以空间广阔;而分布式计算领域,对比HDD和SDD的差距从ms到us数量级的提升,分布式调用同机房ms级别、跨地域几十ms的延迟,才是大问题,所以很多的开源大数据项目,例如spark、hadoop、flink等多采用Java这种工程化好和易维护的语言,也同样满足需求。
第三,存储引擎实现语言的考量:
1)工程化,例如是否static type & 抽象设计
2)Tail latency控制
3)Runtime overhead
Java,2、3对比C++是java的弱项,比如GC和JVM的overhead,但是这两点却是一个DB的刚需。另一个角度,从系统分层角度出发,对2、3敏感的C++,Rust合适,如果是对工程化要求更高Java、 Go 是有其使用场景的。
9. 总结
第一次参加工程性质的比赛,有各路的高手,大家争秒夺毫秒的比拼,非常刺激,也第一次系统性的实践了一把Java IO相关的技术,学习和积累经验的目标已经达到。希望未来自己还能有精力和活力参加比赛,向高手们学习,切磋进步。技术的路很长,每一步的积累都是为了明天更好的自己,与正在读此文的你共勉。
转载时请注明转自neoremind.com。
以上所述就是小编给大家介绍的《阿里云第一届PolarDB数据库性能大赛Java排名第一分享》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 第一届天池 PolarDB 数据库性能大赛
- 第一届HDCTF Write up
- 第一届LCI workshop @ ICCV 2019, 欢迎投稿
- 第一届机器学习会议与机器学习的五大趋势
- “分布式银行”DCC在第一届成都Block Club区块链大会路演
- 「石青云女科学家科技沙龙」圆满结束,第一届石青云女科学家青年奖获奖名单全公布
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
谷歌的断舍离:互联网企业的破坏式创新
[日]辻野晃一郎 / 樊颖 / 机械工业出版社 / 2018-1 / 45.00
本书主要分为三部分: 第一部分主要讨论了世界当下如火如荼的互联网企业进军传统产业大潮,并探讨了传统企业在互联网时代的救赎之路。 第二部分主要探讨了成功体验的反面:速度与迭代,并讨论了传统企业之所以无法实现迭代与快速发展的关键原因。介绍互联网公司如何通过组织精简流程来实现高速竞争时代的机动性。 第三部分讨论了互联网时代究竟需要什么样的人才,传统企业的员工应当怎样投身互联网企业才能避......一起来看看 《谷歌的断舍离:互联网企业的破坏式创新》 这本书的介绍吧!