引言
在当今数据密集型的技术环境中,高效的数据存储和检索机制变得至关重要。这里,LSM-Tree(Log-Structured Merge-tree)发挥了关键作用。最初由Patrick O'Neil等人于1996年提出,LSM-Tree旨在优化大规模数据系统中的写入操作,特别是在写入远远超过读取的场景中。这种数据结构通过一种独特的数据写入和组织方法,显著提高了处理大量写入操作的效率,尤其适用于对存储介质的写入成本较高的环境,如机械硬盘(HDD)。
LSM-Tree在现代数据库管理系统中扮演着关键角色,尤其是在需要处理大量写入、更新和删除操作的分布式数据库和NoSQL数据库中。例如,它被广泛应用于Apache Cassandra、RocksDB和LevelDB等知名系统中。通过优化写入性能并减少对存储介质的损耗,LSM-Tree为高效、可靠地处理大数据提供了坚实的基础。
以下这张图展示了LSM-Tree的基础原理,摘自Stefan Richter在Flink Forward 2018演讲的PPT中的一张图。
LSM-Tree 原理
1. 基础结构
在讲解LSM-Tree之前,我们需要先讲几个最重要的结构:
1. MemTable
MemTable是在内存中的数据结构,用于保存最近更新的数据,会按照Key有序地组织这些数据,LSM树对于具体如何组织有序地组织数据并没有明确的数据结构定义,例如Hbase使跳表来保证内存中key的有序。
因为数据暂时保存在内存中,内存并不是可靠存储,如果断电会丢失数据,因此通常会通过WAL(Write-ahead logging,预写式日志) 的方式来保证数据的可靠性。
2. Immutable MemTable
当MemTable
达到一定大小后,会转化成Immutable MemTable
,字面意思就是不可变的MemTable
啦。Immutable MemTable
是将转MemTable
变为SSTable
的一种中间状态。写操作由新的MemTable
处理,并且在转存过程中不阻塞数据更新操作(后面会提到噢)。
3. SSTable(Sorted String Table)
有序键值对 集合,是LSM树组在磁盘中的数据结构。为了加快SSTable的读取,可以通过建立key的索引以及Bloom Filter来加快key的查找。
LSM-Tree
将存储数据切分为一系列的SSTable
(Sorted String Table
),一个SSTable
内的数据是有序的任意字节组(即arbitrary byte string
,并不是指编程语言中的String字符串),而且,SSTable
一但写入磁盘中,就像日志一样不能再修改(这就是Log-Structured Merge Tree
名字中Log-Structured
一词的由来)。当要修改现有数据时,LSM Tree
并不直接修改旧数据,而是直接将新数据写入新的SSTable中
。同样的,删除数据时,LSM Tree
也不直接删除旧数据,而是写一个相应数据的删除标记的记录到一个新的SSTable
中。
这样一来,LSM Tree写数据时对磁盘的操作都是顺序块写入操作,而没有随机写操作。因为磁盘的顺序写比随机写快太多了,LSM-Tree正是利用了这一核心理念,奠定了它在存储结构中的崇高地位。但是鱼和熊掌不可兼得,这样也导致了它的查找速度没有那么快,会在后面介绍原因。
2. LSM-Tree的读写流程
为了方便理解,我按自己的理解重新绘制了一张更加清晰的流程图:
2.1 LSM-Tree的读
- 用户执行查找操作,首先会在内存中查找,首先查找的就是
MemTable
和Immutable MemTable
; - 若在上面两个结构中找不到对应数据,则会到不同等级的
SSTable
中进行查找;因为内部的key
都是有序的,所以一般采用二分法查找,注意是从低等级开始查起,这样才能查到最新的数据(后面的写操作会为你解答);
读操作非常简单,主要是别的各种结构和操作都是为它铺路,保证查找性能在LSM-Tree中非常重要。
2.2 LSM-Tree的写
- 写入WAL :首先,所有的写入操作(包括新的插入、更新或删除)都会先记录到预写式日志
(Write-Ahead Log, WAL)
中。这保证了即使在系统崩溃的情况下,这些写入操作也不会丢失。 - 写入到MemTable :接着,写入操作(不论是新插入的数据还是对现有数据的更新)会被写入当前的
MemTable
。在这个阶段,并不会直接去修改硬盘上的SSTable
中的数据。相反,如果是更新操作,新的值会作为一个新的键值对写入MemTable
(现在你知道为什么MemTable
转为Immutable MemTable
不会阻塞更新操作的原因了吧)。 - 延迟更新到SSTable :
MemTable
满了之后,它会被转换成一个新的SSTable
并被刷新(Flush & Minor Compact)
到磁盘上。如果这个写入操作是对现有数据的更新,新的SSTable
中会包含这个更新后的值。 - 合并和压缩 :LSM-tree定期执行合并和压缩
(Major Compact)
操作。在这个过程中,多个SSTables会被合并,并且对于同一个键的多个版本,只有最新的版本会被保留。如果之前的SSTable中有这个键的旧值,它将在合并过程中被新值替换,并在最终的合并后的SSTable中被删除,这里的压缩操作,一般有两种策略,Size-Tiered 和Leveled,后面会进行介绍。 - 处理删除操作:对于删除操作,LSM-tree通常通过插入一个特殊的标记(如墓碑标记)到MemTable来表示这个键的值被删除。在后续的合并过程中,这个键及其关联的值会被移除。
通过这种方式,LSM-tree优化了写入性能,因为它避免了直接在磁盘上的SSTables上进行频繁的更新操作,这些操作在传统的数据库系统中可能会导致大量的磁盘I/O开销。相反,通过在内存中快速写入并通过后台进程进行高效的磁盘写入和数据整理,LSM-tree能够提供高吞吐量的写入性能,同时保证数据的一致性和持久性。
3. LSM-Tree主要面临的问题
通过上面的流程讲述,相信你已经已经理解了基本流程,不过在上面唯一可能迷惑的操作就是合并策略,这是什么?为什么需要合并策略?我们就需要来看看LSM-Tree面临的几个问题:
1. 读放大
在探讨现代数据库和存储系统的性能时,一个关键的概念是"读放大"(Read Amplification)
,它是衡量在读取一定量的用户数据时,实际上需要从存储介质读取多少额外数据的指标。这个概念特别适用于评估LSM-tree等数据结构的效率。可以看到上图,明明我只想要key=7
的数据,结果我却被迫要读完好几层结构才找到它。
读放大主要由两个因素驱动:一是数据的物理存储方式,二是系统如何索引和检索数据。在LSM-tree中,由于数据分布在多个不同的层级和文件中,检索特定数据可能需要访问多个数据段。例如,一个查找操作可能首先检查内存中的MemTable,接着是最新的SSTable,然后是更老的SSTable。这意味着,尽管实际需要的数据量可能很小,系统为了找到这些数据却可能需要读取大量额外的数据块。
因此,为了提高查找效率,LSM-tree通常使用辅助索引结构,如布隆过滤器(Bloom Filters)。但,这些结构本身也可能导致一定程度的读放大,因为它们需要额外的读取操作来确定数据是否存在于某个SSTable中。
2. 写放大
"写放大"也是顾名思义,用户可能只想写一小部分数据,结果系统却迫不得已执行了更多的写操作,这主要是与LSM-Tree的数据结构有关。LSM-tree通过将数据首先写入内存中的结构,然后再批量转移到磁盘上,以此来提高写入效率。然而,这种方法有一个副作用:为了保持数据的有序性和一致性,需要频繁地进行数据的合并和压缩操作。在合并过程中,数据会被读取、排序、合并,然后再次写回磁盘。因此,单个数据项在其生命周期内可能会被写入多次,导致了写放大。(这个图不太知道怎么画啊我就不画了hhh,后面讲策略的时候,附上我看到的大佬画的图,也很清晰的)。
3. 空间放大
在数据库和存储系统的设计中,除了写放大和读放大,我们还会遇到"空间放大"(Space Amplification)的概念。空间放大描述的是存储一定量的用户数据实际占用的物理存储空间与理论最小空间需求之间的差距。简而言之,它衡量了存储效率------数据占用了比其实际大小更多的存储空间。
例如上图,key=1 2 3 4 5 6 7 8 9
这些键已经存储多次,根据LSM-Tree的结构,level 1和level 2中这些重复的键是没用的了,相当于是更新前的数据,我们查找数据会优先在level 0上查,查不到才往下走,但在LSM-Tree中这些键要通过压缩删除才能释放,因此空间放大问题在LSM-Tree中尤为显著。
4. Compact 策略
为了解决上述的一些问题,LSM-Tree通常会采用下面的两种策略来解决,不同策略有不同的应用场景,主要看需求,而且也不可能上面三个问题都解决,解决一个其他两个就冒出来了,也许生活也是这样的hhh。
4.1 Size-Tiered 策略
Size-tiered压缩策略是LSM-tree中的一种数据压缩方法,它主要在某些NoSQL数据库系统(如Apache Cassandra)中使用。这种策略的目标是减小存储空间的占用,并优化读写性能。
在size-tiered压缩策略中,SSTables根据它们的大小被组织成不同的层(tiers)。当新的数据被写入并形成一个新的SSTable后,这个SSTable会与其他大小相近的SSTables进行合并。合并过程通常会触发当SSTables达到一定数量或总大小时。
(偷来的图,参考链接会在底下附上)
Size-tiered压缩的工作流程通常如下:
- 写入新数据:新的写入操作首先被记录在MemTable中,一旦MemTable满了,它就会被写入磁盘形成一个新的SSTable。
- SSTable层级:新生成的SSTable被放入一个层级中,这个层级包含了其他大小相近的SSTables。
- 触发压缩:当一个层级中的SSTable数量或大小达到一定阈值时,这些SSTables会被选中用于压缩。
- 合并SSTables:选中的SSTables会被读取并合并成一个更大的SSTable。在合并过程中,相同键的多个条目会根据时间戳进行解决冲突,只有最新的数据会被保留。
- 删除旧SSTables:一旦新的SSTable创建完成,原来参与合并的SSTables就会被删除。
然后我们就可以清晰的看到,size-tiered策略很好的解决了写放大的问题,因为它的合并发生在大小相近的SSTables之间,所以新写入的SSTable可以迅速存储到磁盘,不需要立即参与到复杂的压缩过程中。但它的读放大和空间放大问题非常严重,需要检查更多的SSTable才能找到对应数据,比较适合写多读少的场景。
4.2 Leveled 策略
Leveled压缩策略是LSM-tree在存储系统中的另一种压缩方法,它非常适合于读取密集型的应用,因为它提供了相对稳定的读取延迟。在这种策略中,磁盘上的SSTables被分配到多个层级,每个层级的数据大小有严格的限制,且除了最顶层(Level 0),每个层级中的SSTables都不会相互重叠。
Leveled压缩策略的工作流程通常如下:
- 写入新数据:新的数据首先被写入到内存中的MemTable。当MemTable满了,它被转换成一个不可变的SSTable并写入到磁盘的最顶层(Level 0)。
- Level 0的处理:Level 0是唯一允许SSTable间有重叠的层级。当Level 0的SSTable数量达到一定阈值时,它们将被合并(compacted)到Level 1。
- Level 1及以上的合并:在Level 1及以上层级,SSTables之间不会重叠,它们存储着一段不交叉的键值范围。当一个层级达到其大小限制时,一些SSTables会与下一个层级的相应范围进行合并。
- 数据合并:合并过程中,同一个键的多个条目会根据时间戳解决冲突,通常只有最新版本的数据会被保留。
- 空间释放:一旦合并完成,旧的SSTables会被删除,释放空间。
(接下来是偷图时间,因为大佬画的图太棒了,超越不了hhh
当level 0到达阈值时,会出发合并操作
然后合并到level 1后,level 1层发现它也超过阈值了,他就会选择合并到level 2层上,注意这里是选取level 2层中涉及到level 1层数据的区间即可,并不需要全取。
并且,各个层之间的合并操作是可以并行的:
因此我们可以看到leveled策略,很好地缓解了读放大的问题,因为每个层级(除了Level 0)中SSTables不重叠,所以查找数据时需要检查的SSTables数量相对较少。但相应的,写放大问题非常严重,压缩开销也很大(CPU和I/O负载),每次合并操作需要涉及一个大区间内的所有SSTables,因此leveled策略适合用在读多写少的场景(应该说读写平衡,其实写的还是很快的,只是磁盘压力比较大hhh)。
总结
在面对大规模数据管理的挑战时,LSM-tree(Log-Structured Merge-tree)提供了一个高效的解决方案,尤其是在需要处理大量写入操作的环境中。通过将数据首先写入内存中的MemTable,然后分层级地转移到磁盘上,LSM-tree优化了数据写入和读取的性能。其中,Leveled和Size-tiered压缩策略是两种核心的技术,它们各自针对不同的性能需求和使用场景。
完结撒花!