回顾第一次参加性能挑战赛--第四届阿里中间件性能挑战赛,那时候真的是什么都不会,只有一腔热情,借着比赛学会了 Netty、学会了文件 IO 的最佳实践,到了此次华为云举办的 TaurusDB 性能挑战赛,已是第三次参加比赛了,同时也是最“坎坷”的一次比赛。通过我和某位不肯意透露姓名的 96 年小迷妹的不懈努力,最终跑分排名为第 3 名。ios
若是要挑选一个词来归纳此次比赛的核心内容,那非”计算存储分离“莫属了,经过此次比赛,本身也对计算存储分离架构有了比较直观的感觉。为了比较直观的体现计算存储分离的优点,以看电影来举个例子:若干年前,我老是常备一块大容量的硬盘存储小电影,但自从家里带宽升级到 100mpbs 以后,我历来不保存电影了,要看直接下载/缓冲,基本几分钟就行了。这在几年前还不可想象,现在是触手可及的事实,归根究竟是随着互联网的发展,网络 IO 已经再也不是瓶颈了。git
计算存储分离架构相比传统本地存储架构而言,具备更加灵活、成本更低等特性,但架构的复杂性也会更高,也会更加考验选手的综合能力。github
计算存储分离架构的含义:数据库
- 存储端有状态,只存储数据,不处理业务逻辑。缓存
- 计算端无状态,只处理逻辑,不持久化存储数据。性能优化
比赛总体分红了初赛和复赛两个部分,初赛要求实现一个简化、高效的本地 kv 存储引擎,复赛在初赛的基础上增长了计算存储分离的架构,计算节点须要经过网络传输将数据递交给存储节点存储。微信
public interface KVStoreRace {
public boolean init(final String dir, final int thread_num) throws KVSException;
public long set(final String key, final byte[] value) throws KVSException;
public long get(final String key, final Ref<byte[]> val) throws KVSException;
}复制代码
计算节点和存储节点共用上述的接口,评测程序分为 2 个阶段:网络
正确性评测数据结构
此阶段评测程序会并发写入随机数据(key 8B、value 4KB),写入数据过程当中进行任意次进程意外退出测试,引擎须要保证异常停止不影响已经写入的数据正确性。异常停止后,重启引擎,验证已经写入数据正确性和完整性,并继续写入数据,重复此过程直至数据写入完毕。只有经过此阶段测试才会进入下一阶段测试。架构
性能评测
随机写入:16 个线程并发随机写入,每一个线程使用 Set 各写 400 万次随机数据(key 8B、value 4KB)顺序读取:16 个线程并发按照写入顺序逐一读取,每一个线程各使用 Get 读取 400 万次随机数据热点读取:16 个线程并发读取,每一个线程按照写入顺序热点分区,随机读取 400 万次数据,读取范围覆盖所有写入数据。热点的逻辑为:按照数据的写入顺序按 10MB 数据粒度分区,分区逆序推动,在每一个 10MB 数据分区内随机读取。随机读取次数会增长约 10%。
语言限定
CPP & Java,一块儿排名
看过我以前《PolarDB数据库性能大赛Java选手分享》的朋友应该对题目不会感到陌生,基本能够看作是在 PolarDB 数据库性能挑战赛上增长一个网络通讯的部分,因此重头戏基本是在复赛网络通讯的比拼上。初赛主要是文件 IO 和存储架构的设计,若是对文件 IO 常识不太了解,能够先行阅读 《文件IO操做的一些最佳实践》。
计算节点只负责生成数据,在实际生产中计算节点还承担额外的计算开销,因为计算节点是无状态的,因此不可以聚合数据写入、落盘等操做,但能够在 Get 触发网络 IO 时一次读取大块数据用做缓存,减小网络 IO 次数。
存储节点负责存储数据,考验了选手对磁盘 IO 和缓存的设计,能够一次使用缓存写入/读取大块数据,减小磁盘 IO 次数。
因此选手们将会围绕网络 IO、磁盘 IO 和缓存设计来设计总体架构。
赛题明确表示会进行 kill -9 并验证数据的一致性,正确性检测主要影响的是写入阶段。
存储节点负责存储数据,须要保证 kill -9 不丢失数据,但并不要求断电不丢失,这间接地阐释了一点:咱们可使用 PageCache 来作写入缓存;正确性检测对于计算节点与存储节点之间通讯影响即是:每次写入操做都必须 ack,因此选手必须保证同步通讯,相似于 ping/pong 模型。
性能评测由随机写、顺序读、热点读(随机读取热点数据)三部分构成。
随机写阶段与 PolarDB 的评测不一样,TaurusDB 随机写入 key 的 16 个线程是隔离的,即 A 线程写入的数据只会由 A 线程读出,能够认为是彼此独立的 16 个实例在执行评测,这大大简化了咱们的架构。
顺序读阶段的描述也很容易理解,须要注意的是这里的顺序是按照写入顺序,而不是 Key 的字典序,因此随机写能够转化为顺序写,也方便了选手去设计顺序读的架构。
热点读阶段有点故弄玄虚了,其实就是按照 10M 数据为一个分区进行逆序读,同时在 10M 数据范围内掺杂一些随机读,因为操做系统的预读机制只会顺序预读,没法逆序预读,PageCache 将会在这个环节会失效,考验了选手本身设计磁盘 IO 缓存的能力。
计算存储分离架构天然会分红计算节点和存储节点两部分来介绍。计算节点会在内存维护数据的索引表;存储节点负责存储持久化数据,包括索引文件和数据文件;计算节点与存储节点之间的读写都会通过网络 IO。
随机写阶段,评测程序调用计算节点的 set 接口,发起网络 IO,存储节点接受到数据后不会马上落盘,针对 data 和 index 的处理也会不一样。针对 data 部分,会使用一块缓冲区(如图:Mmap Merge IO)承接数据,因为 Mmap 的特性,会造成 Merge File 文件,一个数据缓冲区能够聚合 16 个数据,当缓冲区满后,将缓冲区的数据追加到数据文件后,并清空 Merge File;针对 index 部分,使用 Mmap 直接追加到索引文件中。
F: 1. data 部分为何搞这么复杂,须要聚合 16 个数据再刷盘?
Q: 针对这次比赛的数据盘,实测下来 16 个数据刷盘能够打满 IO。
F: 2. 为何使用 Mmap Merge IO 而不直接使用内存 Merge IO?
Q: 正确性检测阶段,存储节点可能会被随机 kill,Mmap 作缓存的好处是操做系统会帮咱们落盘,不会丢失数据
F: 3. 为何 index 部分直接使用 Mmap,而不和 data 部分同样处理?
Q: 这须要追溯到 Mmap 的特色,Mmap 适合直接写索引这种小数据,因此不须要聚合。
热点读取阶段 & 顺序读取阶段 ,这两个阶段其实能够认为是一种策略,只不过一个正序,一个逆序,这里以热点读为例介绍。咱们采起了贪心的思想,一次读取操做本应该只会返回 4kb 的数据,但为了作预读缓存,咱们决定会存储节点返回 10M 的数据,并缓存在计算节点中,模拟了一个操做系统预读的机制,同时为了可以让计算节点精确知道缓存是否命中,会同时返回索引数据,并在计算节点的内存中维护索引表,这样便减小了成吨的网络 IO 次数。
站在每一个线程的视角,能够发如今咱们的架构中,每一个线程都是独立的。评测程序会对每一个线程写入 400w 数据,最终造成 16 16G 的数据文件和 16 32M 左右的索引文件。
数据文件不停追加 MergeFile,至关于一次落盘单位是 64K(16 个数据),因为自行聚合了数据,因此能够采用 Direct IO,减小操做系统的 overhead。
索引文件由小数据构成,因此采用 Mmap 方式直接追加写
计算节点因为无状态的特性,只能在内存中维护索引结构。
咱们都知道 Java 中有 BIO(阻塞 IO)和 NIO(非阻塞 IO)之分,而且大多数人可能会下意识以为:NIO 就是比 BIO 快。而此次比赛偏偏是要告诉你们,这两种 IO 方式没有绝对的快慢之分,只有在合适的场景中选择合适的 IO 方式才能发挥出最佳性能。
稍微分析下此次比赛的通讯模型,写入阶段因为须要保证每次 set 不受 kill 的影响,因此须要等到同步返回后才能进行下一次 set,而 get 自己依赖于返回值进行数据校验,因此从通讯模型上看只能是同步 ping/pong 模型;从线程数上来看,只有固定的 16 个线程进行收发消息。以上两个因素暗示了 BIO 将会很是契合此次比赛。
在不少人的刻板印象中,阻塞就意味着慢,非阻塞就意味着快,这种理解是彻底错误的,快慢取决于通讯模型、系统架构、带宽、网卡等因素。我测试了 NIO + CountDownLatch 和 BIO 的差距,前者会比后者总体慢 100s ~ 130s。
但凡是涉及到磁盘 IO 的比赛,首先须要测试即是在 Direct IO 下,一次读写多大的块可以打满 IO,在此基础上,才能进行写入缓冲设计和读取缓存设计,不然在这种争分夺秒的性能挑战赛中不可能取得较好的名次。测试方法也很简单,若是可以买到对应的机器,直接使用 iostat 观察不一样刷盘大小下的 iops 便可,若是比赛没有机器,只能祭出调参大法,不停提交了,此次 TaurusDB 的盘实测下来 64k、128K 均可以得到最大的吞吐量。
计算节点设计缓存是一个比较容易想到的优化点,按照常规的思路,索引应该是维护在存储节点,但这样作的话,计算节点在 get 数据时就没法判断是否命中缓存,因此在前文的架构介绍中,咱们将索引维护在了计算节点之上,在第一次 get 时,顺便恢复索引。批量返回数据的优点在于增长了缓存命中率、下降总网络 IO 次数、减小上行网络 IO 数据量,是整个比赛中份量较重的一个优化点。
在比赛中容易出现的一个问题,在批量返回 10M 数据时常常会出现网络卡死的状况,一时间没法定位到问题,觉得是代码 BUG,但有时候又能跑出分数,不得以尝试过一次返回较少的数据量,就不会报错。最后仍是机智的小迷妹定位到问题是 CPU 和 IO 速率不均等致使的,解决方案即是在一次 pong 共计返回 10M 的基础上,将报文拆分红 64k 的小块,中间插入额外的 CPU 操做,最终保证了程序稳定性的同时,也保障了最佳性能。
额外的 CPU 操做例如:for(int i=0;i<700;i++),不要小看这个微不足道的一个 for="" 循环哦。<="" p="">
流控其实也是计算存储分离架构一个常见设计点,存储节点与计算节点的写入速度须要作一个平衡,避免直接打垮存储节点,也有一种”滑动窗口“机制专门应对这种问题,不在此赘述了。
在 Cpp 中可使用 fallocate 预先分配好文件大小,会使得写入速度提高 2s。在 Java 中没有 fallocate 机制,可是能够利用评测程序的漏洞,在 static 块中事先写好 16 * 16G 的文件,一样能够得到 fallocate 的效果。
get 时须要根据 key 查询到文件偏移量,这显示是一个 Map 结构,在这个 Map 上也有几个点须要注意。以 Java 为例,使用 HashMap 是否可行呢?固然能够,可是缺点也很明显,其会占用比较大的内存,并且存取性能很差,可使用 LongIntHashMap 来代替,看过我以前文章的朋友应该不会对这个数据结构感到陌生,它是专门为基础数据类型设计的 Map 容器。
每一个线程 400w 数据,每一个线程独享一个索引 Map,为了不出现扩容,须要合理的设置扩容引子和初始化容量:new LongIntHashMap(410_0000, 0.99);
最终进入决赛的,有三支 Java 队伍,相比较 Cpp 得天独厚的对操做系统的灵活控制性,Java 选手更像是带着镣铐在舞蹈,幸亏有了上次 PolarDB 比赛的经验,我提早封装好了 Java 的 Direct IO 类库:
考虑到网络 IO 仍是比本地磁盘 IO 要慢的,一个本觉得可行的方案是单独使用预读线程进行存储节点的磁盘 IO,设计一个 RingBuffer,不断往前预读,直到环满,计算阶段 get 时会消费 RingBuffer 的一格缓存,从而使得网络 IO 和磁盘 IO 不会相互等待。实际测试下来,发现瓶颈主要仍是在于网络 IO,这样的优化徒增了很多代码,不利于进行其余的优化尝试,最终放弃。
既然在 get 阶段时存储节点批量返回数据给计算节点能够提高性能,那 set 阶段聚合批量的数据再发送给存储节点按理来讲也能提高性能吧?的确如此,若是不考虑正确性检测,这的确是一个不错的优化点,但因为 kill 的特性使得咱们不得不每一次 set 都进行 ACK。可是!能够对将 4/8/16 个线程编为一组进行聚合呀!经过调整参数来肯定该方案是否可行。
而后事与愿违,该方案并无取得成效。
以前此类工程性质的性能挑战赛只有阿里一家互联网公司承办过,做为热衷于中间件性能优化的参赛选手而言,很是高兴华为也可以举办这样性质的比赛。虽然比赛中出现了诸多的幺蛾子,但毕竟是第一次承办比赛,我也就不表了。
若是你一样也是性能挑战赛的爱好者,想要在下一次中间件性能挑战赛中有一群小伙伴一块儿解题、组队,体验冲分的乐趣,欢迎关注个人微信公众号:【Kirito的技术分享】,也欢迎加入微信技术交流群进行交流~