Skip to content

写了 180 MB,磁盘实际写了 700 MB ​

一个基于 RocksDB 的存储服务,业务写入量每天几十 GB,监控上磁盘的写入量却是它的好几倍。删掉一半的历史数据之后,磁盘占用一点没有下降,过了一夜才慢慢降下来。

很多存储引擎用的是 LSM 树:RocksDB、LevelDB,以及建在它们之上或采用同样结构的 Cassandra、HBase、TiKV、Kafka Streams 的状态存储等。它把随机写变成顺序追加,写入很快,代价是数据要被后台反复整理。「写一份,磁盘上写了几份」「删了数据,空间不回来」都是这个结构的直接结果。

本文用 RocksDB 11.1.2 把这几个数字量出来。实验见文末,数据写在容器内的临时目录里。

写入 180 MB:磁盘上实际写了多少(不含预写日志)顺序键179 MB,写放大 1.0随机键共 697 MB,写放大 3.9同一批键(36 MB)写 5 遍:文件占多大合并之前179 MB:5 个版本都在合并之后36 MB把这批键全部删除:文件占多大删除之后、合并之前40 MB:比删除前还多了删除标记合并之后0 MB三种现象是同一个原因:所有修改都是追加,整理靠后台的合并合并跟不上写入时,空间、读取要查的文件数、写入的卡顿会一起变差
图 1 · 实测(RocksDB 11.1.2,关闭压缩):LSM 树把写入先攒在内存里,满了整块刷成文件,再由后台把文件逐层合并。键是顺序的时候,新文件与旧文件的范围不重叠,直接挪到下层,不用重写;键是随机的时候,每次合并都要把下层范围重叠的数据一起重写,180 MB 的数据实际写了约 700 MB。覆盖和删除也只是追加,旧数据要等合并时才真正消失。

一、写入的路径 ​

一次写入经过三步:

  1. 追加到预写日志,保证掉电不丢。
  2. 写进内存里的有序结构(内存表)。到这里就返回了,所以写入很快。
  3. 内存表满了之后,整块按键的顺序写成一个不可变的文件。

文件越攒越多。同一个键的新旧版本散落在不同的文件里,读的时候要从新到旧查好几个文件。所以后台要不断地把文件合并:把范围重叠的文件读出来,归并排序,丢掉被覆盖的旧版本和已删除的数据,写成新的文件,放到下一层。这个过程叫压实(compaction)。

压实就是放大的来源:一条数据从写入到沉到最底层,会被读出来重写好几次。

二、写放大:顺序键与随机键 ​

写入 150 万个键,共 180 MB,关闭压缩以便按字节对账:

场景刷盘写入压实写入写放大
顺序键179 MB0 MB1.0
随机键181 MB516 MB3.9

键是顺序递增时,每个新文件的键范围都在已有文件之后,与下层没有重叠。压实只需要把文件「挪」到下一层,不用重写内容,所以压实写入是 0。

键是随机的时候,每个新文件的范围都覆盖整个键空间,与下层的所有文件重叠。每次压实都要把下层重叠的数据读出来一起重写。180 MB 的数据,压实又写了 516 MB。

这两个数字都不含预写日志,算上它各再加约 1。实验的数据量很小,只有两三层;生产规模的库有五六层,随机写入下写放大达到十几到几十是常见的。

写放大影响三件事:

  • 吞吐上限。 磁盘的写入带宽是固定的,放大 10 倍,业务能用的只有十分之一。压实跟不上时,RocksDB 会主动放慢甚至暂停前台写入,表现为写入延迟的周期性尖峰。
  • 固态盘寿命。 盘的寿命按写入总量计算,放大的部分同样计入,见新盘跑分很快,用了一个月就慢下来了。
  • 键的设计。 能用时间有序的键(时间戳前缀、递增 ID),写入就接近顺序的情况;用哈希值、随机 UUID 做键,就是随机的情况。B+ 树里随机主键带来的是页分裂,这里带来的是压实;主键怎么选见订单、库存与数据一致性。

实验里也跑了另一种压实策略(通用压实),随机键的写放大是 3.7。这个数据量下两种策略差别不大,不能据此比较;一般的规律是通用压实写放大更低、占用的空间更多。

三、空间放大:覆盖写留下的旧版本 ​

把同样的 30 万个键(36 MB)连续写 5 遍,暂时关闭自动压实来观察中间状态:

时刻文件大小
写完 5 遍,压实之前179 MB
压实之后36 MB

更新不是原地修改,而是追加一个新版本。5 个版本都在磁盘上,有效数据只有五分之一。压实之后旧版本才被丢弃。

默认配置下压实在后台持续进行,不会积累到 5 倍这么多,但磁盘占用大于有效数据是常态。更新频繁的数据(计数器、状态字段)尤其明显。容量规划要按「有效数据乘以空间放大系数」来算,并且给压实留出临时空间:压实时新旧文件同时存在,最坏情况下一层的数据要有双份。

四、删除:空间不是马上回来 ​

把这 30 万个键全部删除:

时刻文件大小能读到吗
删除之前36 MB能
删除之后、压实之前40 MB读不到
压实之后0 MB读不到

删除也是一次写入:写下一条「这个键已删除」的标记。删除之后文件反而从 36 MB 变成了 40 MB,多出来的就是这些标记。读取时遇到标记就返回「不存在」,所以数据立刻读不到了,但原来的数据和标记都还在磁盘上,要等压实把它们一起清掉。

这就是开头「删了一半数据,磁盘没有下降」的原因。两个后果:

  • 空间回收有延迟。 磁盘快满的时候靠删数据救急,短时间内不但没有效果,还会多占一点。需要立刻回收时,可以手动触发指定范围的压实,它本身也要消耗写入带宽和临时空间。
  • 大量删除标记拖慢范围查询。 扫描一段键范围时,要逐个跳过其中的删除标记。删掉了一大片连续的键之后,对这个范围的扫描会变得很慢,直到压实完成。「先删后扫」的队列式用法(把 LSM 存储当队列,消费一条删一条,再从头扫描下一条)是典型的受害者。

批量清理数据时,按范围删除、直接丢弃整个分区或整个列族,比逐条删除的代价小得多。关系数据库里同样的问题,见千万级大表怎么清理数据。

五、三种放大不能同时最小 ​

LSM 树的调优是在三个量之间取舍:

含义压实越积极
写放大写一份数据,磁盘上写了几份越高
空间放大磁盘占用是有效数据的几倍越低
读放大读一个键要查几个文件越低

压实做得勤,旧版本和删除标记清得快,文件少,读得快,占用小,但写得多。压实做得少则相反。没有三者都最优的配置,只能按负载选:写多读少、空间便宜的,容忍更大的空间放大;读多、空间紧的,接受更高的写放大。

和 B+ 树相比,LSM 树的写入吞吐更高、压缩效果更好,读取(尤其是范围扫描和读不存在的键)的开销更大、延迟更不稳定。InnoDB 的写入路径见 Redo Log 与 Undo Log。

六、要看的指标 ​

  • 压实写入的字节数与用户写入的字节数之比,即实际的写放大。
  • 待压实的数据量、第 0 层的文件数。持续增长说明压实跟不上写入。
  • 写入被限速或暂停的次数与时长。
  • 磁盘占用与有效数据量之比。
  • 读延迟的高分位,以及布隆过滤器的命中情况。

RocksDB 的统计与 rocksdb.stats 属性里都有这些数字,建在它之上的系统通常会转成自己的监控指标。


配套实验

  • codesphere-labs/storage/lsm-write-amplification:RocksDB 11.1.2 的 Java 包(JDK 25.0.4.1),关闭压缩,数据在容器内的临时目录:顺序键与随机键写入 180 MB 时的刷盘与压实字节数、同一批键写 5 遍后压实前后的文件大小、全部删除后压实前后的文件大小;脚本只断言范围(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权