0. 存储引擎基础
存储引擎的基本功能和数据结构
一个存储引擎需要实现三个基本的功能:
write(key, value) 二分查找并插入
read(key) -> return value 二分查找并返回
scan(begin, end) -> return values 求key在某区间内的所有元素。先两次二分查找,确定begin和end的位置。两位置之间的数据就是结果集 values
上述的存储引擎和普通的哈希表不同。最大的区别就是存储引擎内要求数据的存储顺序是按照key有序的。这比哈希表更节省空间,也容易实现scan()操作。
乍一看使用普通的有序数组好像就可以解决问题啦,但是普通的有序数组也有个问题:当一个新元素要write插入进来时,为保证数组有序,需要把后面的数据都移动一位,这样开销是很大的。
还有一种有序的结构叫做局部性很差,读性能(read/scan)低。
(在OS的页面置换这一节中我们学过工作集的概念,其实这个和局部性很像。硬件、操作系统等等系统,绝大部分时候,执行一次 操作流程会有额外的开销(overhead)。因此很多部件、模块都设计成:连续执行类似或相同 的操作、访问空间相邻的内容时,则将多次操作合并为一次,或多次之间共享上下文信息。这样能极大提升性能。这种时间、空间上的连续性,叫做局部性。)
那用什么数据结构才好呢?可以考虑把数组和二叉树结合一下,把平衡二叉树的每个节点都改成一片数组,做成一个大叶节点树。这样一方面通过把拆分成若干小数组,减少了数组插入时的开销(写操作)。另一方面,扩大了二叉树中每个节点的大小,增加了读操作的局部性,改善了scan的性能。那具体每个节点的数组要多大才好呢?这就要根据需求进行trade off啦。
叶节点大:局部性高 ● 插入成本高,慢 ● 读取性能高,快
叶节点小:局部性低 ● 插入成本低,快 ● 读取性能低,慢
在实践中很多存储引擎会使用B+ Tree作为存储结构(比如MYSQL):
在插入过程中动态保持有序
把数组拆成多个小段,把小段作 为叶节点用 B+Tree 组织起来,让插入过程代价尽量小
每小段(也就是叶节点)是一个有序数组,插入数据时只需要移动插入点之后的数据,大大减少移动量
存储引擎的持久化
为了保证比如关机重启之后数据仍然可以继续使用,我们需要把数据保存到硬盘上。但硬盘有以下几个特点:
速度比内存慢啊......
连续写入比随机写入快很多
因此硬盘上存储引擎的设计和之前要截然不一样。WAL(Write Ahead Log)就是一种成熟的解决方案。它是一种异构镜像方案(也叫做semi-DB):
异构:磁盘与内存的数据结构不一样。磁盘使用局部性高的结构,内存可以是任意结构
镜像:逻辑上两边的数据等价
用户进行写操作时,内存和WAL都写入。读操作时从内存读取。存储引擎重启时重新执行WAL里记录的所有写操作,恢复内存数据结构。
前面说的好抽象啊......其实WAL可以理解成是一个log文件,写 WAL 都在末尾追加写入,顺序地记录所有修改动作(类比数据库系统的日志)。为了存盘数据的安全,避免进程非正常退出丢数据,WAL 一般每次写完数据都执行 fsync 操作,否则数据可能还留在操作系统的 Page Cache 中没有写到盘上(不实时fsync会有丢失数据的风险,但fsync很占磁盘资源,可能成为性能瓶颈。因此数据库系统会提供参数设置fsync的频率)
WAL工作时其实就是[傻傻的]依次记录每次的写操作,但这样效率也不高:1. WAL 中可能存在相同 key 的多次 Write 的多个版本的数据,占用了 额外空间,也降低重放性能。2. WAL中记录的写入操作太多时,整体效率也会降低。 为解决这些问题,我们可以设计一个机制,在某些特定的时刻将WAL记录的所有操作做成一个快照(即相当于提前执行了到目前为止所有的WAL record,并将数据存盘)。这样既提高了重启时重放WAL的效率,也节省了空间。这个机制就叫做Compaction。compaction过程会占用一些IO资源,比如用户只插入了k GB的数据,由于compaction的存在,硬盘总共会执行大于k GB的IO写操作。这个问题就叫做写放大。假如硬盘是SSD,写放大太严重就会影响硬盘的寿命。compaction其实就是以写放大作为代价,换取更好的读取性能。
按照上面的方案让WAL和内存中的B+ tree配合,看起来就很完美啦!但是别忘了内存空间是有限的,不可能所有的写操作都能丢进内存。所以内存中就只能存放部分数据(相当于一个cache),硬盘中才存放所有数据。
另外,从硬盘向内存读数据也是需要较好的局部性的(还记得连续写入比随机写入快得多嘛?)。因此在实际操作时,我们在硬盘的WAL中,以B+tree中的叶节点大小作为单位存储,为B+tree的每个叶节点都启用WAL。内存中的B+tree在读取时,遇到当前不在内存的叶节点时,就去硬盘加载(类似于虚拟内存中遇到缺页中断的处理机制)。如图所示:
上图的结构中,B+ tree的每个叶子节点都有一个WAL。当叶子节点很多的时候这样也不大好....如果compaction的频率很高,而且WAL做compaction时,数据可以从内存获得,那么真正需要从WAL读数据的机会就很少。这样我们可以把一些叶子节点的WAL合并起来,以提高局部性。(具体实现暂时略)
B+ tree存储引擎分析与改进
经过上面这一顿操作后,我们暂时就有了这样一个存储引擎:
这个模型就很好了咩?我们来分析一下:
Write 很快:
查找写入位置,性能为 O(Log2 n)
Append 到 WAL,性能为 O(1)
更新到叶节点,性能可能略差但:○ 是内存操作 ○ 可以异步操作
Scan / Read 很快:
在 B+Tree 中查找,性能接近 O(Log2 n)
如果数据所在的叶节点:
在内存,完成读取
不在内存,加载相关叶节点,再从中查找。有磁盘 IO、磁盘读放大(定义和写放大类似,表示 [系统实际硬盘读IO数量]大于[用户在前台需要读的数据总量])
另外,如果有奇怪的用户在不同的key值域上随机写入(可能每个key值域上写入量很小,但会写很多不同的key值域),那么WriteCache就很难覆盖所有用户写过的key值域。为了腾出writecache,叶节点必须在修改占比还很小的时候,就compact写盘。在这种情况下会造成巨大的写放大,还会造成写盘次数相对于总写入量过多(全是分散IO,写入效率就比较低)。其根本原因是B+tree中,每个叶子节点覆盖的key范围太小啦。而且存量数据越大,叶节点的key覆盖范围越窄。
另外,B+ tree的叶节点是分散存储在硬盘上的,也导致多次IO之间不存在连续性。
那么怎么办捏?我们可以用另一种局部性好的有序结构,叫做LSM Tree。这也就是RocksDB所用的结构。
LSM Tree
LSM Tree长酱紫:
各个小有序数组的key覆盖范围是相互重叠的,它们合并起来可以看做一个大的虚拟有序数组。同时因为范围是重叠的,因此某个key有可能会在多个小数组上都存在,因此不同数组设置了不同的优先级。
这样设计既采纳了B+Tree中将数组分散存储以防止写开销太大的问题,又可以保证每个小数组都有局部性。
LSM Tree的Read操作:最简单的思路是按优先级从高到低,二分查找每个小数组。但这样会存在读放大问题(找了好多次才找到对应的小数组)。为解决这一问题,我们可以在数组生成时,对每个小数组都做一个Bloom Filter(可以理解为一个高效率的hashset)来记录当前小数组里都有哪些key。在读操作时先查Bloom Filter,如果不存在就不需要二分查找这个小数组了。
注意如果要读取暂存在硬盘上的小有序数组:因为这个数组还是比较大的,所以不能像B+Tree那样直接全load到内存再二分查找。对于硬盘上的数组文件,可以把它分成多个小的block。维护一个Bloom Filter记录每个key在哪个block,还有一个索引记录每个bloc