数据库存储引擎主要做两件事:向它插入数据时,它保存数据;向它查询数据时,它返回数据。
数据库存储引擎主要分两类:日志结构存储引擎和面向页的存储引擎(如B树)。
当数据量较大时,为了使加快查询速度,需要索引,但这里涉及一个权衡:适当的索引可以加速读取查询;索引会减慢写速度。
以下主要考虑KV数据的索引,即给定一个key,存储或查询其对应的value。
哈希索引
在这种机制下,新写入的KV数据不断追加到一个日志文件中,内存中则保存一个<k, file_offset>的哈希表作为索引,查询时从哈希表中直接查出 file_offset,经过一次seek即可读到数据。
- 例:Bitcask
- 适用场景:key数量不多但数据更新频繁,这样能将所有的key保存在内存中
日志文件由于不断追加新内容变得越来越大,此时需要分段,即在文件大小达到一定阈值时就停止更新它(变为只读),并将后续数据写到新文件中。后续再将这些不同的段文件进行压缩合并,同一个key只保留最新数据。注意:分段的文件中的数据一经写入就不会再修改了,压缩合并后的数据会被写入到新文件中,在这个过程中,老的分段文件仍然可以向外提供读服务。在压缩合并完成后,可以原子地将对外服务切换到新文件上,这时老文件就可以安全地删除了。
在存在多个段文件的情况下,每个段文件都有一个其对应的内存中哈希表,将key映射到段文件中的offset。在处理数据查询请求时,首先查询最新段文件对应的内存哈希表,再查询次新的,以此类推。经过不断的压缩合并,系统可以保持只维护较少段数量的状态,因此通常不需要查找太多的内存哈希表即可完成查询请求。
实际实现中还有以下几点需要注意:
- 文件格式:日志文件应使用二进制格式,字符串长度后面直接跟上原始字符串,不需要任何转义,与CSV等文本格式相比既简单又高效
- 删除key:要删除某key对应的数据,需要在日志文件中追加一个特殊的记录删除标记(也叫墓碑),当分段文件压缩合并时,早于墓碑记录的相应key的数据会被丢弃。
- 崩溃恢复:进程重启后,保存在内存中的字典数据就丢失了。理论上可以读取所有的分段文件重建内存字典,但这样效率较低。Bitcask的做法是将提前内存中的字典数据在硬盘上保存一份快照,这样能有效加快进程重启时的字典重建。
- 部分写入的记录:数据库进程可能在往日志文件追加数据的过程中突然崩溃,为防止数据写入不完整,Bitcask日志文件中增加了校验和,利用校验和数据来发现和丢弃文件中corrupted的部分。
- 并发访问控制:段文件只允许追加,不允许随机写。因此只允许一个写线程,保证所有的写入都是严格按顺序进行。同时,允许多个读线程同时读文件。读写可以并行。
文件只允许追加写有以下好处:
- 文件追加和段文件合并都是顺序写盘操作,其速度要快于随机写(特别是在机械硬盘上)
- 并发控制和崩溃恢复机制更容易实现,不需要考虑随机写时可能出现的新数据覆盖旧数据写到一半时进程崩溃导致一半新数据一半旧数据的情况。
- 随机写容易导致数据文件内部的碎片化(记录删除导致的空洞),追加写和段文件定期合并机制则没有这种问题。
哈希表索引的局限性:
- 哈希表必须全部放入内存,不适合key数量较多的场景。理论上可以将哈希表写到硬盘上,但这样需要大量随机I/O,性能比较差。哈希表扩容和冲突解决也很低效。
- 区间查询效率较差,例如不能高效地支持key在kitty000到kitty999范围内的所有记录,只能逐个查询。
SSTable和LSM树
上面提到的日志文件内容是不断追加的,日志中的记录顺序由请求顺序决定。
如果采用某种机制使日志文件中的各记录是按key排序的,则称此文件为排序字符串表(SSTable)。另外SSTable还要求每个key在每个段文件中只能出现一次。其主要有以下优点:
- 即使文件大于可用内存,段文件间的合并也能简单、高效地完成
- 不需要在内存中保存所有key的索引,多条连续的日志记录只需要在内存中保存一条<key, offset>即可高效查询
- 将多个key-value对进行压缩后再写盘,稀疏内存索引中的每个条目指向压缩块的开头,既能节省磁盘空间,又减少了I/O带宽占用
日志内容按key排序是不是意味着文件必须支持随机写了呢?并不是,还是可以只支持追加写,可以以如下方式实现:
- 写入时,将新数据添加到位于内存中的有序的平衡树中(memtable)
- 当memtable内容大于某阈值时(一般几M字节),将其作为SSTable文件写入磁盘;SSTable文件写盘时,新的写入可以添加到新的memtable中
- 处理读请求时,先查询memtable,再查询最新的磁盘段文件,然后是次新的磁盘段文件,以此类推
- 后台进程周期性地执行段文件合并与压缩,丢弃被覆盖或删除的值
为避免数据库进程崩溃可能导致的memtable中还未来得及落盘的数据丢失,可在磁盘上保留单独的日志(WAL),每个写请求的数据先追加到WAL日志文件中(直接追加,不需要按key排序),再更新memtable。这样当进程崩溃并重新启动后,就可用WAL重建memtable。当memtable写入到SSTable文件中后,相应的WAL就可以安全地删除了。
例:LevelDB & RocksDB;Cassandra & HBase;Lucene
当查找不存在的Key时可能需要扫描多个SSTable文件,为加快不存在key的查询速度,可使用布隆过滤器。布隆过滤器是一个数据结构,它支持高效地模糊判断一个key是否在某个集合中(如果布隆过滤器返回在集合中,应用层需要再用别的方式查询确认一下;如果返回不在,则可以确定key的确不在此集合)
B树
上面讲的基于日志结构的索引近年来逐渐流行起来,但市场上占主导地位的还是基于B树的索引方式。
B树在存储时也将key保持有序,以加快查询速度并能区间查询。
B-tree将数据库分解为固定大小的页(如:4KB),页是读写的最小单元。这种设计使其更贴近底层硬件的实现机制(硬盘即是按固定大小的块组织的)。
我们可以使用一个地址(或位置)来定位数据库中的一个页。这种地址类似指针,只不过指向的是硬盘上的一块区域。我们可以用这种地址引用关系来构造一棵树,树中每个结点包含多个key,以及多个孩子页(Child page)的地址。每个孩子页负责一段连续的key区间,而在这些对孩子页的地址之间存放的key值就是这些key区间的边界值。
每次进行查询时,先从树的根结点开始,一直找到叶子结点,数据都存放在叶结点。插入、删除时可能需要对树进行调整,使树保持平衡。
因为B树的写操作主要是原地覆写,而且一次写操作可能涉及多个页,如果中间发生了崩溃,很可能会出现数据不一致的情况。常用的机制是使用 WAL(write-ahead log)日志。这种日志文件也是只支持追加写。具体原理是每次更新B树前都需要先将更新的具体内容追加到WAL日志文件中,成功后再更新B树。如果这时发生了崩溃,可以使用WAL日志中的数据将B树数据恢复到一致性状态。
另外并发控制也是一个需要考虑的点,如果有多个线程不受控制地同时读写数据库,很容易产生数据不一致的情况。一般使用轻量锁来防止这种情况的发生。
对B树的一些实现优化:
- 对页内容进行覆写时不是直接修改,而是使用copy-on-write机制,被修改的数据会存到一个新页中,并且新建一系列的父结点来维护新的引用关系;这种方式也降低了并发控制的难度。
- 非叶结点中存放的key值不使用完整key值,而是对key进行简化(abbreviating),这样一页可以放更多的key,从而增加 branching factor,减少树的高度,进而提高查询效率。
- 所有叶结点按顺序排列在相邻的位置,可以方便用户进行大规模的区间查询。但当树变得越来越大时,很难维持这一状态。
- 树中可以增加其他地址引用以提高访问效率,如叶结点中可以增加对其兄弟结点的引用。
- B树的其他变种(如:fractal trees)采用了一些日志型存储方式的思路来减少disk seeks。其主要思路是在每个树结点中开辟一部分作为缓冲区,用来存储新插入的数据,当根结点的缓冲区写满时,它会将缓冲区中的数据向下传递到树的下一层,以此类推,最终将这些新数据插入叶子结点中。
LSM树和B树的对比
1、LSM树的优势
- 写放大:在B树索引中每份数据都至少要写两次到硬盘,一次写入WAL,一次写入真正的页;而且B树写数据的最小单位就是页(即使只修改一个byte,也需要写整页)
- 写吞吐率高:写放大倍数小(取决于具体负载和配置);顺序写而非随机写,尤其在机械硬盘上差别明显。
- LSM树所对应的文件一般小于B树,因为它的数据整体紧凑一些。B树则由于分裂或某些页未填满等原因会出现碎片化的情况。
- 很多SSD的固件内部使用了类日志型结构的算法来将对底层硬件的随机写转化成顺序写,所以总体说来上层是随机写还是顺序写没那么重要;但较小的写放大倍数和较低的碎片化程度在SSD上还是有好处的:数据更小更紧凑可以使系统在有限的I/O带宽上读写更多数据。
2、LSM树的劣势
- LSM中的日志合并压缩操作可能会影响正常的数据读写:因为硬盘资源有限,读写操作可能需要等待一个耗时的合并操作结束释放相应的资源才能继续进行(主要是长尾数据),而B树的性能相对稳定一些;
- 硬盘的I/O带宽是有限的,这些有限的带宽会被分给正常写数据和日志合并,如果配置得有问题,可能会出现合并速度跟不上写入新数据速度的情况,这会导致硬盘逐渐被占满,同时读数据耗时也增加(因为要检查的段文件变多)。系统中需要有合适的机制来监控这一情况。
- B树中的一个key只存在于唯一的位置,而LSM树中一个key可能存在于多个不同的段文件中。key存在唯一的位置有利于实现事务隔离(在很多关系数据库中,事务隔离的实现都基于对一个key range加锁)。