目录
LSM-Tree:NoSQL数据库的底层设计
LSM树
LSM-tree 上的写操作
LSM-tree 上的更新和删除操作
LSM-tree上的查询操作
LevelDB 中的实现
查询过程
不足与优化
结论
参考资料
LSM-Tree:NoSQL数据库的底层设计
近年来,为了应对海量数据的存储和检索,人们选择的数据库通常是性能强大的NoSQL系统,如Hbase、Cassandra等,这类数据库系统具有类似的底层数据结构,这被称为 LSM-tree 数据结构。
LSM树
LSM-Tree的全称是Log-Structured Merge-Tree ,是一种分层的、顺序的、面向磁盘的数据结构。它起源于1996年的一篇论文《The Log-Structured Merge-Tree (LSM-Tree)》,影响深远,催生了谷歌“三驾马车”中的论文《Bigtable: A Distributed Storage System for Structured Data》 . LSM-tree 的概念启发了无数大数据领域的开发者。
LSM 树是大多数提供高写入吞吐量的存储系统的核心,无论是像 dynamodb/cassandra 这样的键值存储,还是像 pulsar 这样由簿记员支持的消息系统。
The various components of a typical LSM backed system are shown below.
典型的 LSM 支持系统的各种组件如下所示。

LSM Tree,是指数据由系统自动生成并按顺序结构化,就像写日记一样。类似于大多数数据库的预写日志(WAL),这种数据结构中的写入都是以append only的方式进行的。术语Merge-tree是指结构中的算法和数据管理形式。数据按层次结构以树状形式组织,并且可以合并多个“树”。
LSM-tree 是专门为键值存储系统设计的,主要有两个功能:put(k,v),表示写入一个键值对(k,v),和get(k),表示为一个键返回一个值。
下图由 LSM-tree 的组件组成,并显示了其多层结构。C0 是保存在主内存中的组件,保存所有最近写入的键值对,并且可以就地更新。C1、C2 … Ck 是磁盘上的组件,并按键排序。

LSM-tree 上的写操作
LSM-tree 中的写操作,即put(k,v)操作,将首先附加到内存中的 C0 级别,如果发生崩溃,数据实际上会在此之前缓冲到 WAL 中。当C0中的文件大小达到一定阈值时,数据将通过C0中的一个或多个文件与C1级的重叠键合并到磁盘中,这个阶段称为Compaction。compaction后的new-C1会依次写入磁盘并替换old-C1。当这些级别达到阈值时,C1 级别到 C2 级别、C2 到 C3 等也会发生这种情况。较旧的值将在压缩阶段被删除。
写操作只在内存中进行,compaction可以在后台异步完成,不会阻塞新的写操作。写入速度快是LSM-tree结构最吸引人的亮点,数据是顺序写入的,而不是B+-tree结构中的随机写入。

LSM-tree 上的更新和删除操作
由于 LSM-tree 具有 append-only 原则,因此无法从日志中就地更新或删除数据。
更新或删除类似于通过附加到日志末尾来写入日期。
更新是通过附加一个新的键值对来执行的,数据将通过附加一个键值条目和一个写为值的“墓碑”来删除。在这种情况下,较旧的值也将在压缩阶段被删除——与写入操作相同。
LSM-tree上的查询操作
LSM-tree上的查询可以分为点查找和范围查找,LSM-tree上的所有查询都从保存最新数据的C0层开始。如果在 C0 中没有找到该键,则在下一级 C1、C2 等中查找直到找到,否则将返回空值。一个查询操作可能涉及多个点查找,因此与写入相比,LSM-tree 上的读取速度较慢。所以LSM-tree主要针对写入密集、查询少的场景。
如前所述,许多键值存储都采用了 LSM-tree。下面我想以 LevelDB 为例来解释 LSM-tree 的概念是如何在实践中应用的,它基于 Google 的 Bigtable 系统的概念。
LevelDB 中的实现
下图展示了 LevelDB 的架构。LSM-tree结构由三类文件组成,分别是内存中的两个Memtable和磁盘上的SSTable文件(Sorted String Table)。内存中的Memtables是接收写请求的普通Memtable和不可修改的immutable Memtable 。而磁盘上的SSTable是一个有序的字符串表,其中有序字符串是数据的关键. SSTable 共有七层(L0 到 L6)。下一层的总大小限制是上一层的 10 倍。

写过程:
1. 写操作附加在一个WAL文件中,以防系统崩溃;
2. 数据写入Memtable;
3. 当达到配置的阈值时,Memtable 被标记为不可变的 Immutable Memtable,并且将生成一个新的 Memtable 来缓冲传入的数据;
4. 不可变的Memtable被刷新到磁盘上的 SSTable
,
这个阶段也被称为 minor compaction。请注意,不可变的 Memtable 在 L0 层被转储为一个新的 SSTable 文件,而不是直接与该层的旧文件合并;
5. 每次当 SSTable 文件的大小达到阈值时,一个或多个文件将与下一层的具有重叠键的文件合并,这个阶段也称为 major compaction。
L0 层中的 SSTable 文件是无序的,因此它们可能包含重叠的键。但是 L1-L6 层的 SSTable 文件在 major compaction 后重新组织,因此文件是有序的并且只包含非重叠键。
下面是一个主要压缩过程的示例,其中黄色块表示参与此压缩的文件。不可变的Memtable先被flush到L0层,然后触发L0和L1的compaction;之后,删除了L0中的相关文件,更新了L1中的相关文件。L1层保持全局有序,三个文件的数据顺序为“abcdef”。

查询过程
1. 先在 Memtable 和 immutable Memtable中查找key,找到key就返回value;
2. 否则依次查找下一层L1、L2…L6,直到找到key或返回null值。
不足与优化
LSM-tree在存储方面也存在一定的问题会影响其查询性能,即空间放大、读放大和写放大。
空间放大:LSM-Tree的所有写操作都是顺序追加写。更新数据时,旧值不会直接被新值覆盖,否则会分配新的空间存储新值,称为异地更新。因此,冗余数据或多版本数据在一定时期内仍然会存在于LSM-Tree系统中。这种实际占用的空间大于数据本身的现象称为空间放大。
读放大:由于存储结构的设计,当读取一条数据时,会触发多次I/O操作。一个I/O就是一个读请求,读的是后台的大盘。实际读取量远大于目标数据本身的大小,影响读取性能。这种读取并找到不相关的SSTables的现象称为读取放大。
写入放大:在每一层的压缩过程中,多个SSTable文件会被反复读取、合并和排序。删除旧版本的数据后,会写入一个新的SSTable文件。每个key可能会被多次写入,即在存储系统中每一层写入一次,这会导致I/O性能的损失。这种物理上实际写入的数据量远大于逻辑上打算写入的数据量的现象称为写放大。
为了改善缺点,开发了一些优化:
压缩:将多个 SSTable 合并为一个 SSTable 并清理过时数据或不同数据的旧版本有助于最大限度地减少空间放大。
Bloom Filter:对每个SSTable使用Bloom Filter来判断一个SSTable是否包含特定的key,这样可以避免读取没有目标key的SSTable文件,减少磁盘访问次数。因此,可以最小化读取放大并加快查询速度。
WiscKey:在数据存储中将键与值分离。键和值的位置存储在 LSM-tree 中,而值可以附加到单独的值日志文件中。这样,写放大可以被最小化。
结论
本文主要介绍LSM-tree的相关理论。这种设计牺牲了部分读取性能,通过批量顺序写入实现了高吞吐量。这个特性已经被各种NoSQL广泛采用,并在大数据领域得到验证。学习和理解LSM-Tree的结构,有助于我们更好地理解相关NoSQL数据库的实现原理,掌握这些框架下的核心知识。
参考资料
WiscKey:在 SSD 敏感存储中将键与值分离
深入理解什么是LSM-Tree
基于LSM-Tree的分布式组件化KV存储系统
什么是 LSM 树?