首页 > 存储引擎 K/V 分离下的index回写问题

存储引擎 K/V 分离下的index回写问题

前言

近期在做on nvme hash引擎相关的事情,对于非全序的数据集的存储需求,相比于我们传统的LSM或者B-tree的数据结构来说 能够减少很多维护全序上的计算/存储资源。当然我们要保证hash场景下的高写入能力,append-only 还是比较友好的选择。

像 Riak的bitcask 基于索引都在内存的hash引擎这种,在后期的针对data-file 的merge 完成之后会将新的key-value-index回填到内存中的hash索引中,这个过程在实际的生产环境对性能有多大的影响还不太好确定。但是,很明显的一点是正确的hash引擎索引在高并发场景中的更新是需要加锁的。而一旦有了排他锁,也就意味着CPU的独占,这个时候用户的读取和插入 就会和merge 之后的index回填发生锁的竞争,从而影响引擎的外部性能。

而同样的问题在以 Wisckey 为首的 LSM-tree key-value 分离策略中尤为明显,包括Titan, rocksdb的BlobDB,BadgerDB 都会面临这样的问题,他们在compaction 之后的回填 大value-index 还需要产生I/O操作,这个代价可能会更高,那他们是怎么解决这个问题的呢?

探索他们的解决办法不一定完全能够借鉴到hash 引擎的实现中,不过是可以提供一个解决思路。

Titan 的回写策略

关于Titan的 GC 策略介绍可以参考:Titan GC策略实现

Titan 是 pingcap 早期基于wisckey 做出来的key-value 分离存储引擎,可以作为rocksdb 的一个插件来使用。

它的解决办法是提供一个可配置项gc_merge_rewrite

  1. 关闭:会在GC 过程中将key-value写入新的blobfile 之后,通过正常的Write with Callback + Put 接口回写blob-index到lsm-tree。这个也就是默认回写的方式,Titan的Callback 是一个Get操作,在写入之前会先尝试读一下这个key 是否在lsm-tree中,如果不在就不会写入了。而且会将新的key + key-index 完全写入。
  2. 开启:则是一个回写产生的性能问题和读性能之间的一个trade-off。开启之后直接写入 一个kMergeType blob-index,这种情况下不需要去执行Callback了,而是直接写入Merge操作,后续通过compaction 进行 key的blob-index的合并 或者 读请求命中这个key的时候会进行merge。merge请求本省不会携带原本大小的value,所以不会产生较大的写放大,只是在读的时候需要将当前key之前的merge都进行合并,对读性能可能有较大的影响。

相关的实现代码可以参考:

void BlobGCJob::BatchWriteNewIndices(BlobFileBuilder::OutContexts& contexts,Status* s) { ...// 关闭merge,调用默认的写入方式if (!gc_merge_rewrite_) { merge_blob_index.EncodeToBase(&index_entry);// Store WriteBatch for rewriting new Key-Index pairs to LSM// 在这个策略下,rewrite_batches_ 最后的消费是通过 Rocksdb::WriteWithCallback实现// 的,在写入的时候会执行 Callback,里面会去查一下key是否存在。GarbageCollectionWriteCallback callback(cfh, ikey.user_key.ToString(),std::move(original_index));callback.value = index_entry;rewrite_batches_.emplace_back(std::make_pair(WriteBatch(), std::move(callback)));auto& wb = rewrite_batches_.back().first;*s = WriteBatchInternal::PutBlobIndex(&wb, cfh->GetID(), ikey.user_key,index_entry);} else {  // 开启,rewrite_batches_without_callback_ 的消费过程是 直接写入Merge 类型的keymerge_blob_index.EncodeTo(&index_entry);rewrite_batches_without_callback_.emplace_back(std::make_pair(WriteBatch(), original_index.blob_handle.size));auto& wb = rewrite_batches_without_callback_.back().first;*s = WriteBatchInternal::Merge(&wb, cfh->GetID(), ikey.user_key,index_entry);}...
}

最终对两个数据结构的消费逻辑统一是在RewriteValidKeyToLSM函数中。

BlobDB 的回写策略

BlobDB 的大体特性可以参考BlobDB 特性及性能测试结果。

因为BlobDB 新版本是社区比较推荐的一个k/v分离的稳定版本,基本的Rocksdb特性都已经支持了,包括trasaction/checkpoint/backup 等这一些不常用但很重要的功能都已经支持了。除了像merge/ingest等更为偏的能力暂时还不支持。

BlobDB的在GC上的一个考虑就不想因为后续频繁的回写处理影响正常的请求。

如果开启了GC enable_blob_garbage_collection

  1. 则在compaction过程中,迭代器 处理 类型 kTypeBlobIndex 的key时会进入到GarbageCollectBlobIfNeeded,因为分离存储的时候lsm中存放的value 是key-index,即这个value能够索引的到blobfile的一个index。
  2. 确认当前blob能够参与GC 且 当前key需要被保留,则根据key-index 读取到blob_value 并 直接写入到新的blob-file中。并且将新的blob-index 作为当前key的value,提取出来。
  3. key 和 新的key-index 继续参与compaction后续的落盘行为。

主体第二步,也就是想要GC的话会在compaction过程中直接将过期的blob-value直接回收,compaction完成之后 lsm的sst 以及 blob都会被更新到,只需要维护后续的旧的blob回收即可。

代码实现如下:

  1. compaciton过程中(迭代器按key处理阶段) 调度GC
    void CompactionIterator::PrepareOutput() { if (valid_) { if (ikey_.type == kTypeValue) { ExtractLargeValueIfNeeded();} else if (ikey_.type == kTypeBlobIndex) { // 调度GCGarbageCollectBlobIfNeeded();}...
    }
    
  2. 按照上面的步骤进行处理:
    void CompactionIterator::GarbageCollectBlobIfNeeded() { ...// 开启GCif (compaction_->enable_blob_garbage_collection()) { BlobIndex blob_index;{ // 1. 获取blobindexconst Status s = blob_index.DecodeFrom(value_);if (!s.ok()) { status_ = s;valid_ = falsereturn;}}if (blob_index.IsInlined() || blob_index.HasTTL()) { status_ = Status::Corruption("Unexpected TTL/inlined blob index");valid_ = false;return;}// 2. 确认当前blob-index 允许参与GCif (blob_index.file_number() >=blob_garbage_collection_cutoff_file_number_) { return;}const Version* const version = compaction_->input_version();assert(version);{ // 3. 解析读出来当前blob数据const Status s =version->GetBlob(ReadOptions(), user_key(), blob_index, &blob_value_);if (!s.ok()) { status_ = s;valid_ = false;return;}}value_ = blob_value_;// 4. 将读出来的blob数据写入到新的blob file,并构造新的 value-index 作为当前lsm-tree// 即将存储的key的value.if (ExtractLargeValueIfNeededImpl()) { return;}ikey_.type = kTypeValue;current_key_.UpdateInternalKey(ikey_.sequence, ikey_.type);return;}...
    }
    

问题1: compaction过程中读取大value和我们rocksdb 未k/v分离 场景下的读取有什么区别?

这里的读取只会是保留的key的real value,对于那一些要清理的key,则不会读取。为了避免业务峰值触发大量的compaciton以及 GC的读取,GC的触发可以通过SetOption 来动态调整。

问题2: 相比于 Titan GC 调度的优劣?

个人觉得,BlobDB的GC调度更为简洁高效低成本。

来,我们对比一下GC过程中产生I/O的步骤:

  1. TitanDB,通过EventListener 在compaction过程中拿到需要参与GC 的blobfile 集合,compaction完成之后 对待GC的 blobfile 进行iter 迭代。

    a. 拿到每一个key 去 LSM 点查 是否存在。

    b. 存在,则读取其所在blobfile 的 大value,写入到新的blobfile

    c. 写入key 以及 新的value-index 到 LSM -tree(伴随着后续的逐层compaction,或者 merge的合并)。
  2. BlobDB,直接在compaction过程中一起调度GC。

    a. 不需要反查,compaction过程中知道这个key是要keep还是要skip,直接对keep下来的key 读取blobfile的大value,写入到新的blobfile.

    b. 继续compaction时直接将当前要keep下来的key 以及 新的 value-index 写入 lsm即可。

可以看到,blobdb 的第二个步骤是正常的compaction写入逻辑,相比于Titan来说,其实也就只进行了 Titan有效的第二步,少了第一步的点查和第三步的回写。除此之外,Rocksdb的可调性更高一些,可以针对必要的GC时的大value读写进行控制,允许动态调整,从而最大程度得减少了GC对上层请求的性能影响。

具体在 GC 过程中的性能差异会在后续补充上。

BadgerDB 的回写策略

Badger 作为 dGraph 社区备受 cgo 折磨之后推出的自研k/v 分离存储引擎,在go 语言中还是非常受欢迎的。

本文仅讨论BadgerDB 在k/v 分离场景的回写策略,对于其测试优于Rocksdb(rocksdb的默认参数) 以及 其相比于Rocksdb 的其他优秀设计暂不展开讨论。

Badger的大value是存放在value log文件中,它很聪明的一点是GC 接口只交给用户来调度,而不是自己内部自主触发,这样的责任划分就非常清晰了,用户自己选择开启关闭GC,来自己承担GC引入的读写问题,真是机智。

当然BadgerDB 这里的GC回写并没有看到太亮眼的设计,就是在对 value log 进行GC的时候和Titan不开启gc_merge_rewrite 逻辑差不多。

  1. 选择好了待GC的value-log文件,先从lsm中尝试读取key,存在则需要将value写入到新的value log中。
  2. 完成写入新的value-log之后,会将最终的key, value-index 更新到lsm-tree中。

回写源代码基本在RunValueLogGC 函数中的rewrite处理逻辑中,感兴趣的可以看一下。

总结

可以看到为了解决在LSM-tree中大value 不随着compaction一起调度而造成的性能问题,大家可谓是煞费苦心。Titan 尝试做了一些优化,但整体来看还是不尽人意。Rocksdb 的 Blobdb 还是更加成熟,可以说是考虑得很全面了,从实现上看确实有很明显的效果。而BadgerDB的做法更为彻底,这个问题我们不管,交给用户自由调度,因为用户大多数情况还是知道自己的业务什么时候处于高峰,什么时候处于低谷,产生的I/O竞争问题那是你们自己调度造成的,自己解决哈,🐂。

而回到最初的我们 hash-engine 的 hash-index回写问题,其实可以考虑借鉴一下 BlobDB的做法,不过需要接口做的更灵活一些。

更多相关:

  • 前言 最近在读 MyRocks 存储引擎2020年的论文,因为这个存储引擎是在Rocksdb之上进行封装的,并且作为Facebook 内部MySQL的底层引擎,用来解决Innodb的空间利用率低下 和 压缩效率低下的问题。而且MyRocks 在接入他们UDB 之后成功达成了他们的目标:将以万为单位的服务器集群server个数缩减了一...

  • 参考自:  https://www.cnblogs.com/zeng1994/p/03303c805731afc9aa9c60dbbd32a323.html 1、maven依赖

    springboot redis配置

    1、引入maven依赖 org.springframework.bootspring-boot-starter-data-redis   2、redis连接配置 spring:redis:h...

  • json 键值对增加、删除 obj.key='value'; // obj.key=obj[key]=eval("obj."+key); delete obj.key; vue中新增和删除属性 this.$set(object,key,value) this.$delete( object, key ) 触发视图更新 遍历键值 for...

  • 前言 还是回到传统的 LSM-tree 中,我们key-value 写入时以append形态存放到一个data-block中,多个data-block+metablock 之类的数据组织成一个sst。当我们读数据以及compaction的时候读到key 之后则很方便得读取到对应的value,一次I/O能够将key-value完全从磁...

  • 文章目录1. 为什么要有GC2. GC的触发条件3. GC的核心逻辑1. blob file形态2. GC Prepare3. GC pick file4. GC run4. GC 引入的问题5. Titan的测试代码...

  • 【BZOJ4282】慎二的随机数列 Description 间桐慎二是间桐家著名的废柴,有一天,他在学校随机了一组随机数列, 准备使用他那强大的人工智能求出其最长上升子序列,但是天有不测风云,人有旦夕祸福,柳洞一成路过时把间桐慎二的水杯打翻了…… 现在给你一个长度为 n 的整数序列,其中有一些数已经模糊不清了,现在请你任意确定这些整...

  • CrashReport系统在游戏内测当天出现了异常情况JVM僵死,通过top -p -H 结合jstack(jstack -m -l pid)查看,发现是VM Thread线程CPU占用100%,线程ID好为18540,线程信息如下:----------------- 18540 -----------------0xb...

  • // 下载blob文件流(暂不支持手机H5唤起下载文件!!!) downloadFile(res: any, fileName: any = '未命名', format: any = '.xlsx') {const blob = new Blob([res]);fileName += format;// for IEif (windo...