由Rocksdb状态后端引出的Tree的应用

1.前言

  本节主要是由于Rocksdb的数据结构LSM树,所以介绍一下常见的树结构在不同场景下的应用,更好的理解一下常见的数据库,KV存储系统都是如何设计,以及为什么这样设计的。

2. 二叉树

  二叉树:每个节点至多有两个子节点。可以计算高度为N(从0开始计数)的二叉树,最后一层最多有2^N个节点,全部节点数是2^(N+1) - 1个节点

  完全二叉树:除最后一层,其他层的节点数达到最大,最后一层所有节点集中在左边

  满二叉树:这个国内和国外定义好像不太一样,这里按照国内定义:所有的层节点都满了,即是满二叉树:其节点数就是二叉树说的2^(N+1) - 1个

  二叉查找树:上面的二叉树都是一些简单的定义,二叉搜索树则是具有实际使用用途的,定义也很简单:对于任何节点来说,其左子节点的值< 当前节点的值 < 其右子节点的值 (左右节点存在)

        利用上面的性质,就可以从根节点开始,判断大小选择左右分支,找到数据是否存在了。

  AVL树(平衡二叉树):二叉查找树的问题在于构建的树可能比较极端,全部节点都在一侧,导致查找效率不高。平衡二叉树就是用来解决这个问题的:其构建保证任何节点的两个子树的高度最大差别为1。

             AVL树保证了效率为O(logn),要保证AVL树的特点,增加删除节点都会破坏AVL树的定义,所以需要进行旋转,旋转分成LL,LR,RL,RR。

             具体操作看文章:https://www.cnblogs.com/skywang12345/p/3576969.html

3.红黑树

  上面介绍了AVL树,从AVL树的定义上来看是一种优势巨大的二叉树了,但是为什么还会有红黑树呢?

  原因在于AVL树虽然可以保证高效的查询,但是保证不了高效的插入删除。因为为了维持任何节点的两个子树高度最大差别为1带来的旋转代价是巨大的,简而言之就是AVL树适合查找多而插入删除少,而实际运用中这种场景还是不多的。红黑树是AVL树的一种变体,其是一种弱平衡二叉树,妥协解决了插入和删除频繁旋转的问题,所以像Java高版本的hashmap冲突构建的是红黑树,TreeMap也是一个红黑树。

  红黑树的名称来源,其将所有节点划分成红节点和黑节点,其要求:1.根和叶子节点(NULL节点)必须是黑的,2.每个红节点的子节点都是黑色,3.从任一节点到每个叶子节点的路径包含的黑节点数量一致。

  其定义约束了最长路径和最短路径差距最多两倍长(最短全是黑节点,最长红黑相间),红黑在查询中没有实际意义,但是在旋转平衡的时候就有特殊的指导意义了。

  https://www.cnblogs.com/xrq730/p/6867924.html

  红黑树平衡分成修改颜色,以及左旋和右旋,参考文章:https://www.cnblogs.com/CarpenterLee/p/5503882.html。讲的比较详细。

  本文想表达的也不是红黑树,所以与之相关的内容就到此为止。

4.磁盘上的树

  从这里开始是本文的核心内容。我们都知道内存比磁盘快很多,但是内存容量小且断电数据就会丢失,磁盘虽然保存了就不易丢失,但是查询速度慢,那么海量数据无法加载到内存中,又是如何做到令人可以接受的查询插入速度呢?这就是索引的魅力,索引使用较小的数据空间,可以快速定位数据位置,达到目标,上面所说的AVL和红黑树都可以作为索引,但是对于磁盘而言并不会使用二叉树。原因很简单,二叉树只有两个子节点,很容易树的高度变得无法接受。

  首先回顾一下磁盘的特性,才好针对特性设计合适的数据结构。磁盘是以扇区为基本单位,一个扇区512字节(存在4kb的),磁盘可以保证单个扇区的原子性,要不写入成功,要不失败。磁盘有很多个柱面,每个柱面有不同的磁道,每个磁道又被划分成扇区,读取数据要三个坐标定位一个扇区,之前的磁盘都是单臂操作,只有一个磁头在工作,目前好像有双臂操作的机械盘。这篇文章介绍了更详细的磁盘知识:https://blog.csdn.net/u012184539/article/details/84257877 ,http://c.biancheng.net/view/879.html。磁盘操作的时间有:寻道,旋转和数据传输。数据传输一般不考虑,所以优化都集中在寻道和旋转上,寻道一般在10ms左右,所以1秒也只能操作100次,旋转取决于转速。固态硬盘则是完全不同的存储结构,容量小且贵,目前而言用作大数据存储成本过高,不做考虑。

  简单而言,磁盘的寻道和旋转会是性能瓶颈,所以需要减少这个次数,另外顺序写的速度是很高的,而且顺序操作也变相的解决了寻道和旋转的效率问题。

  既然都知道顺序写是很好的解决方法,那顺序写不就完了吗? 问题在于做不到啊。不考虑其他应用程序对磁盘的随机使用,单单说数据库本身,数据在不断的修改,插入删除,从根本上就无法保证数据在磁盘上是简单的连续的。所以我们需要作出妥协,充分利用磁盘特性实现目的。下面介绍B+树和LSM树是如何适应磁盘的。

4.1 B+树

  B+树是由B树引申而来,可以参考一下这篇文章:https://blog.csdn.net/u013400245/article/details/52824744https://www.jianshu.com/p/71700a464e97

  这里总结一下B树性质,一个M阶的B树:

  1. 每个节点最多有M个子节点 (M>=2)
  2. 每个节点最少有ceil(M/2)个子节点,向上取整
  3. 所有叶子节点都在同一层,且不包含任何关键字信息
  4. 有x个孩子节点的节点包含x-1个关键字,从小到大排列

  B+树相比于B树的区别:

  1. 有k个子结点的结点必然有k个关键码
  2. 非叶子节点只有索引作用,不含数据
  3. 叶子节点包含数据,而且用双向链表连接

  一棵含有N个总关键字数的m阶的B树的最大高度是多少?https://blog.csdn.net/ASJBFJSB/article/details/100114403

  logm(n+1)<= h <=log(ceil(m/2)) (n+1)/2 + 1

  关于树的特性说明就到这里,下面结合mysql说明一下为什么使用这种结构,并详细说明mysql中二次写,redo日志等操作的原因。

  首先mysql选择了B+树而不是B树,原因可能如下:1.非叶子节点都是索引,没有数据,所以索引里面可以存更多的关键字。2.叶子节点都是数据,而且是双向链表,范围查询更方便

  接下来就是详细分析,先说结论:B+树在磁盘的应用就是减少了磁盘寻道时间,但是不是无限制的提升:https://blog.csdn.net/csdnlijingran/article/details/102309593 mysql一般到了千万级性能就会下降了,B+树的高度一般在3层。mysql具体操作步骤如下,这里会混合着mysql本身的知识点进行说明:

  mysql是以页来管理数据的,一页16kb.

  1.查询一条数据:select * from table1 where id = 5 (假设id是聚集索引). 从B+树根节点出发,读取根节点页,放入缓存,进行二分查找,找到下一个索引位置页,读入内存,继续查找直到数据页。

   所以一次查找由树的高度决定,一般3层高度,结果就是执行3次IO,寻道和旋转,所以大概几十毫秒就够了。而且由于有缓存,后续命中缓存速度会更快。

   这也就是上面说的为什么二叉树不能这么操作的原因了,树的层级过高,磁盘操作次数会变多。mysql保证单个数据页是连续存储,所以B+树的单个节点可以看成一个单元,但是不同的节点在磁盘上并不是顺序的,这部分寻道是无法节省的。第二个问题是为什么4层5层就不行了呢?多加一层按照上面计算逻辑,可以容纳的记录数是惊人的,按照我的理解原因是由多方面造成的:首先是B+树的分裂合并造成在磁盘上的位置更加随机,不连续,寻道旋转耗时更久。第二个是数据量过多,缓冲区可能容纳不了,导致磁盘查询更加频繁。
  2.插入更新数据:这一块mysql对性能和安全性做了大量的设计优化。更新数据,事务提交主要做了两件事情:第一是将事务写入日志缓冲,再将日志缓冲刷入事务日志(redo log)。第二是将事务写入缓冲池。这两个步骤都提交成功了,事务就成功了。因为插入都是随机的,对于磁盘而言就是随机写,这在上面我们说过对磁盘而言这并不友好,所以mysql先用顺序写,写入事务日志,然后写入缓冲池。这个时候可以看见随机写没有发生,只是写了事务日志,宕机时从事务日志恢复就可以了。被修改的缓冲区的页被称为脏页,还没有被写入磁盘,会通过一定规则写入磁盘。写入的时候并不是直接将脏页刷入磁盘,这是因为闪断的时候页数据会被破坏(历史上发生过),而数据页本身损坏了,事务日志记录的是物理修改,也就没法恢复整个页。所以现在的mysql都使用double write方式,先按顺序写入double write区域(也是顺序写),然后修改磁盘的数据,闪断的时候还能通过double write数据进行修复。所以整个发生了2次顺序写,一次随机写。写入事务日志可以配置规则,因为我们知道只有flush才算成功写入磁盘,否则都在磁盘的缓冲中,flush会影响写入性能,所以mysql让用户决定什么时候flush。https://blog.csdn.net/zhaoliang831214/article/details/82711350

  这篇文章详细介绍了redo log和undo log:https://www.cnblogs.com/f-ck-need-u/archive/2018/05/08/9010872.html

  总结一下:mysql是以页为单位进行管理的,每次都会将页读取到内存中进行操作,由于B+树的高度原因,查找一个页的位置是可以预估到次数的。由于buffer pool还会缓存页,所以实际磁盘操作比想象中要少,如果数据量超过缓存大小,性能势必下降了,没有绝对的数值,都是根据内存有多大决定表的记录数达到多少有性能瓶颈。写入由于不可能顺序写,一定会产生随机写,原本应该存在性能问题。但是mysql采取了redo log的方式,将数据临时顺序写入磁盘,认为是数据保存成功,所以将随机写变成了顺序写,提升了写性能。后面由于实际修改页怕损坏,采取double write机制,先写共享表的一个2MB数据空间,这个也是顺序的,再随机写修改页。由于是异步线程操作,而在顺序写redo log就可以认为数据实际上已经不会丢失,所以这个速度不影响mysql的正常查询修改删除。

4.2 LSM树

  在Hbase权威指南一书中就讨论过B+树和LSM树的区别,主要在于如何利用磁盘。B+树利用磁盘的随机查找能力,涉及上尽量避免随机写以及多次IO,结果是随机读取优势很大。LSM树利用的是磁盘的连续传输能力,并排序和合并文件。文中假设10MB/s带宽,10ms的磁盘寻道时间,每条数据100字节(100亿条数据),每页10KB(10亿页),更新1%的数据需要多久? 随机B树需要1000天,批量100天,排序和合并只需要1天。

  从上面的描述可以看出,LSM在写入上存在优势,当然是建立在牺牲了部分读取性能的基础之上。原论文地址:https://www.cs.umb.edu/~poneil/lsmtree.pdf。下面简单说下LSM树的思想。

  首先,LSM树不是一棵树,也不一定是树形结构,也有使用跳表的(Hbase C0树)。LSM树有几个重要概念:

  1. 具有很多层级,从C0  - CN。
  2. C0一般都是在内存中,不同应用的称呼不一样,rocksdb中为MemTable。
  3. 读写一般都是操作MemTable,这个是内存操作,根据局部性原理,一般命中概率会大。写入很好理解,读取没读到会查询C1层的文件。
  4. 满足一定条件后,C0的MemTable会变成ImmuTable,然后刷入磁盘变成SST文件,SST文件都是不可变文件,所以这种做法可以充分利用顺序写性能。

  上面的结构和优缺点可以参考文章:https://www.cnblogs.com/cobbliu/articles/9553271.html

  简单来说,只有C0级内存树可以进行修改,ImmuTable开始到文件后都不允许修改了,所以没有B+树那种删除添加的问题。同时内存是有限的,所以需要读取磁盘获取数据,这部分就是读取的瓶颈。

  所以LSM树的优化集中在对数据的查询上。第一点优化就是随着SST文件的不断生成,小文件会越来越多,需要将小文件进行合并,这就是所说的compact操作,将小文件合并成大文件,由于之前要求单个文件中数据有序,这样可以增快合并速度。第二点优化就是由于不知道数据落在哪个文件上,需要查询所有的文件,所以在每个文件上引入了bloomFilter,更快的定位数据可能出现的文件,搜索更少的文件。https://www.jianshu.com/p/f911cb9e42dehttps://blog.csdn.net/varyall/article/details/79980915这两篇文章配合图片更易理解。

  一句话总结,LSM牺牲读取,利用磁盘连续传输能力提升性能,通过compact和bloomFilter提升读取性能,局部性原理:如果一个数据最近被使用,那么它被再次使用的可能性就更大了,所以多层级的结构,可以认为历史很久之前的数据被访问的概率更小。

  https://blog.csdn.net/f1550804/article/details/88382750

5. RocksDB

  上面介绍了不同的树结构在磁盘上的运用,这里主要介绍一下compact策略,以及相关问题和参数调优。

  这篇文章介绍了RocksDB的compact策略 https://www.jianshu.com/p/e89cd503c9ae

  总结一下:compact策略一般有两种:

  1. size-tiered compaction:思路就是每个文件有固定阈值,每层有固定文件数量,到达后就开始合并,放入更高层。存在的问题是空间放大:因为LSM的文件是不可修改的,所以对数据的增删改都会生成新的记录,查询的时候按照文件生成的顺序,逆序找到最新的一条记录就是当前记录了。其他的数据都是多余的,另外在compact操作的时候,执行完之前原文件也是不可删除的,所以单个sst可能放大2倍。
  2. leveled compaction:思路是同一层级的sst文件包含不同范围的key值,比如1文件范围0-10,2文件范围11-20,前面层级的包含范围更大,影响下游部分文件,比如前层级的包含键值是0-100,对应下游就是0-10,11-20等等10个文件,所以0层级的是全部的key的集合。这样在单个文件compact时,只会影响到该层一个文件和下游若干文件,而不是所有,可以有效缓解空间放大问题(这种结构就像B+树的索引节点)。但是与之而来的问题更麻烦了,其会造成写入放大,原因也很好理解,就是会造成逐层影响,直到最后层级。size-tiered只会影响一层,因为其阈值是确定的,该层全部合并走向下游。但是leveled具有上下游紧密联系,第一层刷到第二层可能触发第二层的compact,第二层会造成第三层的合并,最后造成大量的无意义的磁盘操作。

  RocksDB采取了混合模式,详细看上面的文章。

  接下来是Flink运用时的一些参数调优,参考文章:https://cloud.tencent.com/developer/article/1592441

  首先,Flink使用时keystate禁用了WAL,WAL会有性能问题,而Flink有自己的容错机制,不太需要。内存中有memTable和ImmuTable,这上面已经说明了,还有一种就是Block Cache,不可能每次都去查找磁盘,查找到后肯定会cache下来。

  1. 毫无疑问,缓存大小肯定直接影响读写性能。block size(state.backend.rocksdb.block.blocksize),block cache size(state.backend.rocksdb.block.cache-size)。block的大小是sst文件的基本单位,和mysql的页差不多,一般是4KB,生产中调整到16-32kb,固态可以达到128KB。增加block size会造成读放大,原因是cache没变,block增大,能放入的block数量就会变少,所以命中缓存率会降低,磁盘IO增多。所以改变这个值需要同步修改block cache size,如果内存吃紧,就不要变动了。block cache size默认是8MB,可以设置成32MB-256MB。开启state.backend.rocksdb.metrics.block-cache-usage观察cache使用情况,进行调优
  2. 最大可以打开的文件句柄(state.backend.rocksdb.files.open)。这个也较容易理解,最好让rocksdb保持所有管理的文件都可以读取状态。没有开启cache_index_and_filter_blocks,该值表示内存中可以容纳的index和filter数量,建议操作系统和程序都改成-1.
  3. cache_index_and_filter_blocks默认是false,不再内存中存放index和filter,用到才加载,用完舍弃。true表示运行放入block cache中,可以提升局部读取性能,开启需要同步开启pin_l0_filter_and_index_blocks_in_cache,防止操作系统换页操作造成的性能抖动。由于放入cache中会占用空间,所以除非你内存有多,或者存在局部键值热点,可以加速,否则会减少能容纳的缓存,导致性能变差。
  4. optimize_filters_for_hits设置成true,L0不会生成bloom filter,可以减少90%的filter开销。同样只适合有局部热点和基本不会cache miss的场景
  5. Flink中每个State对应了一个ColumnFamily,每个ColumnFamily都有一个memTable,1.10可以共享。需要计算自己的状态可能的大小值进行设置。
  6. write buffer size(state.backend.rocksdb.writebuffer.size),就是memTable的大小,默认64mb.调整这个参数还需要调整以下内容:1.state.backend.rocksdb.compaction.level.max-size-level-base 一定需要增大level1层的阈值,该值太小会造成能存放的SST文件过少,层级变多造成查找困难,太大会造成文件过多,合并困难。2.max_bytes_for_level_multiplier是leveled下的一个倍数设置。3.state.backend.rocksdb.writebuffer.count 内存中可以存放的memTable个数,超过这个值会被flush到磁盘,内存足够可以调整到5。4.state.backend.rocksdb.writebuffer.number-to-merge,合并的最小阈值,调大调小都有很大影响,最好是3
  7. 其他是一些compact和flush的控制,不再展开

  最后附上文章:https://cloud.tencent.com/developer/news/643334  快手基于hbase开发了一个slimbase做为状态后端,解决rocksdb的部分问题。

  最后一个小问题,hbase的存储结构也是LSM,学习了上面的都应该知道KV存储,LSM是不能删除修改的,每次都是读取最近文件命中的数据,所以一个key只会认同一条记录。那么hbase的column数据是具有版本概念的,这个是怎么实现的呢?......  这个其实也很简单,我们一般在表象上看见hbase的键是rowkey,但存储的时候肯定不是存的rowkey,否则值存哪个column的数据呢?所以在HFile中存储的键是由rowkey, columnFamily,column和timestamp组成的,所以这个就是表象上的column的多版本了。键值设计参考:https://blog.csdn.net/ping_hu/article/details/77115998

posted @ 2020-07-02 15:03  dark_saber  阅读(674)  评论(0编辑  收藏  举报