基于RocksDB实现精准的TTL过期淘汰机制

3,927 阅读11分钟

本文来自OPPO互联网技术团队,如需要转载,请注明出处及作者。欢迎关注我们的公众号:OPPO_tech

Parker 是 OPPO 互联网自研的一个基于 RocksDB 的分布式 KV 存储系统,它是一款类 Redis 的存储系统,主要解决的是用户使用 Redis 遇到的内存超限启动恢复时间长,一主多从代价大,硬件成本昂贵,无法存储海量数据等问题。

1. Parker简介

Parker 具有如下特性:

  • 支持海量存储:单个集群存储容量可达数百 TB,线上单集群 TPS 峰值可达百万级。

  • 支持水平扩展:存储容量、读写性能都可通过增加机器的方式水平扩展。通过数据分片 (slot) 的方式,将不同的分片散落在不同的节点,保证存储容量和读写性能的可扩展 。

  • 服务高可用:每个数据分片包含两副本,主备副本可在秒级内切换,保证单个数据分片的读写服务的高可用。

  • 兼容部分 Redis 协议:Parker 对外通信协议兼容 Reids Cluster 协议,支持 String 和 Hash 类型及对应的操作函数,即用户可以通过 Redis 客户端来读取 Parker 中的数据。

  • 支持 TTL 特性:一条数据写入后,超过 TTL 指定的时间戳,就过期淘汰,对用户不可见。这就需要 Parker 能及时删除过期数据,回收过期数据占用的磁盘空间。

2. 遇到问题

我们在容量为 5TB 的存储服务器上部署了 8 个 Parker 实例,写入的数据设置为 3 天过期,预计在数据写入速度和过期回收速度平衡的情况下,单实例会保持有 300GB 的存储占用。

实际上 Parker 运行五天后发现磁盘使用率不断攀升,开始写入的前三天,磁盘使用率直线上升,而后虽然上升速度有所减缓,但是总体上升速度还是比较快,与预期效果有严重偏差。虽然数据在 TTL 过期之后无法读取到,但是实际上磁盘空间并没有得到及时回收,这导致磁盘使用率居高不下。

3. 原因分析

3.1 RocksDB 原理

Parker 的底层存储引擎使用的是 RocksDB ,RocksDB 底层数据存储是 LSM 架构。数据分为不同的层,默认是 7 层,compaction styles 默认选择 leveled compaction。如下图所示:

用户写入数据到 RocksDB 时,会先将数据写入到一个 Memtable 中,当一个 Memtable 写满了之后,就会变成 immutable 的 Memtable。RocksDB 在后台会通过一个 flush 线程将这个 Memtableflush 到磁盘,生成一个 Sorted String Table (SST) 文件,放在 Level 0 层。当 Level 0 层的 SST 文件个数超过阈值之后,就会通过 compaction 策略将其合并到 Level 1 层,以此类推。

如果没有 Compaction,那么写入是非常快的,但这样会造成读性能降低,同样也会造成很严重的空间放大问题。为了平衡写入、读取、空间三者的关系,RocksDB 会在后台执行compaction,将不同Level 的 SST 进行合并。

3.2 TTL实现原理

RocksDB 中有一个 CompactionFilter 功能,该功能就是 RocksDB 在 compaction 每一条数据时,都会调用一个 Filter 函数 ,这是一个钩子函数,可以用户自定义。TTL 的实现方法就是在 Filter 函数中实现 TTL 过期删除逻辑,具体实现如下所述:

  1. 往 Parker 中写入数据时,我们在数据的 value 的尾部添加四个字节的 TTL,以 String 类型举例,其具体的数据编码格式如下图所示:

  1. 在 Filter 函数中,我们实现了这样的逻辑:取出数据尾部的 TTL,并与当前时间进行比较,如果TTL 小于当前时间的数据,就认为该数据已过期,不再合并到下一层文件中,从而达到删除数据的目的。

3.3 问题分析

Parker 中配置 RocksDB 每一层存储的容量上限如下:

仔细梳理 RocksDB 的 compaction 原理:RocksDB 触发 leveled compaction 的条件是该层文件大小或个数超过上限,例如 Level 2 的文件总大小超过 10GB,则会触发 compaction,从而调用compaction fliter,过滤掉 TTL 过期的数据。

基于我们遇到的问题,结合 RocksDB 的原理进行分析,我们发现:在我们的场景中会有 380GB 左右的数据会落在 Level 4,而这些数据虽然大部分已经过期,但由于这一层的数据一直没能达到该 Level 容量的上限 1TB,所以未触发 compaction filter,所以造成过期数据没有被删除,磁盘空间回收不了。

4. 解决方案

简单的更改 RocksDB 的配置,使得每一层存储的容量上限变小,可以解决上述问题,但是这种方案并不具备普适性。不同的使用场景,不同的存储数据量,存储数据量一旦改变就需要重启服务来更改RocksDB 的配置,这是业务不可接受。

从宏观上分析,回收过期数据磁盘空间的方案主要有两类:

  • 业务实现过期删除逻辑,主动删除过期数据。
  • 依赖 RocksDB 的 compaction 机制,在触发 compaction 的时候对过期数据进行删除。

为了实现快速回收磁盘空间删除过期数据,我们结合 RocksDB 的原理,梳理出了以下的几种方案:

4.1 业务实现删除逻辑

通过 Parker 自身逻辑,来主动删除过期数据。业内有赞 KV 就是采用类似的方案。具体做法是,保持现有存储列族不变,另外增加一个列族,专门存储 key 的 TTL,然后通过一个 goroutine 根据当前时间戳,按照过期数据删除策略可以是定时触发,例如凌晨1点,或者每个一段时间触发等。该列族中 key 的编码规则如下:

  • key:时间戳 + key 的类型 + key 值

  • value:存储一个字节,代表不同数据类型

这个方案优点是回收速度快且回收时间可控,但是缺点就是实现复杂,极端情况下会降低 50% 的TPS。

4.2 OpenDbWithTTL 方案

这个是 RocksDB 本身支持的一种数据过期淘汰方案,该方案是通过特定的 API 打开 DB,对写入该 DB 的全部 key 都遵循一个 TTL 过期策略,例如 TTL 为 3 天,那么写入该 DB 的 key 都会在写入的三天后自动过期。该方案底层也是通过 compaction filter 实现的,也就是说过期数据虽然对用户不可见,但是磁盘空间并不会及时回收,另外该方案不灵活,无法针对每一条 key 设置 TTL。

4.3 主动触发 RocksDB 的 compaction

目前空间无法快速回收的根本原因就是数据堆积在某一层,而该层没有触发 compaction,那么我们可以手动调用 RocksDB 的 CompactionRange 函数,来触发 compaction filter,达到快速回收磁盘空间的目的。但是主动调用 CompactionRange 会导致 RocksDB 自身的 compaction 暂停,这会触发 Write Stall,造成非常严重的后果,所有这种方案也不是非常完美。

4.4 Periodic compaction + dynamic compaction

Periodic compaction 的主要原理是增加一个 periodic_compaction_seconds 参数,并记录每个SST 文件的创建时间,每隔 periodic_compaction_seconds 秒,主动对这个 SST 文件进行 compaction 操作,从而回收沉底的 SST 文件;而 dynamic compaction 则是通过设置level_compaction_dynamic_level_bytes 为 true,进行动态合并,而不是按 level 的顺序合并到下一层,这使得 compaction 更加频繁。这个方案实现方式比较优雅,无须改动现有代码结构,只需要改动一些配置即可。

对以上几个方案进行比较,方案 4 实现较为完美,对实现逻辑改动较小,于是我们对方案 4 进行了实验验证。

5. 实验验证

5.1 机器配置

CPU 内存 磁盘
64核 188GB 5.9TB Nvme-SSD

5.2 RocksDB 新增配置

  • RocksDB 版本:6.4.6
  • level_compaction_dynamic_level_bytes = true;
  • periodic_compaction_seconds=3600;

5.3 测试结果

我们向 Parker KV 存储写入字符串数据:总写入数量:30000000000(三百亿)条数据,单条数据170Byte 左右。每条数据的过期时间为写入时间之后的 10800s,即写入 3 小时后过期,写入速度为51MB/s,写入总量预估为 5.32T。该机器磁盘占用情况如下:

5.4 结果分析

  1. 前 3 个小时因为写入的数据都没有过期,所以磁盘的使用率几乎是线性增长的,大概以 51 MB/s(即约180GB/h) 的速度增加,因为 RocksDB 本身对数据有进行压缩,所以磁盘大概增长了520GB。

  2. 第 4 个小时开始,数据开始有过期了,但是因为触发 periodic compaction 的 SST 文件并不多,而且开启了 dynamic_level,本身 compaction 就相对比较频繁一些,未更新时间大于 1h 的 SST文件比较少,另外即使触发了 periodic compaction,需要删除的 key 也比较少,所以在这 1 个小时内,磁盘的增长率有明显下降,但是总体上磁盘的使用还是在增长的,写入的磁盘空间比回收的空间要多。

  3. 从第 4 到第 5 个小时这段时间内,过期的 key 开始变多,而且触发 periodic compaction 的 SST文件也增加,这个时候,写入的磁盘空间与删除的磁盘空间大致能达到平衡,磁盘使用增长率大幅度下降,接近于 0。

  4. 理想情况下,应该是从第 5 个小时之后,磁盘的使用率应该稳定在某个水平线左右上下波动,当然在第 5 到第 9 小时,这段区间内,基本就是符合理想情况。

  5. 从图中可以看到,在第 9 个小时即 20:00 的时候,磁盘使用率骤然下降,通过查看 RocksDB 日志分析发现,在这段时间内有大量的 SST 文件触发 periodic compaction,然后整个被删除,level 6 的 SST 文件数量锐减了接近 500 个。因为这个时候,大多数文件的 key 都是全部过期的,一旦触发 compaction 就全部回收,所以这段时间磁盘回收大于写入的量。

  6. 在磁盘使用率骤降之后,触发 periodic compaction 的 SST 文件也会相对减少,导致磁盘使用率又有了一小段的上升,从图中可以看到第 10 和第 11 个小时,磁盘使用率的增长量又增大了。

  7. 在之后的很长一段时间内,磁盘的使用率基本维持平衡。这段时间内可以认为有 3 个小时的写入量没有过期,这个没有过期的量约为 600GB,而这个时候磁盘的使用达到了 900GB,也就是说接近 1.5 倍的空间放大。

  8. 在 11/7 11:00 到 13:00 这段时间,我们降低了写入速度,整个吞吐量从 58MB/s 下降到10MB/s,可以看到图中磁盘的使用率在这段时间也是骤然下降,也就是说降低了写入的速度,过期的量没有减小,磁盘回收的速度不变,回收速度大于写入速度,磁盘使用率下降。

  9. 在 11/7 11:50 的时候停止写入数据,Parker 的写入吞吐量降为 0,而这个时候 Parker 还存在大量过期数据,这个时候磁盘使用率明显在下降,一直降到某个水平线。

  10. 按照最理想的情况应该是回收到最开始写入的水平线,然而理想只能是理想,最后磁盘使用率稳定在 10.239%,比最开始的 6.617% 多了 3.622%,也就是有 218GB 的磁盘空间还没有被回收,这部分数据会在重新启动写入的时候被回收。

6. 总结

目前来看,通过 periodic compaction + TTL 过期来回收磁盘空间的方案是可行的,并且我们总结出如下规律:

通过监控发现 periodic compaction 对 CPU 负载有一定影响,也就是说periodic_compaction_seconds 时间越短,CPU 负载越高。其实这也容易理解:RocksDB 更加积极主动的进行 SST 文件的 compaction,必然会消耗更多的 CPU 资源,建议将这个参数调整为12 小时。

从理论上推导,整个 RocksDB 中会有约 periodic_compaction_seconds 时间长度的过期数据延迟回收,从而造成一定的空间放大,所以部署的时需预留有一定的空闲磁盘空间,建议预留 30%的冗余存储空间。

7. 参考资料

1.github.com/facebook/Ro…

2.tech.youzan.com/shi-yong-ka…

3.mysql.taobao.org/monthly/201…

4.github.com/facebook/Ro…

5.tech.meituan.com/2018/11/22/…

6.www.cockroachchina.cn/?p=1282