Repository navigation
Crash Safe Recovery
-
进程崩溃:进程异常终止,包括
kill -9/abort这样的人为杀死;操作系统继续运行,其文件页缓存(PageCache)仍然保留。 - 硬件断电:服务器硬件突然失去供电,操作系统停止运行,内存中的运行状态(包括 PageCache)丢失。
容器编排系统 Kubernetes 默认给正常终止留出 30 秒宽限期,超时后以 SIGKILL 强杀;用户层软件 bug、运维事故或直接强杀,则可能绕过正常退出流程。
数据中心市电中断也不等于服务器突然失电:不间断电源(UPS)与备用发电机 可接续供电。例如 Eaton 手册中的 10–15 分钟 UPS 方案 远长于上述默认宽限期,配合告警与自动关机流程,可留出有序退出的时间。
file mmap:将文件映射到进程地址空间,使进程能够像访问内存一样访问文件内容的机制。
采用共享映射(MAP_SHARED)时,写入修改的是内核管理的文件页。进程崩溃只解除该进程的映射,不会因此丢弃已修改的文件页;新进程重新打开、映射同一文件,仍可访问这些数据。参见 Linux mmap(2)。
“保活”的是文件数据,而不是原进程的地址空间;它也不等于数据已经持久落盘,只是数据仍可通过操作系统访问(在PageCache中)。
CSPP(Crash-Safe Parallel Patricia)的出发点是高性能并发读写,前身是 Dynamic Patricia Trie。单论 key 读写,它比 SkipList 快一个数量级;例如,已有的并发写测试记录了不考虑 value 长度时超过 RocksDB SkipList 30 倍的性能。
提高并发读写性能,需要减少读写之间的相互等待。普通数据结构的多步原地修改可能暂时破坏一致性,通常需要用锁阻止读者访问中间状态,读者因而必须等待写者完成。怎样既保持一致性,又让读写并行?CSPP 先在副本上完成需要替换的结构,再用原子操作发布新入口,让读者在任意时刻读到的结构都是完整的一致的。这就是写时复制(Copy-on-Write,COW)与原子发布相配合的方式。OffsetSkipList(OSL)是基于 SkipList 的另一种独立并发数据结构,也采用写时复制与原子发布机制。
读侧查找能在有限的自身步骤内完成,不依赖写者的进展,这种性质称为读侧无等待(wait-free reads),见 Herlihy 对 wait-free 的定义。
由此再看进程崩溃:对读取已发布结构而言,写者在更新途中永久停止,与写者只是长时间暂停并无区别。这种并发读写方式与进程 crash-safe 在结构可读性上的同构,详见 读侧无等待与 Crash-Safe 的同构性。
要让新进程继续使用这份结构,还需要保留字节并解决重新寻址。新进程重新映射同一文件时,映射基址可能改变,原进程的绝对地址因而不能直接复用。CSPP / OSL 在结构内部使用相对偏移而非绝对地址;新进程从文件中确定结构入口后,便可基于新的映射地址访问结构。这样,file mmap 保留字节,相对偏移支持重新寻址,读侧无等待所需的更新与发布方式保住已发布结构的可读性,三者共同构成底层数据结构的 crash-safe 能力。
LSM(Log-Structured Merge-tree)先缓冲写入,再批量生成、合并有序文件。RocksDB 是采用 LSM 的 KV 存储引擎;ToplingDB 从 RocksDB 分叉而来,沿用其基本读写模型,重写了其热路径代码并替换了其关键组件。
在这套模型中,WAL 记录写入,MemTable 缓冲新增数据;Flush 将停止写入的 MemTable 转为 SST,通常先进入 L0,再由 compaction 合并整理。普通 Flush 要遍历 KV、重新构建 SST,也就是 BuildTable。
能否省去正常 Flush 时的这次重建?
MemTable 本来就维护着可查询的数据与索引。如果从创建时就把这份结构放在文件映射内存(file mmap)上,并让数据库能将它直接作为 SST 读取,那么 Flush 时就不必再遍历 KV、重新组织一份数据。所需要的是能够复用文件布局的 MemTable,而不是更快的 BuildTable。
ToplingDB 将 CSPP / OSL 作为底层机制,基于它们实现了满足这一条件的 MemTable。
以 CSPP MemTable 的 FileMmap 模式 为例,数据与索引直接在文件映射上维护;在 Flush 时,通过 ConvertToSST 将这份已有文件转化为 SST 并交给 LSM 管理,省去了重建。不过,完整 value 仍可能同时保存在 WAL 和 MemTable 文件中。
memtable_as_log_index 进一步让 MemTable 记录 value 在 WAL 中的偏移、直接引用已有的 value,省去 value 在 MemTable 中的内存重复占用,也减少了 ConvertToSST 时 fsync 需要回写的数据量;短 value 则可内联保存。它与 ConvertToSST 配合时,转换所得 SST 继续使用这些引用,完整方案见 Omit L0 Flush。
图 1:文件结构与 value 的两种复用。
flowchart TB
accTitle: 普通 Flush 的重建与两种复用
accDescr: 普通 Flush 遍历 KV 新建 SST,ConvertToSST 复用已有文件结构,memtable_as_log_index 则引用 WAL 中的 value。
subgraph ordinary["普通 Flush"]
direction LR
mem["MemTable<br/>数据与索引"] -->|遍历 KV · BuildTable| sst["新建 SST"]
end
subgraph converted["ConvertToSST"]
direction LR
mapped["FileMmap MemTable"] -->|复用文件结构| reused["已有文件作为 SST"]
end
subgraph indexed["memtable_as_log_index"]
direction LR
index["MemTable / 转换所得 SST<br/>保存 value 的偏移"] -.->|引用| wal["WAL 中的 value"]
end
ordinary ~~~ converted
converted ~~~ indexed
进程若在非预期的代码处崩溃,尚未 Flush 的普通 MemTable 会随进程消失,下次 Open 需要重放 WAL、重新插入 KV。MemTable 越大,重建工作越多。既然正常 Flush 已能复用文件,崩溃恢复能否同样保留已经完成的工作?Omit L0 Flush 的原始设想 已经提出这个方向。
关键区别是:正常 Flush 面对的是已经停止写入的完整 MemTable,崩溃留下的文件却可能停在某次写入的中途。它能不能用,不能只看文件是否存在。
数据结构在进程崩溃后仍然可读,是否就足够了?RocksDB 的普通写入路径以 WriteBatch 保证整批操作的原子性,并用序列号区分各次写入及同一 key 的不同版本。因此,结构可读还不够:恢复后也不能暴露半个 WriteBatch。
以普通的逐 KV 分配序列号的写入为例,假设先前的写入完成到了序列号 100,下一个 WriteBatch 包含序列号为 101、102 的两个 KV。该批 WAL 已经完整写入,但进程在 101 插入完成、102 尚未完成时崩溃。底层结构可以完全一致,101 也确实可读,但若直接把这份文件转换成 SST,就会向用户暴露半个 WriteBatch。
因此,“最后一个能读到的 KV”不是恢复终点,“文件里的最大序列号”也不是。并发插入时,序列号较大的 KV 甚至可能先完成,观察到它并不能证明前面的写入都完成了。同一 DB 还可以包含多个列族(Column Family,CF),每个 CF 有自己的 MemTable 和 SST,但共享 WAL;一个 WriteBatch 可以跨 CF。即使一个 CF 已经写完,也不能替另一个尚未完成的 CF 宣布整批写入成功。
底层保证的是单个 KV,DB 要保证的是整个 WriteBatch。不过,没必要因此放弃文件里的全部数据。在上面的例子中,100 及以前的工作已经完成;只要暂时隐藏 101,并从 WAL 完整重放包含 101、102 的那一批,就能保留旧成果,同时补齐被中断的写入。
于是问题从“这份文件能不能用”,收敛成了“它的哪一段可以直接用”。需要在写入过程中保存一个能够确认完整性的安全序列号,它之前的数据可以复用,它之后的数据交还给 WAL 恢复。这个边界就是 pubseq。
整批完整性并不是恢复才提出的要求:在普通写入路径中,正常读操作也不能看到半个 WriteBatch,所以 RocksDB 需要提供读操作使用的序列号边界。LastPublishedSequence 表示这一已发布的可见性边界;它不同于底层节点或链接的发布,后者让某个 KV 在结构中可达,并不代表整批写入已经可见。因此,这个已有的读可见性边界,也是恢复边界 pubseq 的自然候选。
但写入并非始终沿一条串行路径完成。为了摊薄写 WAL 的成本,引擎可以将多个 WriteBatch 合成一个写入组;WAL 和 MemTable 又有各自的推进过程,同一组也可能由多个线程并行插入。边界只有在其覆盖的写入都完成后才能推进:仅仅写完 WAL,或者某个线程已经插入较大的序列号,都不足以证明整组 MemTable 数据可用。
这给出了两个方向上不对称的后果。边界过早向前推进,会把尚未完成的数据误判为已完成,进而跳过仍然需要的 WAL,造成缺失;边界保守地落后一些,则只会让部分已经插入的 KV 再恢复一次。前者破坏正确性,后者付出少量恢复成本。因此目标是让 pubseq 尽可能接近 LastPublishedSequence,而不是不惜代价地与它相等。
沿用前面的例子:即使 101、102 都已插入,进程也可能在更新恢复边界之前崩溃,使保存的 pubseq 仍为 100。恢复时仍按 100 处理,隐藏文件里的 101、102,再从 WAL 重放整批。这并没有丢弃已成功的写入,只是没有利用那部分来不及确认的工作。
图 2a:两个不同崩溃时刻的对比——边界可以保守落后,不能错误超前。两个例子中的 [101, 102] 均已完整写入 WAL。
flowchart LR
accTitle: 保守落后与错误超前的后果
accDescr: 左列在两个 KV 均插入后仍保存 pubseq 100,重复插入这一批 KV;右列是假设的错误做法,在 102 尚未插入时错记 pubseq 102 并跳过该批 WAL,造成无法补回的缺失,不是当前实现。
subgraph safe["保守落后(可接受)"]
direction TB
done["101、102 均已插入<br/>恢复边界尚未更新"] --> oldseq["保存的恢复边界<br/>pubseq = 100"]
oldseq --> redo["完整重放 [101, 102]<br/>这批 KV 再插入一次"]
end
subgraph wrong["错误超前(反例)"]
direction TB
partial["101 已插入<br/>102 尚未插入"] --> badseq["错记 pubseq = 102<br/>并跳过该批 WAL"]
badseq --> lost["102 缺失<br/>无法由恢复补回"]
end
safe ~~~ wrong
可是,只保存序列号还不够。它告诉我们哪些 KV 可以使用,却不能直接告诉我们从 WAL 的哪里继续。若为了找这个偏移仍要扫描全部日志,只省掉 KV 插入,恢复时间就依然受 WAL 总量影响。因此,还要一起保存对应的 WAL 文件号和偏移,让“复用到哪里”与“从哪里重放”成为同一个边界的两种表示。
这两个边界不能各取各的最新值。WAL 偏移若推进得比可复用的数据更远,中间就会出现一段既没有被文件完整覆盖、也不再被日志恢复的空缺。它们必须配套:一侧保留已确认的数据,另一侧补回未确认的数据,合起来覆盖恢复所需的完整写入历史。
边界还必须按完整的逻辑日志记录(WAL Record)对齐。一条记录可以包含一个 WriteBatch,也可以包含合并写入的整组 WriteBatch,这一组也称为 WAL group。没有被直接复用的部分,按完整记录重放,不能只补其中看起来缺失的几个 KV。这样,底层的单 KV 保证才能在恢复时重新对齐到 DB 的整批写入保证。
图 2b:pubseq 与 WAL 重放偏移配套,已有数据和完整 WAL Record 共同覆盖恢复范围。
flowchart TB
accTitle: pubseq 与 WAL 偏移必须配套
accDescr: 同一次保存的恢复边界中,pubseq 100 确定已有数据的复用范围,WAL 文件号和偏移指向完整 Record 101、102 的起点;整条重放与已有数据共同覆盖恢复结果。
boundary["同一次保存的恢复边界"]
boundary --> seq["pubseq = 100"]
boundary --> pos["WAL 文件号 + 偏移"]
seq -->|确定复用范围| data["复用已有数据<br/>seq ≤ 100"]
pos -->|定位完整记录起点| record["整条重放<br/>Record [101, 102]"]
data --> result["恢复结果<br/>≤100 + 完整 [101, 102]"]
record --> result
现在有了三个相互关联的值:pubseq、WAL 文件号、WAL 偏移。将它们保存在 DB 目录下的恢复元数据文件 CSPUBSEQ 中,通过共享 file mmap 更新,便可在下次 Open 时读取。
但若三个字段逐个写入,进程可能在中间退出。例如,新 pubseq 配上旧 WAL 偏移,这份记录的各个字段虽然都能读出来,组合起来却不是一个有效的恢复边界。用它决定跳过多少 WAL,就可能出错。
包含这三个值及填充字节的记录是 32 字节。AVX 可以用一条对齐的 32 字节存储指令将它写入,于是可以利用进程崩溃发生在指令边界这一前提,避免两条存储指令之间留下新旧字段混合的记录。
仅仅使用 AVX intrinsic 还不够。Clang 可以把未加 volatile 的存储拆成两次 16 字节 store。这里用 volatile 标记这次访问,是为了利用 Clang/LLVM 的明确约束:后端不得拆分或合并目标原生支持的 volatile load/store,见 LLVM Volatile Memory Accesses。因此,GCC 和 Clang 的 AVX 路径可以共用这次 volatile 存储。
没有 AVX 时,无法沿用这次单指令存储,但仍可以识别并拒绝未完成的更新:为记录保存一个更新代次计数 generation,先把它标成奇数,再更新字段,最后发布偶数。下次恢复看到奇数,就知道这次更新可能没有完成,不能使用该记录。
这里不要求每次崩溃后都拿到最新边界。拿到一份完整的旧边界,并且所需文件仍然可用,可以多重放一些 WAL Record;确认记录没有完成,则放弃快速复用。真正不能接受的是把新旧字段拼成一份看似有效、实际上从未成立过的边界。
至此,解决的是进程崩溃时发布记录的一致性,不是掉电后的持久性。WAL 与文件同步配置仍有它们各自的职责。
上次进程留下、尚未作为正常 SST 纳入 LSM 的 MemTable 文件,称为 leftover。有了可靠的边界,前面的推导还剩一个问题:这些文件中可能真的存在 pubseq 之外的数据。仅仅决定“从 WAL 重放它们”,并不会让旧文件里的那半批数据自动消失。若直接暴露全部内容,读取仍可能遇到不应采纳的版本。
可以先把这些 KV 删除再转换吗?那就需要遍历甚至重写文件,恢复又绕回了与 MemTable 大小成正比的工作。这里选择保留文件内容,只限制它能贡献哪些可见数据:恢复转换出的 SST 通过 VisFilter:1 标记过滤,从文件元数据取得序列号上限,点查和迭代都隐藏seq越过pubseq边界的KV。
这样,上面例子中残留的 101 不再是“必须修复的文件内容”,而是“这份恢复 SST 不得提供的版本”。101、102 的完整写入由 WAL 重放提供。物理上保留旧字节,逻辑上只采纳已确认的前缀,既守住了 WriteBatch 边界,也保留了直接复用文件的速度。
对同一个 key,这要求隐藏的是超出边界的版本,而不是整个 key。例如它在序列号 90 和 101 各有一个版本,边界为 100 时,文件中的 90 仍须可读。基于 CSPP / OSL 的 MemTable 实现保留按序列号区分的版本,因此可以在查找时限制上限,找到已确认的旧版本,而不被未确认的新版本遮住。
图 3:沿用 101 已插入、102 尚未插入的例子,两路数据共同组成恢复结果。
flowchart TB
accTitle: 过滤遗留版本并从 WAL 重放完整批次
accDescr: 遗留文件经过转换和可见性过滤,只贡献序列号不大于 100 的数据;101 的字节保留但不可见,101 与 102 由完整 WAL Record 的重放提供。
leftover["遗留文件<br/>≤100 已完成<br/>101 已插入,102 未插入"]
leftover --> filter["ConvertToSST<br/>可见性过滤<br/>pubseq = 100"]
filter --> old["贡献序列号 ≤100<br/>101 字节保留,读取不可见"]
wal["完整 WAL Record<br/>[101, 102]"] --> replay["整条重放<br/>提供 101、102"]
old --> result["恢复后的逻辑数据<br/>≤100 + 完整批次 [101, 102]"]
replay --> result
这项额外判断只属于恢复得到的 SST。正常 Flush 的 ConvertToSST 不需要它,普通迭代器也不应为崩溃恢复付出这项成本。
先看普通写入:已确认的数据可以直接复用,未确认的残留也已被过滤,剩下的工作就只有恢复边界之后的 WAL。若恢复元数据和 leftover 都可用,默认写入路径中已经完成所有在途写入的 DB,在崩溃后不需要重放 WAL 记录;跳过已覆盖的 WAL,直接定位到保存的偏移即可。
写入的组织方式也会影响这个尾部。two_write_queues 将只写 WAL 的请求与还要写 MemTable 的请求分到两条队列;seq_per_batch 则表示按批或子批、而非逐条 KV 操作分配序列号。启用前者而不使用后者时,在 MemTable 一侧完成后发布边界,也能得到同样的结果。
unordered_write 允许写入不按序列号顺序完成,流水线写入路径 pipelined_write 则让 WAL 与 MemTable 阶段重叠推进。这两条路径保守地让保存的边界落后一个 WAL group,把最后一组留给重放。这正是前面“少复用一点,换取安全边界”的具体取舍。
再看 TransactionDB 的两阶段提交(2PC)事务。KV 已经在文件里,是否就能跳过包含它的 WAL?还不能:已经 prepare、但尚未提交或回滚的事务仍处于未决状态,恢复时必须把它们找回来。KV 数据本身不能代替这些事务状态。
所以,allow_2pc 下即使复用了 leftover,仍要读取相关 WAL 来重建事务恢复状态 recovered_transactions_;边界之前已经覆盖的 KV 不再重复插入。TransactionDB 的 WriteCommitted 在提交时将事务数据写入 MemTable,它支持这条恢复路径,包括第二个队列上的 prepare,但不能因此承诺事务恢复也只需定位到 WAL 尾部。
WritePrepared / WriteUnprepared 是另外两种允许事务数据在提交前进入 MemTable 的策略,采用按批分配序列号的模式。它们启用 two_write_queues 时,就属于 seq_per_batch && two_write_queues 的组合,当前实现对此回退到完整 WAL 重放。决定回退的是序列号发布方式,不能仅凭事务策略的名字判断。
还需要考虑另一个问题:如果文件不齐,或保存的边界不可用怎么办?快速恢复必须能证明“跳过的 WAL 已被现有数据覆盖”。必要 leftover 缺失或校验失败、相关 CF 不支持所需能力、发布记录不可用、转换失败时,都不能继续采用这个假设,只能退回完整 WAL 重放。恢复日志过滤器 wal_filter 要让用户逻辑实际处理日志,尽力恢复仍可用数据的 best_efforts_recovery 模式也有自己的恢复语义,这些情况下同样不走快速复用。
因此,快速恢复依靠的是一组相互配合的保证:底层结构留下可读的 KV,发布边界确认哪些写入完整,读取过滤排除未确认的残留,WAL 补齐其余部分。缺少其中任何一项,“文件还在”都不足以推出“可以跳过重建”;条件都成立时,恢复工作才从重建整份 MemTable,缩小为复用文件并补齐尾部。
既然 ConvertToSST 这么便宜,正常关闭数据库(Close)时为什么不顺便做?上游 RocksDB 在 Close 时不 Flush 已有 WAL 保护的普通 MemTable,原因不是没有收益,而是 BuildTable 昂贵,关库可能为此等待很久。
ToplingDB 的 MemTable 支持廉价的毫秒级 ConvertToSST,成本条件变了,原先不适合在 Close 中做的事,现在可以做:所有 MemTable 都可转换时,在 Close 时 Flush(convert)。对应 Kubernetes,应用正常响应停止信号并调用 Close,关库不再需要等待昂贵的 BuildTable,原则上也就不存在因 MemTable 太大而无法在宽限期内完成优雅关闭的问题。转换成功还省去了下次 Open 对它们的 WAL 重建。快速关闭和快速重启因此可以同时得到,而不是把关库省下来的工作推迟到下次开库。已有的“存在未持久化数据时 Close Flush”的条件仍然保留;这里的未持久化数据,是指未写 WAL、也尚未 Flush 到 SST 的数据。
kill -9 会直接终止进程,用户层软件 bug、运维事故等也会造成进程崩溃,内核因内存不足而终止进程的 OOM kill 也是异常退出的来源。crash-safe recovery 不依赖进程执行清理,而是借助前面推导出的 pubseq、可见性过滤和 WAL 恢复边界,在下次 Open 复用留下的数据。因此,Flush/Close ConvertToSST 与 crash-safe recovery 共享底层能力,却分别解决正常退出与异常退出两条路径的问题;前者不需要开启后者,后者也不要求前者已经执行。
avoid_flush_during_shutdown: true 会无条件跳过 Close Flush,包括 ConvertToSST。只有存在未写 WAL 的未持久化数据时,这种跳过才会导致数据丢失;风险并不是 ConvertToSST 新增的。
主开关 DBOptions::memtable_crash_safe_recover 默认 false,不能通过 SetDBOptions() 在线修改。启用后,所有未被删除的 CF 都须使用 CSPP / OSL 的 convert_to_sst: kFileMmap,并配置与转换结果匹配的 SST reader。
在 db_bench_community.yaml 的 DBOptions.default 中增加 memtable_crash_safe_recover: true 即可启用;memtable_as_log_index 不是必要条件。
启用后,开库自动将 manual_wal_flush 调为 false、recycle_log_file_num 调为 0、wal_compression 调为 kNoCompression;写入时忽略 disableWAL,仍使用 WAL。只读 Open 自动关闭快速恢复,并警告完整 WAL 重放可能很慢。
convert_to_sst 在创建 factory 时确定,不支持在线修改;其它支持在线修改的选项仍可调优,参见在线修改配置。
重启时修改 memtable_as_log_index 会改变 WAL 格式:旧格式可识别时,先按旧格式恢复并 Flush,再按新配置打开;有未决事务时,须先用旧配置完成提交或回滚。
日志中的 Convert leftover, WAL tail from 表示采用快速复用并定位 WAL 尾部;fallback to full WAL RecoverLogFiles 表示回退完整 WAL 重放。开关开启、Open 成功,并不等于本次实际使用了快速恢复。
如果前面的推导成立,收益应当来自省掉整份 MemTable 的重建,而不只是 WAL 重放快了一点。可以让两种配置写入相近规模的 MemTable,不调用 Close 就终止进程,再比较两条恢复路径的下一次 Open。
2026-10-02 的测试将一个 MemTable 写到 2GiB write_buffer_size 的 75%(1.5GiB),key 为 16 字节、value 为 128 字节,随后直接 _exit,不调用 Close。每次测量先复制同一份目录,只计时 DB::Open;读取首尾 key 的检查在计时之后。
本次比较同一 ToplingDB 构建下的两种配置:CSPP crash-safe 与默认 SkipList / WAL 重放;不是与独立构建的上游 RocksDB 对比。
数据位于内存支持的文件系统 /dev/shm,共五次测量:
| 恢复路径 | 五次 Open 耗时(ms) | 平均(ms) | 相对耗时 |
|---|---|---|---|
| CSPP crash-safe | 6.545, 4.692, 3.820, 4.768, 5.120 | 4.989 | 1.0 |
| 默认 SkipList / WAL 重放 | 6124.357, 5849.884, 5984.324, 5943.391, 6033.802 | 5987.152 | 1200.1 |
两者按相同 MemTable 内存占用目标停止,而不是相同 key 数:CSPP 为 9,664,512 个 key,默认 SkipList 为 9,090,048 个 key。恢复日志确认前者复用 leftover、没有回退,后者完整重放 WAL。这组结果展示了省掉 WAL 重建的实际收益。
测试方法及原始结果见主仓 crash_recover_bench.md,配套 YAML 用于该实验。
回到最初的重建成本:FileMmap 与 ConvertToSST 让正常 Flush 不必重建已有结构,memtable_as_log_index 省去 value 在 MemTable 中的重复内存占用;crash-safe recovery 则让已有的数据与索引,在进程崩溃后仍有机会被继续使用。关键不在于把 WAL 重放做得更快,而在于用安全的发布边界补上底层单 KV 的 crash-safe 与 DB 原子性之间的缺口,证明哪些工作不必重做。
补上这个缺口后,大 MemTable 不再必然带来漫长的崩溃恢复,因此 crash-safe 模式鼓励使用比典型 RocksDB 配置大得多的 MemTable。
填充 MemTable 时对内存是随机写,CSPP / OSL 的 FileMmap 模式下就是对其 PageCache 的随机写。修改后尚未写回磁盘的页称为脏页,操作系统会根据脏页已存在的时间(脏龄)安排周期回写。随机写会迅速把所有页都变脏;于是页被写回后,持续的随机写又会迅速把这些页变脏。同一页的不同中间状态因而多次写入磁盘,这就是重复回写。
随机写的范围在 MemTable 内。MemTable 较小时,涉及的页少,重复回写的数据量也小,甚至还没来得及重复回写就写满转化为 SST 了,所以影响有限。随着 MemTable 增大,随机写涉及的页随之增多,MemTable 存活时间也更长,重复回写也随之增加。因此,超大 MemTable(当前上限 16G)下的这项开销更值得关注。开启 memtable_as_log_index 后,MemTable 主要保存索引和 value 在 WAL 中的偏移,省去了 value 的重复保存,回写量随之减少,问题基本可以忽略。考察这项开销,只是践行 ToplingDB“消除一切非本质开销”的设计哲学,并非 ToplingDB 因此就丧失了竞争优势。
中间状态被回写之后仍会继续变化,提前回写并不能免去最终的回写,也不增加任何其它能力。DB 中现有文件写操作都有受控的 fsync / fdatasync,需要同步的时点由应用掌握。因此,可以在 MemTable 接受写入期间尽力延迟周期回写,到 ConvertToSST 时再按配置同步。
调大 vm.dirty_expire_centisecs 就能延后脏页因年龄而进入周期回写的时间,例如设为 60000(600 秒)。这能解决当前负载中的问题,但参数作用于整台机器,也会改变其他程序的回写策略。应用只想延迟自己的 MemTable 后备文件,却不得不调整全局策略。
我们为此实现了一个内核模块 deferwriteback:持续刷新指定文件 inode 的脏龄,推迟它因年龄而进入周期回写的时间。这样无需改变整机策略,但这种方式有部署和维护成本。实际上,应用真正需要表达的,只是“这个文件正在接受持续的随机修改,请在这一阶段尽量延迟回写”,不必由应用反复刷新内核中的计时。
因此,进一步的改进方向是给操作系统添加系统调用,提供带生命周期的文件级延期提示。应用在开始填充时提出请求,完成填充时撤销,再按原有配置同步;解除映射或进程退出时,请求也应自动撤销。这是拟议的公共接口:应用提供使用阶段的信息,内核仍掌握回写的调度权。提示只针对按脏龄触发的周期回写,内存压力、脏页限额和显式同步仍可触发回写。目前已经向linux内核提交了feature request,如果得以实现,整个linux世界都会因此受益。