【论文阅读】Wisckey Separating keys from values in ssd-conscious storage
背景
LSM Tree 已经在 write-intensive database 中大量应用,但旧的实现存在以下问题:
- 严重的读写放大(read/write amplification)
- 没有充份利用 SSD 的内部并行性。
问题 1 尤为严重,LevelDB 的最坏情况下,写放大(从 L0 下沉至 L1)为 50,读放大(从 L0 读取所有 SST,再读穿到 L6)为 24×14 = 366。严重的读写放大不仅会导致 SSD 磨损,还会导致占用大量带宽。
[!NOTE] LevelDB 的读放大 读放大主要有两大来源:
- LevelDB 的分层设计
- 从 SST 中查找 key-value 需要读额外的元数据。
L0 最多 8 个 SST,彼此间可能存在重叠,因此 L0 最多读取 8 个 SST;L1 到 L6 中,同一层中的 SST 彼此不重叠,因此 L1 至 L6 最多读取 6 个 SST。从 L0 到 L6 最多读取 8+6=14 个 SST,这里的写放大是 14。
从 SST 中搜索 key,需要读取 index block + bloom-filter blocks + data block。论文以 1KB 的 key-value 为例,index block 占 16KB,bloom-filter block 占 4KB,data block 占 4KB,总共需要读取 24KB。这里的写放大是 24。
最坏情况下,从 L0 读到 L6,每次读取 SST 的读放大都是 24,读放大总计 14×24=336。
[!NOTE] LevelDB 的写放大 LevelDB 的写放大主要来自分层设计。 为了写一个 SST 被迫进行 comapction,一次 compaction 需要读取同层的其他 SST,最终写入下一层(上下层 SST 大小相差 10 倍)。考虑将某一层写满时,此时的写放大(总写入量/实际数据量 - 1)是 10。comapction 可能会导致更多的 comapction(比如下面某一层已经满了),最坏情况下 LevelDB 有 6 层,因此最坏情况下写放大是 5×10=50。
写放大的分析并不直观。考虑写满 L0,此时写放大是 0(即没有放大)。写满 L0 后进行 compaction,将 L0 下移成 L1 的一个 SST,此时写放大 +1;不断写入,再从 L0 下移成 L1 的一个 SST,写放大 +1;L1 写满后,总共进行了 10 次 compaction,因此每一层写满时,写放大 +10。总共有 6 层,其中只有 L1-L5 是 comapction 的得到的,因此最坏情况下写放大是 5×10=50。
论文基于 LevelDB,提出 KV 分离的 LSM Tree,即 key 仍然使用 LevelDB 的 LSM Tree 存储,Value 已 log-structured 方式存储。通常 key 的大小远小于 value,通过 KV 分离可以极大地缓解读写放大。
KV 分离的设计带来了给范围查询、一致性、GC 等带来了挑战,论文巧妙地通过一系列简洁高效的设计解决了这些问题。
设计
在 LSM-tree 中存储<key, addr>
,在 vLog(Value Log)中以 log-structured 方式存储 value。
查询时,在 LSM-tree 查找到<key, addr>
,拿到地址后从 vLog 中读取 value。
写入时,先在 vLog 中附加 value,再把对应的<key, addr>
插入到 LSM-tree。
LevelDB 的实现中,KV 一体,查找到 Key 即可读写 Value,KV 一体的设计有以下好处:
- 查询写入都比 Wiskey 少一步(读写 vLog)
- 支持高效的范围查询(KV 在 SST 中有序)
- 容易实现一致性(Key/Value 总是同时写入)
Wiskey 在实现上主要面临以下问题:
- 如何实现高效的范围查询?
- 如何确保崩溃时 key-value 的一致性?
- 如何进行 vLog 的 GC?
实现
范围查询
Wiskey 分两步进行查询:
- 从 LSM-tree 查询
<key, addr>
。 - 从 vLog 读取 value。 value 在 vLog 中不是有序的,因此无法顺序读取。 Wiskey 用一下两种方法实现高效的范围查询:
- 缓存 LSM-tree 到内存。LSM-tree 只含有 key,通常可以全部或部分缓存到内存中。查询 key 的过程不涉及磁盘 IO。
- 并发从 vLog 读取 value。SSD 的并发乱序读性能和串行顺序读性能持平。 评估一节可以看到,SSD 在数据大小达到一定阈值后,并发乱序读吞吐量才能和串行顺序读持平,并且并发乱序读性能取决于 SSD 的内部并行程度。因此 Wiskey 范围查询的性能取决于:
- value 的大小。小对象的范围查询性能不如 LevelDB。
- SSD 内部并行程度。
垃圾回收
key 存储在 LSM-tree 中,compaction 时进行 GC。Wiskey 主要考虑的是 vLog 的垃圾回收,即设计一种在线的垃圾回收算法,尽可能减少对用户写入的干扰。
Wiskey vLog 的 GC 算法和 Log-structured SSD 类似。合法的 value 放在连续区域 tail-head 中,tail 标示 GC 起始位置,head 标示 GC 终止位置(同时也是下一次写入位置)。tail 和 head 会被持久化到 LSM-tree 中,GC 时递增 tail,所有写入都递增 head。
GC 流程就是从 tail 开始读取 value,将最新的 value 附加到 head,并更新 tail/head。为了判断 value 是否过时,Wiskey 修改了 vLog 的布局,将 key-value 一起存储到 vLog 中。读取 key-value,并从 LSM-tree 中查询该 key,判断地址是否和 key-value 在 vLog 中的地址相同即可判断该 value 是否过时。
GC 流程如下:
- 判断 KV 是否过时。读取 key-value,并从 LSM-tree 查询该 key,校验地址是否相同。
- 以同步(写入后立刻 fsync) append 最新的 value 到 vLog。
- 以同步修改 LSM-tree 中 value 对应的地址和 tail maker。 当整个 tail-head 区域完成扫描后,释放被回收的 value 占用的存储空间。
[!NOTE] 文件空间的释放 vLog 实现为一个文件,GC 后被回收的文件范围(tai-head)会变成一个“洞”(hole)。现代文件系统支持通过 fallocate(2) 分配/回收文件空间,Wiskey 就是用 fallocate(2) 释放 GC 回收的空间。
Wiskey 的 GC 是在线算法,GC 和用户写并发进行。这会导致竟态条件:
- GC 扫描到 key,发现该 key 时最新的。
- 用户写入该 key 的新 value。
- GC 将该 key 的旧值的新地址写入 LSM-tree。
- key 指向旧值,导致新值丢失。 论文没有提到这个问题,但可以猜想,为了避免竟态条件,每个对象的 GC 都要是原子的。
有了以上背景知识,Wiskey 的插入流程可以总结为:
- 写 vLog。同步写入则立即刷盘。
- 更新 LSM-Tree。同步方式写 vLog 时,不需要写 WAL。
- 更新 head。
故障一致性
故障一致性(crash consistency)指发生故障时,满足以下两个要求:
- 插入的 key-value 是原子的。要么 key-value 已插入,要么未插入。
- 按插入次序恢复 key-value。对于插入的序列 K-V1、K-V2 直至 K-Vn,恢复时也按这个次序恢复。
实现要求 1 原子性的方法如下:
- 如果未查找到 key,视作 key-value 未被插入。已被插入的 value 后续会被 GC。
- 如果查找到 key,进行验证(verification)。
- key 对应的地址是否在合法的 head-tail 范围内。验证失败的情况是更新 head 前故障。
- vLog 中的 key-value 是否和 key 对应。验证失败的情况是 vLog 刷盘(异步写入)前故障。
要求 2 的实现利用了现代文件系统的特性:append 不会乱序。例如,按 v1、v2、v3、v4 次序 append 文件,故障恢复后,成功 append 的序列一定是 v1、v2、v3、v4 的某个前缀(如 v1、v2)。
优化
消除 LSM-tree 的 WAL
LevelDB 为了实现持久性,写 memtable 前要先写 WAL。在 Wiskey 中,vLog 同时存储了 key-value,而且写 vLog 先于写 LSM-tree。只要保证 vLog 的写入是同步的,vLog 就可以视作 LSM-tree 的 WAL。
优化 GC
论文给出了两种 GC 的优化:
- 并发查找:扫描过程中并发查找 key。
- 无效 KV 位图:使用 bitmap 记录 tail-head 范围内 key-value 是否过时。论文没有详细说明,猜测是 insert 时更新 bitmap,GC 直接查 bitmap 避免查 LSM-tree。
评估
测试表明,Wisckey 的读写放大均远远小于 LevelDB。
- Wiskey 顺序插入(key 递增)性能更好,原因是 Wiskey 不需要写LSM-tree WAL。
- Wiskey 乱序插入性能更好,原因是 Wiskey 的 GC 成本低于 LevelDB 的 compaction,并且 Wiskey 的写放大远远小于 LevelDB(1KB 大小下,Wiskey 的写放大接近 1,LevelDB 大于 12)。
- Wiskey 插入操作的延迟比 LevelDB 更稳定,原因是 LevelDB 的 compaction 导致前台写卡顿。
- Wiskey GC 开启后性能下降最多 30%。
- Wiskey 删除整个数据集 GC 最快,这是因为 GC 的成本来自扫描和将合法 key-value 附加到文件,整个数据集都被删除时没有合法的 key-value 需要附加,因此成本最低。
评估揭示了 LevelDB 性能瓶颈根源:
- 写放大浪费磁盘带宽。
- compaction 导致前台写卡顿。
- 写 LSM-tree log。