LSM-trees简介

642 阅读12分钟

LSM-trees是什么

LSM-trees是一种数据结构,很适合用于key-value数据的高效索引和持久化存储。一些广泛使用的存储系统BigTable、LevelDB、RocksDB(可以充当MySQL的存储引擎)都用到了这项技术。

B+树

image.png

  • MySQL的InnoDB存储引擎使用了B+树
  • 拥有平衡的读写性能
  • 单次写入需要改变多个节点,这些节点存在硬盘中不同的位置,可能需要进行多次硬盘随机写入
  • 一般来说,硬盘的顺序写入速度远快于随机写入

LSM-trees

The log-structured merge-tree (LSM-tree)

  • 设计哲学是尽量最大化利用硬盘的顺序写入性能
  • 是一种持久化键值存储结构,它能为高频率的键值插入(insert)、更新(update)、删除(delete)操作提供高效率的索引
  • 一棵LSM-trees包含从C0到Ck多个组件,这些组件的容量依次增加。其中C0常驻内存之中,是一颗排序树(sorted tree)。其余从C1到Ck组件处于硬盘中,每个组件都是单独的B树(append-only B-tree)
  • 对于一次键值插入、更新或者删除操作,有以下步骤:
    • 第一步,键值对首先以追加方式写入log文件中。这是持久化最重要的一步,保证服务器崩溃时数据不丢失,同时顺序写入也保证了写入性能非常好。
    • 第二步,键值对会被追加到的C0,由于在内存之中,这个过程非常快。对于用户来讲,写入操作就结束了。接下来的步骤是后台线程异步执行的。
    • 第三部,当C0的大小达到阈值时,它会跟硬盘上的C1进行归并排序操作,这个操作被称为compaction。两者合并生成的新文件会被顺序写入硬盘,取代旧版本的C1。
    • 第四步,compaction操作同样发生在C1到Ck组件中。当任意Ci的大小达到阈值,都将触发compaction,与下层Ci+1合并。
  • 对于update和delete操作,不实际删除失效的值,而是在compaction过程中清除数据。
  • 对于一次查询操作,LSM-trees数据结构可能需要查询多个组件
    • 查询过程从C0开始,C0包含最新鲜的数据

    • 如果在C0中未查到对应键值对,存储引擎将会开始查询C1,以此类推

    • 因此,为了查询一对键值对,LSM-trees需要从C0查起,往下查找,直到查到目标键值对所在的Ci

    • 对于查询不存在的数据,LSM-trees需要查遍所有组件。因此,一般来说,对于查询操作,LSM-trees相对于B-tree耗时更大

工程实践

BigTable

Bigtable : A Distributed Storage System for Structured Data

  • 谷歌公司在Bigtable论文中第一次将LSM-trees数据结构应用到大规模分布式存储系统中,论文中描述了一种构建在谷歌GFS(Google File System,即谷歌公司的分布式文件系统)上的存储

    • 顺便提一下Google的大数据三驾马车分别是GFS、MapReduce、BigTable,这三篇论文都非常经典

  • 以键值对的update操作为例

    • 数据会被先写入到日志中

    • 然后写入memtable,memtable是一种内存中的有序结构

    • 随着写入操作增多,memtable中的数据量逐渐增大,当数据量达到设定的阈值时,memtable将会被冻结,然后被写入GFS成为SSTable文件(Static Sorted Table),这个过程被称为minor compaction

    • 随着键的update和delete操作的增多,SSTable中失效的键值对需要被及时清理。BigTable会定期进行major compaction,读取多张SSTable中的数据,进行归并排序,清除失效数据,然后创建新的SSTable,并最终将数据写回到GFS中

LevelDB

源码 github.com/google/leve…
go语言版本源码 github.com/golang/leve…
leveldb-handbook.readthedocs.io/zh/latest/

  • 谷歌公司开源的单机键值存储引擎
  • 将SSTable组织成小文件层级结构,小文件可以减少单次compaction消耗
  • RocksDB是Facebook维护的LevelDB分支,RocksDB是目前工业界被广泛使用的存储引擎

MemTable

Skip lists: a probabilistic alternative to balanced trees

跳跃表

  • 底层是一个普通的有序链表
  • 更高层都充当下面链表的索引。有点像实现了二分查找法的链表
  • 以查找节点5为例子,只需查找1-4-5。插入和查找时间复杂度都是log(n)
  • Redis的有序列表也用到了跳跃表结构

SStable

物理结构

  • 每4kb进行压缩,默认使用snappy算法

  • 我们公司去年在推用RocksDB取代innodb,主要原因是能够节省70%的存储空间

逻辑结构

  • data block: 用来存储key value数据对;

  • filter block: 用来存储一些过滤器相关的数据(布隆过滤器),用于快速判断某个key是否在这个sstable当中,但是有一定误判率;

  • meta Index block: 用来存储filter block的索引信息(索引信息指在该sstable文件中的偏移量以及数据长度);

  • index block:index block中用来存储每个data block的索引信息;

  • footer: 用来存储meta index block及index block的索引信息;

data block

  • data block中存储的数据是leveldb中的key-value键值对。

  • 由于sstable中所有的keyvalue对都是严格按序存储的,用了节省存储空间,leveldb并不会为每一对key-value对都存储完整的key值,而是存储与上一个key非共享的部分,避免了key重复内容的存储。

  • 每间隔若干个keyvalue对,将为该条记录重新存储一个完整的key。重复该过程(默认间隔值为16),每个重新存储完整key的点称之为Restart point。

一个entry分为5部分内容:

  • 与前一条记录key共享部分的长度;

  • 与前一条记录key不共享部分的长度;

  • value长度;

  • 与前一条记录key非共享的内容;

  • value内容;

log文件

  • log文件跟memory db是一一对应的,数据同时写入memory db和log,不仅迅速插入内存中的有序结构,而且写入了磁盘,保证数据不丢失。

  • Memory db写入到一定大小时,会被锁定,然后转变成SSTable,这个过程被称之为minor compaction

写入流程

Minor Compaction

  • 一次minor compaction非常简单,其本质就是将一个immutable的所有数据持久化到一个磁盘文件中

  • minor compaction的优先级高于major compaction

  • 当进行minor compaction的时候有major compaction正在进行,则会首先暂停major compaction

Major Compaction

三种情况触发:

  • 当0层文件数超过预定的上限(默认为4个)

  • 当level i层文件的总大小超过(10 ^ i) MB

  • 当某个文件无效读取的次数过多

    • Leveldb的作者认为,一个文件一次查询的开销为10ms, 若某个文件的查询次数过多,且查询在该文件中不命中, 那么这种行为就可以视为无效的查询开销,这种文件需要被compaction。
    • 但是compaction本身代价也很昂贵。对于一个1MB的文件,其合并开销为
      • source层1MB的文件读取

      • source+1层 10-12MB的文件读取

      • source+1层10-12MB的文件写入

    • compaction1MB文件有25MB的文件IO开销,除以100MB/s的文件IO速度,估计开销为25ms。

    • 因此当一个1MB的文件无效查询超过25次时,便可以对其进行合并。

读取流程

  • 依次尝试从MemTable-Immutable Memtable-SSTable中读取数据

  • Level0层sstable直接从memtable转化,key有重叠,需要读取所有sstable

  • 下层level每层sstable都不会重叠,最多只需要读取一个sstable

读写放大问题

什么是读写放大

写(读)放大指的是,当用户向存储引擎写入(读取)数据,存储引擎向硬盘写入(读取)的数据总量大于用户写入(读取)的数据总量。比如,用户写入LevelDB的数据量为10GB,而LevelDB写入硬盘的数据量为100GB以上,这造成了10倍的写放大。

levelDB的读写放大

写放大

  • 在LevelDB中,Li层级的SSTable跟Li+1层级进行合并时,需要先读取对应所有的SSTable文件,在内存中进行归并排序,然后生成新的SSTable,并全量写回硬盘。

  • LevelDB中Li+1层级的SSTable容量是Li层级的10倍。在最坏的情况下,Li+1层级的SSTable需要跟10个Li层级的文件进行compaction,从而相同的数据需要重写10次。因此,当数据在两层之间的移动最多会造成10倍的写放大。

  • 而LevelDB共有6层,从L0层级转移到L6层级需要转移5次,最坏情况下的写放大倍数达到了50倍。

读放大

读放大问题来源于两方面:

  • 第一,为了寻找一对键值对,LevelDB需要查询多个SSTable文件。最差情况下,LevelDB需要查询4个L0层级SSTable,以及L1到L6每层一个文件,共10个文件。
  • 第二,为了在一个SSTable文件中寻找一对键值对,LevelDB需要读取多个数据块。如上面章节介绍的,SSTable文件由数据块(data block),布隆过滤器块(filter block),元数据块(meta index block)和索引块(index block)构成。具体来讲,从SSTable中寻找1KB大小的键值对,需要首先读取16KB大小的索引块,判断该数据所在的位置,然后读取4KB大小的布隆过滤器数据块,判断该数据是否存在,最后读取4KB大小的数据块,最多24KB数据。
  • 综上,10个SSTable文件乘以每个文件读取24KB,读放大最终可能达到240倍。

几种优化思路

PebblesDB

PebblesDB: Simultaneously Increasing Write Throughput and Decreasing Write Amplification in Key-Value Stores

  • levelDB同一level的sstable严格有序,key不重叠,导致compaction代价极大

  • PebblesDB引入guard概念,guard之内的sstable可以无序,且key可以重叠

  • compaction不需要读取下层数据。(最底层除外)

  • 文件在不同level之间移动时只需要重写一次

  • 优化了写放大,但是读放大更严重了

LSM-trie

LSM-trie : an lSM-tree-based ultra-large key-value store for small data items

  • 将每一个Level均由多个sub-levels替代,以减少写放大。

  • 之后在合并的过程中将这些sub-level中的数据进行合并,并将合并后的内容作为下一个level。

  • 通过这种方法,数据在不同level间移动时只需要重写一次,所以也就很好的减少了写放大。

  • 跟pebblesDB相同,优化了写放大,但是读放大更严重了

WiscKey

WiscKey: Separating Keys from Values in SSD-Conscious Storage
工程实践
pingcap.com/blog-cn/tit…

  • 一般情况下键比较小,值比较大

  • WiscKey将键值分开存储,lsm-trees只存键和索引,值存在单独value log里面。

  • 写放大优化

    • 假设写入一对键值,键的大小为8字节,值的大小为1024字节,LSM-trees数据结构的写放大倍数为10倍,由于我们只将键存在LSM-trees,那么实际的写放大倍数仅仅为1.07倍((8 * 10 + 1024 ) / (8 + 1024) = 1.07)。

  • 读放大优化

    • 对于一次读取操作,存储引擎需要先从键仓库读取地址,然后再从值仓库获取真实的值。表面上看来这个过程比传统的LSM-trees算法需要多一次磁盘I/O操作,但是,由于在LSM-trees中并不需要存储值,数据量显著减少,存储引擎中LSM-trees的大小显著小于LevelDB,一次查询操作所需要查询的level数量将减少,因此减少了读放大,读取性能将获得提升。

如何进行垃圾回收

  • 遍历LSM-trees代价太大,遍历value log更高效

  • Value log中记录键信息,去lsm-trees查键值是否失效

  • 从尾部开始垃圾回收,失效直接丢弃,有效将数据移到头部

  • 如果update/delete操作频繁,就会导致反复GC,数据反复从尾部移动到头部,反而增加了写放大问题

HashKV

HashKV: Enabling Efficient Updates in KV Storage via Hashing

  • 将lsm trees本身的log文件和value log文件合二为一

  • 根据key的hash值,将value log分成非常多的segment group

  • 垃圾回收以segment group为单位,哪个segment group最近update多,就回收哪个

  • check k-v 的有效性

    • 不需要查找 LSM-tree,segment group 中的 value 都是以 log-structed 形式存在的,那么从 tail 开始,最后面的某个 key 对应的肯定就是 lastest version,前面的就是旧的 可以删除的版本。由于segment group较小,完全可以全量扫描一遍这个 segment,注意从后往前扫描,就可以获取所有有效的value。

  • GC性能提升,但是写入的log分成了太多segment,把原来顺序写入的优势给丢掉了

参考链接

相关论文

  • The log-structured merge-tree (LSM-tree)
  • Bigtable: A Distributed Storage System for Structured Data
  • Skip lists: a probabilistic alternative to balanced trees
  • PebblesDB: Simultaneously Increasing Write Throughput and Decreasing Write Amplification in Key-Value Stores
  • LSM-trie: An LSM-tree-based Ultra-Large Key-Value Store for Small Data Items
  • WiscKey: Separating Keys from Values in SSD-Conscious Storage
  • HashKV: Enabling Efficient Updates in KV Storage via Hashing