本节摘要:WAL、MemTable、SSTable 不是三件并列的工具,而是一条流水线上互相咬合的三个工位:WAL 用顺序日志买崩溃安全,MemTable 用内存结构买写入速度,SSTable 用分块与索引买读取效率。本节逐个拆开它们的内部构造,讲清衔接的时序纪律,最后用一个六步案例把「一次写入的一生」走完整。
先给自己出一道题:应用调用 Put 写入一笔转账记录,引擎返回成功,二十毫秒后服务器断电。重启之后,这笔记录在不在?
答案取决于一个严格的时序:引擎承诺「成功」之前,这笔操作的证据是否已经落到了磁盘上。 如果证据在内存里还没落盘,那这笔写入就是空头支票。WAL(预写日志)存在的全部意义,就是把这道题的答案钉死为「在」:任何内存修改发生之前,操作的完整记录必须先以顺序追加的方式写入日志并按承诺刷盘。它不关心数据最终怎么组织,只保证「承诺过的事一定留痕」。这就是三个工位的第一条纪律:先落日志,再动内存。
第二条纪律关于衔接:WAL 不是无限长的,它以 MemTable 的生命周期为刻度回收——一张内存表对应一段日志,表被安全刷成 SST 后,那段日志才可回收。日志与内存表是共生体,谁也不能单方面提前退场。
内存表的默认实现是跳表,这个选择把三件事一次做对:并发(无锁插入,多写线程不打架)、有序(天然支持范围遍历,为刷盘与扫描省去排序)、内存友好(节点分配模式规整,碎片少)。每个节点除了键和值,还带着序列号——内存表因此天然是多版本的:同一个键的几次写入并存,新旧靠序列号排队。
内存管理交给专门的内存池:只分配不逐个释放,表销毁时整段回收。这个看似粗暴的策略把内存管理的成本压到常数级——对每秒百万次写入的路径来说,省下的正是最贵的那部分开销。
容量到线,舞台换场。冻结的细节上一节已讲,这里补一笔 flush 时的「结构化增值」:冻结表在刷成 SST 的路上并不是原样照搬,后台会顺手构建数据块索引、生成布隆过滤器、按策略压缩数据块。内存表原本只是「有序的写入快照」,经过这一趟加工,变成「磁盘上可直接高效查询的结构」。加工是增量成本,收益归读取路径——又是一笔典型的跨路径转账。
SST 文件是一套自带索引与校验的磁盘结构,切分层级如下:
| 组成部分 | 职责 | 类比 |
|---|---|---|
| 数据块 | 存放压缩后的键值对,块内有序 | 正文 |
| 索引块 | 记录每个数据块的边界键与位置 | 目录 |
| 过滤器块 | 布隆过滤器,快速否定「本文件没有这个键」 | 速查卡 |
| 元块 | 文件级统计与属性 | 版权页 |
| 脚注 | 指向索引与过滤器的指针,附校验和 | 封底勘误表 |
一次点查在这个结构里的路径是固定的:先查过滤器,判定「绝对没有」就直接返回;否则查索引块,二分定位到唯一的数据块;把数据块读进内存解析,找到键;全程校验和护航。注意这个设计的精妙处:磁盘访问次数有硬上界——最多一次过滤器读、一次索引读、一次数据块读,再多就没有了。读取延迟的可预测性就是从这里来的。
SST 还有两个常被忽视的元数据:键范围(最小键与最大键)和序列号范围(本文件内最老与最新的序列号)。键范围让「哪些文件可能与目标键相关」成为一次内存比较就能回答的问题;序列号范围让压缩在挑拣旧版本时不用翻内容。文件因此是「自描述」的——元数据足够丰富,读取与压缩都能先看档案再动手。

把前面的构造拼成一个具体案例。设写缓冲为常规配置,观察键 user:123 的一次更新:
第一步 · 领号:引擎给它分配序列号,比如 1001。从此它在所有版本排序中有了唯一座次。
第二步 · 留痕:操作被序列化成一条日志记录——序列号、键、值、操作类型四样俱全——追加进 WAL,按当前写选项的要求刷盘。这步走完,崩溃就再也抹不掉它。
第三步 · 入住:记录插进活跃内存表的跳表。此刻起,任何不带快照限制的读取都能看到新值——虽然磁盘上还什么都没有。
第四步 · 换场:写入持续,内存表达到容量线,被冻结为只读;新表无缝接班,前台毫无感觉。冻结表进入后台刷盘队列。
第五步 · 铸型:后台线程拾起冻结表,构建索引块与过滤器块、压缩数据块,写成一个 L0 层 SST 文件,并在版本账本上登记这个新文件。对应的那段 WAL 从此可回收。
第六步 · 归位:随后的某次压缩把这个文件与邻居合并、下沉到更深的层。
user:123的新值随大流沉入 L1、L2,直到某天被一次 Get 从深处捞起,或被更新的版本覆盖后清退出局。
六步里有一步值得你多想三十秒:第五步完成之前,数据同时在 WAL 和内存表里存在两份;第五步完成之后,SST 顶替了日志的位置。持久性的载体就是这样换手的——这也解释了为什么日志的回收必须以刷盘成功为前提,提前回收就是自毁保险单。
机制的分量要在事故里称。推演一次最普通的故障:进程在写入进行到一半时被强行终止,内存清零,磁盘完好。
打开库的瞬间,三份资产的状态各不相同:日志里躺着全部已确认的写入,包括崩溃前最后几毫秒;内存表随进程消失,一个字节都不剩;磁盘文件完好,但只包含到某次刷盘为止的数据。恢复的对账规则因此简单而精确——重放日志里比文件更新的部分,把崩溃前留在内存里的那一截补回来。日志为什么必须先落、内存表为什么敢放在易失的内存里、文件为什么敢慢慢刷,三个设计在这一刻互为因果:日志的严格换来了内存的放纵,内存的放纵换来了写入的速度,文件的从容换来了前台的安静。
推演再往前走一步:如果日志在崩溃时写了半条呢?半条记录的校验码对不上,恢复时自动截断——只有完整落盘的记录才有资格被重放。这就是「确认返回」四个字的重量:凡是引擎答过一声成功的写入,一定在日志里有完整的一条;凡是没答的,丢了也不算违约。持久承诺的边界就画在这里,多一分是浪费,少一分是谎言。
数据落进了 SST,但「字节层面长什么样、版本由谁记账」还是黑盒。下一章进数据模型与存储格式的内部。