存储基础(1):写放大

1 分钟阅读

发布于:

本文是「存储基础系列」第 1 篇:解释写放大(Write Amplification)是什么、为什么几乎所有存储系统都绕不开它,以及工程上如何衡量与优化。


1. 什么是写放大

写放大可以用一句话概括:

应用层写入 1 份数据,存储系统为了完成这次写入,实际写了多份数据。

一个常见定义是写放大系数:

WA = 系统实际写入的字节数 / 用户逻辑写入的字节数

如果 WA = 4,意思就是:用户写了 1GB,系统最终在磁盘/SSD 上写了 4GB(包含日志、索引、合并重写等所有“额外写入”)。

写放大的常见来源:日志、索引/元数据、后台合并重写

1.1 两个“写放大”不要混

实际排查时,建议你把写放大拆成两个层级,否则很容易鸡同鸭讲:

  • 引擎内写放大(engine WA):单机存储引擎内部的额外写入(WAL、flush、compaction、元数据)。
  • 系统总写放大(system WA):复制/纠删码导致的额外写入(比如 3 副本就是 至少 3× 的“集群写入”)。

本文主要讲 引擎内写放大,因为它决定了单机上限与尾延迟稳定性;系统总写放大是分布式层面的成本模型,后面一致性/复制系列再展开。

2. 写放大从哪来(通用来源)

不同系统的细节不同,但写放大通常来自这些“额外写”:

  • 日志(WAL / redo log):为了崩溃恢复,先写一份顺序日志。
  • 索引与元数据:写 data 的同时,还要写 index、filter、manifest、checkpoint 等结构。
  • 覆盖写与版本管理:更新同一条记录,会产生旧版本、tombstone,最终通过后台回收。
  • 后台重写(compaction / merge / GC):为了读性能或空间回收,把数据反复合并并重写。

如果把“集群写入字节”也算进去,多副本复制会进一步放大成本(这是另一层面的“系统总写入成本”,和单机引擎内部的 WA 不同,但同样重要)。

2.1 一个“最小闭环”的写入至少写两份

很多初学者会惊讶:为什么即便没有 compaction,写放大也不可能是 1?

因为你至少需要:

  • WAL:保证“返回成功”的写崩溃后可恢复(顺序写)。
  • 数据文件/页:把数据落到最终结构里(页/段/SST)。

所以即便没有任何后台重写,你也经常会看到:

  • 强持久(每次 fsync):更稳但更慢,且尾延迟更敏感。
  • group commit(批量 fsync):吞吐更高,但允许更大的“未落盘窗口”。

3. LSM / Compaction 为什么容易产生写放大

LSM 的核心是把随机写变成顺序写(WAL + memtable + SST),吞吐很高;但代价是后台合并(compaction)会不断重写数据。

一个直觉模型:

  • 新数据先进入更“上层”(更小、更热)
  • 后台把上层文件不断合并进下层
  • 同一条记录可能会随着层级下沉,被重写多次

于是可以看到典型现象:

  • compaction backlog 增长 → 写延迟抖动
  • 删除/覆盖很多 → tombstone 需要 compaction 才能真正回收空间

因此,LSM 的“经典三角”就是:

  • 写放大(WA)
  • 读放大(RA)
  • 空间放大(SA)

你降低 WA,往往会在 RA 或 SA 上付出代价。

3.1 直觉公式:为什么 leveled compaction WA 往往更高

不追求严格推导,只给一个工程要点:在 leveled 策略里,数据会随着层级下沉,多次参与合并。

如果我们把每层容量比近似成 T(例如 10),层数为 L,那么“同一条数据被重写次数”大致与 L 同阶,写放大常常会接近一个“每层重写一次”的量级(再叠加 WAL / 元数据)。

这就是为什么:

  • tiered(更少合并) 往往 WA 更低,但读要合并更多 run,RA 更难。
  • leveled(更积极合并) 往往读更稳,但 WA 更高,且 compaction 更容易成为后台热点。

Leveled vs Tiered:为什么一个更稳读,一个更低 WA

4. B+Tree 就没有写放大吗?

也不是。

B+Tree 常被认为“就地更新”,但它也会出现写放大:

  • page 分裂/合并会导致额外写
  • WAL + page flush(甚至 doublewrite)依然是“写两份/多份”
  • checkpoint/刷脏页也会带来额外写入

区别是:B+Tree 的写放大更集中在“页级随机写 + 日志”,而 LSM 的写放大更集中在“后台合并重写”。

5. 如何衡量写放大(建议两个视角一起看)

  • 设备视角(真实成本):SSD/NVMe 的写入字节(最接近真实成本)。
  • 引擎视角(可解释):把写入分解成 WAL bytes、flush bytes、compaction bytes 等。

排查时你最需要回答:

  • WA 主要来自 WAL 还是 compaction
  • compaction 写放大主要来自 覆盖/删除 还是 策略本身

5.1 最实用的度量组合(建议你以后固定这么看)

  • 设备写入字节:最接近 SSD 寿命与成本(“真实写”)。
  • 引擎分解指标:例如 WAL bytes / flush bytes / compaction bytes(“可解释”)。
  • 写停顿信号:stall/throttle 次数、L0 文件数、compaction pending bytes(“会不会抖”)。

当三者一起看,你能很快把问题归因到“写路径的哪个阶段”。

5.2 一个可落地的“写放大拆账”例子(用数字说话)

假设业务 1 小时内逻辑写入 10GB(user bytes = 10GB),你从系统里观测到:

WAL 写入:10GB
Flush 写入:10GB
Compaction 写入:30GB

那么可以粗略拆成:

engine WA ≈ (10 + 10 + 30) / 10 = 5

这类拆账在工程上很有用,因为它直接告诉你“主要成本在 compaction”,你接下来应该优先盯 compaction backlog、合并策略、冷热分层与删除/覆盖比例,而不是去纠结 WAL 的细枝末节。

注意:这是“引擎口径”。如果你还有 3 副本复制,系统总写入成本还要再乘以复制因子。

6. 常见优化手段(从“不会错”的方向开始)

不同系统参数名不一样,但思路高度一致:

  • 写入批量化:group commit、批量写入、合并小写,提高日志与落盘效率。
  • 减少无效重写:更合理的 compaction 选择策略,避免频繁重写热点数据。
  • 合并策略取舍:leveled vs tiered(一般 leveled 读更稳,但 WA 更高;tiered 往往 WA 更低但读更复杂)。
  • 压缩策略权衡:压缩可能降低设备写入字节(改善真实写入成本),但会消耗 CPU;要选“速度/压缩比”合适的算法与级别。
  • 冷热分层/TTL:让短生命周期数据尽快过期/隔离,避免被多次合并重写。

6.1 两个高频误区

  • 误区 A:把 compaction 开得越猛越好
    Compaction 过猛会把后台 IO/CPU 吃满,写入尾延迟反而更差;正确做法是“让后台可控”,并在 backlog 变大时做节流保护。

  • 误区 B:只盯 WA,不看 RA/SA
    你把 WA 压得很低,可能换来更高的读放大(更多文件要查)或更高的空间放大(更多重叠数据/版本)。线上更重要的是稳定的尾延迟总成本

6.2 快速定位与动作清单(建议收藏)

当你发现 WA 很高时,按这个顺序做更高效:

  1. 先拆账:WAL/flush/compaction 哪个占大头?(先定方向)
  2. 再看写抖动:stall/throttle、L0 文件数、pending compaction bytes 是否上升?
  3. 再看数据形态:覆盖/删除是否很重?是否有 TTL/短生命周期数据搅局?
  4. 最后调策略:合并策略(leveled/tiered)、压缩、后台资源预算(并发/速率/优先级)

7. 小结

写放大几乎决定了:

  • 写入吞吐上限
  • 写延迟稳定性(抖不抖)
  • SSD 寿命与成本

后面你看到任何系统参数(compaction、flush、WAL、cache),都可以回到这个问题:它是在降低写放大,还是在用写放大换读性能?


下一篇会从“读放大”切入,并把 Bloom Filter 与缓存放到读路径里一起看。