存储基础(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 更容易成为后台热点。
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 很高时,按这个顺序做更高效:
- 先拆账:WAL/flush/compaction 哪个占大头?(先定方向)
- 再看写抖动:stall/throttle、L0 文件数、pending compaction bytes 是否上升?
- 再看数据形态:覆盖/删除是否很重?是否有 TTL/短生命周期数据搅局?
- 最后调策略:合并策略(leveled/tiered)、压缩、后台资源预算(并发/速率/优先级)
7. 小结
写放大几乎决定了:
- 写入吞吐上限
- 写延迟稳定性(抖不抖)
- SSD 寿命与成本
后面你看到任何系统参数(compaction、flush、WAL、cache),都可以回到这个问题:它是在降低写放大,还是在用写放大换读性能?
下一篇会从“读放大”切入,并把 Bloom Filter 与缓存放到读路径里一起看。