LSM树(log-structuredmerge-tree)是一种对频繁写操作非常友好的数据结构,同时兼顾查询效率。LSM树是很多key-value或者日志数据库所依赖的核心数据结构,比如BigTable、HBase、Cassandra、LevelDB、SQLite、Scylla、RocksDB等。LSM树之所以有效,是基于以下事实:磁盘或内存的顺序读写性能远高于随机读写性能,有时这个差距可以达到三个数量级。这种现象不仅存在于传统的机械硬盘中,对于SSD硬盘也是如此。如下图所示:LSMtree在工作过程中尽量避免随机读写,充分发挥磁盘连续读写的性能优势。SSTableLSM树持久化到硬盘后的结构称为SortedStringsTable(SSTable)。顾名思义,SSTable存储的是排序后的数据(实际上是键值对键排序)。每个SSTable可以包含多个存储数据的文件,称为段,每个段在内部是有序的,但不同段之间没有顺序关系。一旦生成段,它将不再被修改(不可变的)。一个SSTable的例子如下:正如你所看到的,每个段内的数据是按键排序的。下面介绍一下每个段是如何生成的。所有对数据LSM树的写操作都是顺序写的,所以效率非常高。但是,由于外部数据是乱序到达的,如果没有脑子不断的往segment里面写,顺序显然是没法保证的。对此,LSM树会在内存中构建一个有序的数据结构(称为memtable),比如红黑树。每一条新到达的数据都被插入到红黑树中,使数据始终保持有序。当写入的数据量达到一定的阈值时,会触发红黑树的flush操作,一次性将所有排序好的数据写入硬盘(这个过程是连续写入),一个新的segment会生成。之后,红黑树会从头开始下一轮的数据积累过程。读取/查询数据如何从SSTable中查询特定的一条数据?最简单和最直接的方法之一是扫描所有段,直到找到查询的键。通常,您应该从最新的段到最旧的段依次扫描。这是因为越近的数据越容易被用户查询,先扫描最近的数据可以提高平均查询速度。在扫描特定段时,由于段内的数据是有序的,因此可以使用二分查找在O(logn)时间内得到查询结果。但是对于二分查找来说,要么一次性把所有的数据读入内存,要么每次划分都要消耗一次磁盘IO,当段很大时(这种情况在大数据场景中很常见),这两种情况的代价非常高。一个简单的优化策略是在内存中维护一个稀疏索引(sparseindex),其结构如下图所示:每个块开头的一段数据。指数。与之相反的是密集索引,即索引所有的数据,任何一条数据的增删改查都需要索引的更新。两者相比,全索引的查询效率更高,达到了理论极限值O(logn),但是写入和删除的效率较低,因为每次数据的增删改查都需要IO操作更新索引。常见的关系型数据库,如MySQL,内部使用B-tree作为索引结构,是一种全索引。有了稀疏索引后,可以先在索引表中使用二分查找快速定位某个key位于哪一小块数据,然后只从磁盘中读取这块数据,得到最终的查询结果。此时加载的数据量只是整个segment的一小部分,所以IO开销很小。以上图为例,假设我们要查询dollar对应的值。首先在稀疏索引表中进行二分查找,dollar应该位于dog和downgrade之间,对应的offset为17208~19504。然后去磁盘读取范围内的所有数据,然后再次进行二分查找找到结果,或者判断结果不存在。稀疏索引极大地提高了查询性能,但是有一种极端情况会导致查询性能突然下降:当要查询的结果在SSTable中不存在时,我们将不得不依次扫描所有的段,这就是最糟糕的情况。有一种称为布隆过滤器的数据结构非常适合这个问题。布隆过滤器是一种空间效率很高的算法,可以快速检测数据集中是否存在某条数据。我们只需要在写入每条数据之前在Bloomfilter中注册,查询时就可以判断是否缺失了一条数据。布隆过滤器内部依赖于哈希算法。在检测某条数据是否被看过时,有一定概率出现误报(FalsePositive),但一定不能出现漏报(FalseNegative)。也就是说,当布隆过滤器认为一条数据出现了,那么这条数据很可能已经出现了;但是如果布隆过滤器认为一条数据没有出现过,那么这条数据一定是从来没有出现过。这个特性恰好符合这里的需求,即检查是否有数据缺失。文件合并(Compaction)随着数据的不断积累,SSTable会产生越来越多的Segment,导致查询时扫描文件的IO次数增加,效率下降。因此,需要一种机制来控制段的数量。对此,LSM树会周期性地进行文件合并(compaction)操作,将多个segment合并成一个更大的segment,然后清理旧的segment。由于每个段内的数据是有序的,所以合并过程类似于归并排序,效率很高,只需要O(n)的时间复杂度。上例中,段1和段2中都有key为dog的数据,此时应该以最新的段为准,所以合并后的值为84而不是52,类似于字典/HashMap“覆盖”语义。删除数据了解了LSMtree读写数据的方式,那么如何删除数据呢?如果是在内存中,删除一段数据通常会将其引用指向NULL,那么这段内存就会被回收。但目前的情况是,数据已经存在硬盘中,要从段文件中擦除一段数据,必须覆盖其后的所有内容,成本非常高。LSM树采用的方法是设计一个特殊的标志,称为墓碑(tombstone)。删除一条数据就是将其值设置为墓碑,如下图所示:本例展示了删除段2中的狗后的数据效果。注意此时segment1还保留着dog的旧数据。如果我们查询dog,它应该返回empty而不是52。所以delete操作的本质是覆盖而不是清除一条数据,乍一看不符合常识。tombstone在compact操作的时候会被清除,所以被设置为tombstone的数据在新的segment中将不再存在。LSMtree和Btree比较主流的关系型数据库都是使用B/B+tree作为建立索引的数据结构,因为Btree提供了理论上最高的查询效率O(logn)。但是对查询性能的追求也造成了B树相应的缺点,即每插入或删除一条数据,都需要更新索引,从而产生磁盘IO。这个特性决定了B-tree只适用于读多写少的场景。在频繁写入的情况下,会造成大量的磁盘IO,导致性能骤降。这种应用场景在传统关系型数据库中比较常见。LSMtree避免了频繁写入场景下的磁盘IO开销。虽然它的查询效率达不到理想的O(logn),但还是很快的,可以接受。所以本质上,LSMtree相当于牺牲了部分查询性能来换取可观的写入性能。这对于键值或日志数据库非常重要。总结LSM树存储引擎的工作原理包括以下几个要点:在写入数据时,首先将数据缓存到内存中的有序树结构中(称为memtable)。同时触发相关结构的更新,例如布隆过滤器、稀疏索引。当memtable累积到足够大时,会一次性写入磁盘,生成内部有序的segment文件。过程是连续写的,所以效率极高。进行查询时,首先检查布隆过滤器。如果布隆过滤器报告数据不存在,只需返回Absent。否则,从最新到最旧依次查询每个段。在查询每个段时??,首先使用二分查找检索对应的稀疏索引,找到数据所在的偏移范围。然后读取磁盘上该范围内的数据,再次进行二分查找,得到结果。对于大量的segment文件,会在后台定时进行compaction操作,将多个文件合并成更大的文件,保证查询效率不衰减。
