深圳科技园有家深夜大排档叫“铁记小馆”,高峰期一晚上要接两千单。老板娘有套独特的接单法:客人下单,传菜员先在门口的流水簿上飞快记一笔,再把写着菜名的便利贴按拼音贴到备料台的墙上——而不是像街对面的老字号那样,立刻翻开厚厚的大账本,找到对应那页当场涂改。账本式经营听着严谨,可每单都要翻页、找行、改写,高峰期根本忙不过来。铁记的哲学是:先痛快记下,打烊后再把便利贴收起来,按菜名装订成小册子,隔几天再合并成整齐的大菜谱。这套“先写后整理”的思路,正是 RocksDB、LevelDB 底层存储引擎 LSM 树(Log-Structured Merge Tree,日志结构合并树)的核心。它把每次写入变成“记流水 + 贴便签”的低成本动作,换来了惊人的写入吞吐;同时,这份痛快也有代价——查询要跑好几处地方,后台还得不停归整。本文陪你走进铁记后厨,把 LSM 树的每个角落看个明白。

岗位关联

理解 LSM 树,你就拿到了读懂 RocksDB、LevelDB、HBase、TiKV 等存储引擎的钥匙——无论调优参数还是系统选型,都能直击要害,不再人云亦云。

目录
  1. 可视化演示
  2. 写入全流程:先记流水,再贴便签
  3. MemTable内存表:一面自动排序的便签墙
  4. SSTable文件:一本本按序装订的菜谱
  5. 层级结构:柜台、书架与大库房
  6. Compaction合并:伙计们的打烊归整
  7. 读路径优化:从便签墙到大库房的寻菜之旅
  8. 与B+树对决:账本式经营 vs 便签式经营
  9. 工程实践:从小馆开到连锁店
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示LSM树三大核心流程:WAL+MemTable+SSTable多级写入路径、Compaction合并机制控制读放大、以及分层查找与布隆过滤器加速读取。

本节配有交互动效(进入视口后自动加载,约 3KB)。

写入路径:WAL→MemTable→SSTable多级落盘 写请求先记WAL保证崩溃恢复 数据写入内存MemTable,满后flush到L0 Compaction将L0合并到L1/L2/L3 LSM树通过追加写入实现高吞吐。

写入全流程:先记流水,再贴便签

顾客喊一声“一份辣子鸡!”,传菜员的第一动作绝不是翻菜谱,而是先在门口的流水簿上唰唰记一笔——这是给今晚的账上个保险。紧接着,老板娘把“辣子鸡”写在便利贴上,按拼音贴到备料台最顺手的位置。对客人来说,单子已经收下了;对后厨来说,真正的开工可以稍后慢慢来。

在 LSM 引擎里,“流水簿”对应 WAL(Write-Ahead Log,写前日志),“备料台便签墙”对应 MemTable(内存表)。一次写请求只做两件事:把记录顺序追加到 WAL 末尾,再插入内存中的 MemTable,然后立刻向客户端返回成功。真正的落盘发生在 MemTable 写满之后,由后台线程异步完成,写请求全程不需要等待磁盘寻道。

WAL 是只追加的日志文件,靠顺序写把单条写入的耗时压到微秒级;MemTable 内部是一棵跳表,插入平均只要 \(O(\log n)\)。写路径与“数据最终落在磁盘哪个位置”彻底解耦——这正是 LSM 高吞吐的秘密。万一进程崩溃,MemTable 里尚未落盘的数据,可以照着流水簿(WAL)一笔笔重放回来,这就是故障恢复的基本盘。

老板娘总结经营心得:“先记流水、再贴便签,单子接得又快又稳;至于整理菜谱,那是打烊后的事。”这句话,基本就是 LSM 树写入哲学的全部。

T写=TWAL+TMem≈O(1)+O(log⁡n)\displaystyle T_{\text{写}} = T_{\text{WAL}} + T_{\text{Mem}} \approx O(1) + O(\log n)
一次写入的耗时 = 顺序日志的常数时间 + 内存跳表的对数时间,与磁盘随机寻道彻底无关。
  1. T_WAL(流水簿时间)WAL 把记录顺序追加到日志文件末尾,磁盘顺序写接近常数时间,约等于 \(O(1)\)。
    TWAL≈O(1)\displaystyle T_{\text{WAL}} \approx O(1)
  2. T_Mem(贴便签时间)MemTable 用跳表组织,插入时需要沿高层指针跳着找位置,平均是 \(O(\log n)\)。
    TMem≈O(log⁡n)\displaystyle T_{\text{Mem}} \approx O(\log n)
  3. 两者相加把两个成本相加,真正昂贵的随机寻道被完全避开,写入耗时与“数据最后存在哪个文件”无关。
    T写=TWAL+TMem\displaystyle T_{\text{写}} = T_{\text{WAL}} + T_{\text{Mem}}

MemTable内存表:一面自动排序的便签墙

铁记的便签墙有个讲究:所有便签按菜名拼音从 A 到 Z 排好,新单子插进来时不用一张张挪,而是沿着墙上的几条“索引线”跳着找位置。老板娘说,高峰期一秒来三单,便签墙要是找得慢,前台就得排长队。这面墙还是全店最新鲜的信息源——刚下的单一定在墙上,没人会去翻旧簿子。

MemTable 就是这面便签墙,它是内存中的有序结构,LevelDB 和 RocksDB 默认用跳表(skip list)实现。跳表是一种概率型平衡结构:每个节点“抛硬币”决定是否向上多建一层指针。高层指针像电梯一样,让查找能大步跳过成群节点,而不是逐层爬楼梯。

跳表的插入、查找、删除平均都是 \(O(\log n)\),实现比红黑树简单,对多线程也更友好。MemTable 写满后不会被丢弃,而是冻结成一个只读的 immutable MemTable,同时新开一个空的接棒,形成“双缓冲”,保证客户端写入不被打断。冻结的那块由后台线程刷成 SSTable,完成数据从内存到磁盘的第一次落地。

便签墙满了就整面撤下、换新墙,客人的写单速率因此稳如泰山——这正是 MemTable 双缓冲的智慧。

P(节点位于第 i 层)=12i−1,平均查找步数=O(log⁡n)\displaystyle P(\text{节点位于第 } i \text{ 层}) = \frac{1}{2^{i-1}}, \qquad \text{平均查找步数} = O(\log n)
跳表用“抛硬币”决定每个节点占几层,大部分节点只有一层,少数节点有高层指针,查找像坐电梯一样抄近道。
  1. 抛硬币决定层数新节点像抛硬币一样,每抛中一次(概率 \(1/2\))就向上升一层,所以第 \(i\) 层节点数约为总数的 \(1/2^{i-1}\)。
    Pi=12i−1\displaystyle P_i = \frac{1}{2^{i-1}}
  2. 高层作电梯查找从最高层出发,能跨就跨,不能跨就降一层,平均只需 \(O(\log n)\) 步就能定位目标。
    O(log⁡n)=log⁡n×O(1)\displaystyle O(\log n) = \log n \times O(1)

SSTable文件:一本本按序装订的菜谱

打烊前,伙计把整面便签墙揭下来,按菜名排序装订成一本小册子,这就是 SSTable(Sorted String Table,排序字符串表)。为了让伙计快速定位一道菜,不用从头翻到尾,册子每隔 10 页印一行目录(稀疏索引);为了不翻开就知道“本店没有这道菜”,每本册子封底贴一张“气味速查表”(布隆过滤器),闻一下就知道厨房有没有这种食材。

SSTable 是磁盘上不可变的有序文件,内部按块(block)组织,每块装若干条记录。每一块的起始键和偏移量被记入稀疏索引——索引项数远少于记录数,整份索引可以常驻内存。文件尾部的元信息记录了索引和过滤器的位置,打开文件时一次性加载。

查一条记录时,先在内存索引里二分定位到块号,再只读那一个块上磁盘扫描,几 KB 的 IO 就能命中。布隆过滤器用几个哈希函数把键映射到位数组,查询时只要有一位对不上,就能断定“该文件里没有这个键”,从而跳过整个文件。它牺牲少量内存和一点 CPU,换来磁盘 IO 的大幅削减。

“菜谱出了册子就不改了,要改也是下次归整时重新印。” SSTable 不可变的特性,让并发读写和后台合并都变得简单而安全。

N索引项=N数据项K\displaystyle N_{\text{索引项}} = \frac{N_{\text{数据项}}}{K}
每 \(K\) 条记录才记一条索引,索引体积缩小到数据量的 \(1/K\),所以能整体装进内存。
  1. 块内不设索引每 \(K\) 条记录组成一个数据块,块内部靠顺序扫描,所以不需要为每条记录都建索引项。
    K=N数据项N索引项\displaystyle K = \frac{N_{\text{数据项}}}{N_{\text{索引项}}}
  2. 块首记一项只在每块开头记录它的起始键与偏移量,索引项总数等于数据项总数除以 \(K\)。
    N索引项=N数据项K\displaystyle N_{\text{索引项}} = \frac{N_{\text{数据项}}}{K}
  3. K 的取舍\(K\) 越大索引越小、内存越省,但单块扫描的 IO 成本越高,工程上通常让每块落在 4~16 KB。
    块大小=K×单条长度‾\displaystyle \text{块大小} = K \times \overline{\text{单条长度}}

层级结构:柜台、书架与大库房

铁记的菜谱分三处存放:新装订的小册子随手堆在柜台(L0),稍微规整过的放前台书架(L1),最全的大册子存后厨库房(L2)。老板娘立了条规矩:越往里走,册子越厚、越整齐,能装的菜谱越多——一层比一层大十倍。

LSM 的磁盘文件正是按这种层级组织的。L0 层是 MemTable 刷盘后的直接产物,文件之间无序,允许键范围互相重叠;从 L1 开始,每一层内部严格有序、文件互不重叠。第 \(i\) 层的总容量约为 L0 的 \(R^i\) 倍,\(R\) 通常取 10。

指数扩容带来一个优雅的结果:哪怕总数据量达到 L0 的一百万倍,层数也只是对数级别(约 6 层)。每层文件不重叠意味着,L1 及以下查某一键,最多只需读一个文件(用二分定位到覆盖它的那个文件);只有 L0 因为无序,需要逐个检查。层级是 LSM 的“地理坐标”,读路径的每一步都靠它导航。

老板娘闭着眼都知道去哪层找什么菜——因为有柜台、书架、库房的分层规则,找菜和放菜才都有章可循。

L=⌈log⁡RDS0⌉\displaystyle L = \left\lceil \log_R \frac{D}{S_0} \right\rceil
从基础层到最大层按倍数扩容,层数是总数据量与基础层容量之比的对数,所以堆再多的数据,层数也长得极慢。
  1. 等比扩容设基础层 L0 容量为 \(S_0\),则第 \(i\) 层容量是 \(S_0 \times R^i\),每下探一层,容量翻 \(R\) 倍。
    Si=S0×Ri\displaystyle S_i = S_0 \times R^{i}
  2. 容量守恒总数据量 \(D\) 最终要放进最大层,近似有 \(R^{L-1} \times S_0 \approx D\)。
    RL−1≈DS0\displaystyle R^{L-1} \approx \frac{D}{S_0}
  3. 解出层数两边取对数可得 \(L \approx \log_R(D/S_0)\),再向上取整;代入 \(R=10\)、\(D/S_0=10^6\),层数只有 6。
    L=⌈log⁡10106⌉=6\displaystyle L = \left\lceil \log_{10} 10^6 \right\rceil = 6

Compaction合并:伙计们的打烊归整

每晚打烊后,伙计们把柜台上堆的散册子搬到长桌上:两本小册子摊开,按菜名一路比对,重复的页抽掉,写有“已停售”标记的页撕掉,最后重新装订成一本更厚的大册子,送进书架或库房。老板娘管这叫“归整”,引擎里叫 Compaction(合并)。

Compaction 的职责有二:一是把多个小有序文件归并成一个大有序文件,减少文件数量;二是趁机清理被覆盖、被删除的旧数据,回收磁盘空间。触发条件通常是 L0 文件数超过阈值、某层总大小超过上限,或管理员手动发起。

归并两个有序序列只需要双指针顺序扫描,代价 \(O(M+N)\)。LevelDB 采用分层式合并(leveled compaction),新数据逐层下沉,每一层都比上一层大十倍;RocksDB 还支持 size-tiered 等更多策略。合并是后台任务,但并非多多益善——太频繁会让物理写入远大于逻辑数据量(写放大),严重时还会触发写停顿(Write Stall),这正是 LSM 调优的核心矛盾。

归整虽然累(要搬书、重印),但换来的是柜台清爽、找菜更快。老板娘说:“归整的节奏,决定了店里的呼吸。”

C归并=O(M+N),WA=W物理写入W有效数据\displaystyle C_{\text{归并}} = O(M + N), \qquad WA = \frac{W_{\text{物理写入}}}{W_{\text{有效数据}}}
归并两个有序文件只需各扫一遍;写放大衡量“为了写 1MB 有效数据,磁盘实际写了多少 MB”。
  1. 双指针归并两个有序数组从小到大依次取头,两边的 \(M\)、\(N\) 个元素各扫描一遍,总代价是 \(O(M+N)\)。
    C=O(M+N)\displaystyle C = O(M + N)
  2. 写放大定义写放大 \(WA\) 等于物理写入字节除以有效数据字节,合并、重写、刷盘都会推高它。
    WA=WphysicalWlogical\displaystyle WA = \frac{W_{\text{physical}}}{W_{\text{logical}}}
  3. 触发条件L0 文件数超过阈值 \(N_{\text{trigger}}\) 就触发合并;阈值调高可减少合并次数、降低写放大,但 L0 文件变多会让读放大上升。
    NL0 文件≥Ntrigger⇒Compaction\displaystyle N_{\text{L0 文件}} \geq N_{\text{trigger}} \Rightarrow \text{Compaction}

读路径优化:从便签墙到大库房的寻菜之旅

客人点“宫保鸡丁”,老板娘不会一头冲进库房。她先扫一遍备料台的便签墙——刚下的单在这儿;没有,再翻柜台上的散册子;还没有,才进书架和库房。每处检查前,先用“气味速查表”闻一下:闻不到立刻走人。一趟找下来要翻好几处地方,这趟“寻菜之旅”的路程,就是读放大。

LSM 的读路径是写入路径的倒放:先查 MemTable 和冻结的 immutable,再查 L0 的所有文件,最后逐层下探 L1、L2……同样的键可能在多个层级各有一份副本,一次读取被迫检查多处,读放大由此而来。L0 文件多时,这里是最大的开销。

工程上用布隆过滤器给每个 SSTable 配一张“气味表”,查询时先闻后翻,多数不存在的键在内存里就被拦下,避免磁盘 IO。点查(按单键取)在布隆加持下可以做到很快;但范围扫描要横跨多层取并集,成本依然偏高——这是 LSM 与生俱来的“偏科”。

老板娘笑说:“找菜的路远近,全看打烊归整做得勤不勤。归整得好,一趟就能摸到;归整得懒,柜台堆成山,点单就得等。”读放大与 Compaction 的宿命纠缠,在这个比喻里一目了然。

RA=1⏟内存表+NL0⏟L0 全部+(L−1)⏟每层一个文件\displaystyle RA = \underbrace{1}_{\text{内存表}} + \underbrace{N_{\text{L0}}}_{\text{L0 全部}} + \underbrace{(L - 1)}_{\text{每层一个文件}}
读放大等于“查一次数据实际要读多少个文件”:内存 1 处加 L0 全部文件,再加更深的每层 1 个。
  1. 内存算一处MemTable 和冻结表总归是内存查询,命中就不用下盘,记 \(+1\)。
    +1\displaystyle +1
  2. L0 全部检查L0 无序且互相重叠,最坏情况下 \(N_{\text{L0}}\) 个文件每个都要翻,记 \(+N_{\text{L0}}\)。
    +NL0\displaystyle +N_{\text{L0}}
  3. 下层各查一个L1 起每层有序不重叠,二分定位后最多读一个文件,共 \(L-1\) 层,记 \(+(L-1)\)。
    +(L−1)\displaystyle +(L - 1)

与B+树对决:账本式经营 vs 便签式经营

街对面的老字号“王记饭庄”是账本式经营:每来一笔生意,账房先生当场翻开总账,找到对应那页改写。账本永远是准的,客人要查账,翻开就是。可问题也在这:高峰期一晚上几千笔,账页被来回涂改、翻得千疮百孔,改一页有时候要带动后面一整摞——用行话说,这是昂贵的随机写。

B+ 树就是数据库界的“王记账本”:MySQL InnoDB 的默认索引。它原地更新、结构稳定,点查和范围查询都很快,读路径短。但每次更新可能触发页分裂,一次逻辑写入要付出多次磁盘随机 IO,而磁盘随机 IO 比顺序 IO 慢一到两个数量级。

铁记的便签式经营正好相反:写入时只追加不修改,把“随时整理”推迟成“后台归整”。因此写密集场景下,LSM 的写入吞吐可以比 B+ 树高出一个量级;代价是读放大、写放大和一定的空间冗余。选型逻辑很清楚:日志、监控、消息队列这类写多读少的场景交给 LSM;交易、订单这类读多写少、要求低延迟的场景,B+ 树依然稳坐江山。

两家馆子各有拥趸:赶时间的喜欢王记的随时可查,深夜撸串的偏爱铁记的接单神速。选型没有银弹,只有匹配业务的那一款。

CB+ 树=O(log⁡FN)×随机 IO,CLSM=O(1)×顺序 IO+O(log⁡n)×内存\displaystyle C_{\text{B+ 树}} = O(\log_F N) \times \text{随机 IO}, \qquad C_{\text{LSM}} = O(1) \times \text{顺序 IO} + O(\log n) \times \text{内存}
B+ 树每次更新要先沿树高做多次随机寻址;LSM 每次更新只是顺序追加日志加内存插入,成本结构完全不同。
  1. B+ 树的页寻址更新一棵 \(N\) 个键、扇出 \(F\) 的 B+ 树,要沿树高 \(\log_F N\) 定位叶子页,每次定位都可能是一次磁盘随机 IO。
    O(log⁡FN)×随机 IO\displaystyle O(\log_F N) \times \text{随机 IO}
  2. LSM 的追加写一次写入只追加 WAL(常数次顺序 IO),再插入内存跳表 \(O(\log n)\),全程没有随机 IO。
    O(1)×顺序 IO+O(log⁡n)×内存\displaystyle O(1) \times \text{顺序 IO} + O(\log n) \times \text{内存}
  3. 成本前置与后置B+ 树把整理成本前置(每笔都整理),LSM 把成本后置(后台归整),同一笔账两种付法。
    读快写慢vs写快读慢\displaystyle \text{读快写慢} \quad \text{vs} \quad \text{写快读慢}

工程实践:从小馆开到连锁店

铁记开出连锁店后,各分店的便签墙做多大、流水簿多久合订一册、伙计几点归整,都要根据客流数据定规矩:外卖多的店便签墙要大,堂食查菜谱多的店气味速查表要做厚。老板娘说:“后厨的每一项参数,都是给客流画像。”

生产环境用 RocksDB 时,几个核心参数直接决定引擎性格:write_buffer_size(单块 MemTable 大小,相当于便签墙面积)、max_write_buffer_number(缓冲块数,等于墙上和备用墙的数量)、level0_file_num_compaction_trigger(L0 触发合并的阈值,等于柜台堆几本册子才归整)、max_bytes_for_level_base(基础层目标大小,决定书架容量的基调)。

参数之间互相牵扯,所以要先算内存预算,再设合并节奏,最后用监控验证。常用指标有 Write Stall 时长、写放大系数、读放大系数、L0 文件数。RocksDB 之所以被 TiKV、HBase、Flink 状态后端以及无数队列系统选作底座,正是因为它把“先写后整理”的哲学打磨成了可配置、可监控的工程实践;重启后依靠 WAL 重放与后台合并自动恢复,就像停电后按流水簿重新接单。

连锁店的秘诀不是某个神奇菜谱,而是“用数据定参数、用监控看呼吸”。调优 RocksDB 也一样:理解比喻背后的机制,再去动参数,心里才有底。

M内存预算≈2×Sbuffer×Nbuffer+Scache+Sbloom\displaystyle M_{\text{内存预算}} \approx 2 \times S_{\text{buffer}} \times N_{\text{buffer}} + S_{\text{cache}} + S_{\text{bloom}}
内存预算 = 双倍的写缓冲(当前表 + 冻结表)乘以缓冲块数,再加上块缓存和布隆过滤器占用的内存。
  1. 双缓冲系数 2当前 MemTable 和正在刷盘的 immutable 各占一份内存,所以单块大小先乘 2。
    2×Sbuffer\displaystyle 2 \times S_{\text{buffer}}
  2. 乘以缓冲块数max_write_buffer_number 决定总共几块写缓冲并行工作,乘上单块大小得到写缓冲总占用。
    Sbuffer×Nbuffer\displaystyle S_{\text{buffer}} \times N_{\text{buffer}}
  3. 缓存与过滤器block cache 和布隆过滤器同样吃内存,必须计入总预算,否则会挤压 OS 页缓存,反而变慢。
    + Scache+Sbloom\displaystyle +\, S_{\text{cache}} + S_{\text{bloom}}

AI 应用场景

推荐系统的特征实时写入

阶段:数据采集与特征工程

解决的问题:推荐系统上线后,用户点击、曝光事件以每秒数十万条涌入。传统 B+ 树存储每条事件都要随机写盘,磁盘 IO 瞬间打满;LSM 引擎把写入变成“顺序日志 + 内存便签”,峰值写入吞吐提升一个量级,且 WAL 保证进程崩溃不丢数据。

具体案例:某短视频推荐平台将用户实时行为特征写入 RocksDB,先供在线特征服务读取最新特征,后台 Compaction 再把过期特征清理掉,存储成本下降 40%。

大模型网关的调用日志

阶段:模型服务与监控

解决的问题:LLM 应用每天产生上亿条推理调用日志(时间、用户、token 数、耗电),既要全量落盘审计,又要支持按时间范围回溯分析。LSM 的顺序追加写入能吞下这个量级,布隆过滤器让“查某个 request_id”的定点检索几乎不落盘。

具体案例:某大模型网关把调用日志写入 RocksDB,按天分桶存储,高峰期写入 QPS 突破 50 万,定点查询延迟 P99 小于 20ms。

流式计算的状态持久化

阶段:模型训练与数据管道

解决的问题:Flink 窗口聚合、去重计数等算子把中间状态保存在 RocksDB(作为状态后端)。计算节点随时可能被抢占重启,状态写得太慢会拖垮整个管道,写得不牢一重启就全丢。RocksDB 的 WAL + MemTable 恰好兼顾了“快”与“牢”。

具体案例:某金融风控系统的实时特征管道用 Flink + RocksDB 保存每秒百万事件的滑动窗口状态,任务重启后通过 WAL 重放和快照恢复,数据零丢失。

Python 演示

python85 行
"""MiniLSM: 用几十行代码体验 LSM 树的核心思想."""import bisect  class MiniLSM:    """极简 LSM 存储引擎: WAL + MemTable + 多层 SSTable。"""     def __init__(self):        self.wal_log = []          # WAL 流水簿        self.mem_map = {}          # MemTable 数据        self.mem_keys = []         # MemTable 有序键        self.mem_limit = 6         # MemTable 写满阈值        self.l0_files = []         # L0 层: 无序文件列表        self.l1_files = []         # L1 层: 已合并文件        self.l2_files = []         # L2 层: 最底层文件        self.l0_limit = 2          # L0 合并触发阈值     def put(self, key, value):    # 写路径        self.wal_log.append((key, value))              # 1. 先记流水簿        if key not in self.mem_map:                    # 2. 贴便签            bisect.insort(self.mem_keys, key)        self.mem_map[key] = value        if len(self.mem_keys) >= self.mem_limit:       # 3. 贴满了            self._flush()     def get(self, key):           # 读路径: 从最新到最旧        if key in self.mem_map:                        # 先看便签墙            return self.mem_map[key]        for table in self.l0_files:                    # 再翻柜台            for k, v in table:                if k == key:                    return v        for table in self.l1_files + self.l2_files:    # 最后查库房            for k, v in table:                if k == key:                    return v        return None     def _flush(self):             # MemTable -> L0        table = [(k, self.mem_map[k]) for k in self.mem_keys]        self.l0_files.append(table)        self.mem_map.clear()        self.mem_keys.clear()        if len(self.l0_files) >= self.l0_limit:            self._compact()     def _compact(self):           # 归并: 把无序文件合并成有序文件        merged = []        for table in self.l0_files:            merged.extend(table)        merged.sort()             # 归并排序        self.l0_files.clear()        self.l1_files.append(merged)        if len(self.l1_files) >= 2:                    # L1 也满了, 沉到 L2            big = []            for table in self.l1_files:                big.extend(table)            big.sort()            self.l1_files.clear()            self.l2_files.append(big)     def replay_wal(self):         # 崩溃恢复: 重放流水簿        for key, value in self.wal_log:            if key not in self.mem_map:                bisect.insort(self.mem_keys, key)            self.mem_map[key] = value  def main():    db = MiniLSM()    for i in range(30):        db.put("user:%d" % i, "v%d" % i)    print("写入 30 条后 -> L0:", len(db.l0_files),          "L1:", len(db.l1_files), "L2:", len(db.l2_files))    print("点查 user:5 =", db.get("user:5"))    db.put("user:5", "updated")                        # 覆盖旧值    print("覆盖后 user:5 =", db.get("user:5"))    db2 = MiniLSM()                                    # 模拟断电恢复    db2.wal_log = db.wal_log[:]    db2.replay_wal()    print("WAL 重放后 user:29 =", db2.get("user:29"))  if __name__ == "__main__":    main()
目的:用 60 行代码还原一个微型 LSM 引擎,直观看到写入先进 WAL 和 MemTable、刷盘成 L0、归并沉到 L1/L2 的全过程。
  1. self.wal_log.append((key, value))每次写入先记流水簿,模拟 WAL 顺序追加,这是崩溃恢复的依据。
  2. bisect.insort(self.mem_keys, key)在有序键列表里二分插入,模拟跳表保持 MemTable 有序。
  3. self.l0_files.append(table)MemTable 写满就整体落盘成一个 L0 文件,对应“便签墙整片撤下装订”。
  4. if key in self.mem_map:读路径先查内存表,命中就不下盘,对应老板娘先扫便签墙。
  5. merged.sort()L0 多个无序文件合并成一个有序大文件,对应伙计们的打烊归整。
  6. db2.replay_wal()新实例重放 WAL 恢复数据,模拟进程崩溃后的持久性保障。

常见误区

误区名字里带“树”,很多初学者以为 LSM 像 B+ 树那样,在内存或磁盘上维护一棵完整的有序树,所有读写都在树上进行。

正解LSM 只有内存里的 MemTable 是一棵跳表,磁盘上是一堆分层的排序文件(SSTable),“合并”发生在文件之间而非树节点上。它更像“便签墙 + 书架”的组合,而不是一棵树。树的部分只负责“贴便签”,“找菜”靠的是层级和索引。

为何常见:教科书常用树形示意图表达分层,容易让人误以为整棵大树真实存在。

误区后台多合并几次,数据更整齐、读更快,于是把触发阈值调到很小,让引擎拼命归并。

正解每次合并都要重写一遍数据,太频繁会让写放大飙升,磁盘带宽被后台任务吃光,反而触发写停顿、拖慢前台写入。合并频率是个平衡点:合并太少,L0 堆成山、读放大失控;合并太多,写放大失控。调优要看监控,而不是一味图“整齐”。

为何常见:只看到合并让数据变整齐的好处,忽略了每次合并都有真实的磁盘重写成本。

误区既然数据先进 MemTable,WAL 看起来是“多余的日志”,关掉它能省一次磁盘写,性能一定大涨。

正解WAL 是顺序追加写,成本极低(微秒级),它是持久性的最后底线。关闭 WAL 后,进程崩溃或断电会把 MemTable 里尚未落盘的数据全部丢掉。生产环境收益很小、风险巨大,RocksDB 官方也只在可容忍数据丢失的缓存场景才建议关闭。

为何常见:把 WAL 当成和普通随机写一样的“额外开销”,忽略了顺序追加远比随机写便宜。

误区调用 Delete 后,以为这条数据立刻被删除、磁盘空间马上释放,占用会变小。

正解LSM 是追加写模型,“删除”只是写入一条墓碑标记(tombstone),旧数据仍然占着磁盘。真正回收空间要等 Compaction 扫到这条记录,把旧值和墓碑一起处理掉。所以删除操作的“见效”是滞后的,这正是 LSM 与 B+ 树语义最不一样的地方。

为何常见:习惯了 B+ 树原地删除立刻生效的语义,没理解追加写模型下“删除也是一种写入”。

面试题

Q1. 一条写请求在 LevelDB 中经过的正确路径是?

单选题 · easy

  • A.先写 WAL 日志,再插入内存表← 正确答案
  • B.先插入内存表,再写 WAL 日志
  • C.直接写入磁盘上的 SSTable 文件
  • D.先查布隆过滤器,再决定写入层级

答案:A。LSM 的写路径固定是“先记流水簿、再贴便签”——先追加 WAL 保证可恢复,再插入 MemTable 返回成功。B 颠倒了顺序,崩溃时会丢数据;C 跳过了内存缓冲,失去顺序写优势;D 中的布隆过滤器只服务读取,与写入路径无关。

Q2. MemTable 内部通常使用什么数据结构保证有序?

单选题 · easy

  • A.有序跳表结构← 正确答案
  • B.链式哈希表
  • C.最小二叉堆
  • D.动态环形队列

答案:A。LevelDB 和 RocksDB 默认用跳表(skiplist)实现 MemTable,靠“抛硬币”分层实现 O(log n) 的插入与查找。哈希表无序且无法范围扫描;二叉堆只保证堆顶有序;环形队列是 FIFO,都不满足有序读写的要求。

Q3. WAL(写前日志)的核心作用是?

单选题 · easy

  • A.加速热门数据的读取
  • B.崩溃后重放日志恢复数据← 正确答案
  • C.减少磁盘空间占用
  • D.让数据落盘排序更快

答案:B。WAL 就是门口的流水簿:每条写入先顺序记进日志,进程崩溃后把 MemTable 里没落盘的数据重放回来。它不参与读加速、不省空间、也不排序,纯粹是为持久性兜底。关掉它等于把持久性押在“永远不死机”上。

Q4. 关于 L0 层与 L1 层,下列说法正确的是?

单选题 · mid

  • A.L0 文件间允许键范围重叠← 正确答案
  • B.L0 与 L1 都严格有序且不重叠
  • C.读取数据时永远先查 L1 层
  • D.L0 数据文件的数量永远保持不变

答案:A。L0 是 MemTable 直接刷盘的结果,文件之间无序、允许重叠,像柜台随手堆的散册子;从 L1 起每层才严格有序不重叠。读取永远先查内存表和 L0,因为那里数据最新;L0 文件数随刷盘和合并动态变化,不可能恒定。

Q5. 读放大产生的根本原因包括?

多选题 · mid

  • A.同一键数据在多个层级重复存放← 正确答案
  • B.L0 层多个文件键范围互相重叠← 正确答案
  • C.布隆过滤器占用了过多内存
  • D.WAL 日志被反复重放造成开销

答案:AB。同一键在 MemTable、L0、L1 等多处各有副本,查一次要逐层找,这是读放大的根源;L0 文件互相重叠又让这一层必须逐个检查,进一步放大。布隆过滤器恰恰是缓解读放大的手段,不是原因;WAL 只在故障时重放,与正常读取无关。

Q6. 布隆过滤器在 LSM 中的作用是?

单选题 · mid

  • A.判断键是否一定存在于文件内
  • B.快速判断键不存在以减少磁盘 IO← 正确答案
  • C.对 SSTable 数据块做压缩编码
  • D.加快 MemTable 的插入速度

答案:B。布隆过滤器像“气味速查表”,它说“没有”就一定没有,从而跳过整个 SSTable 文件;但它说“有”只是可能,仍需读盘确认,所以不能用于判断存在性。它不压缩数据,也不参与 MemTable 插入。

Q7. Compaction(合并)可以带来哪些收益?

多选题 · hard

  • A.回收被删除或覆盖数据的空间← 正确答案
  • B.减少层级数量从而降低读放大← 正确答案
  • C.彻底消除写放大这一现象
  • D.让所有数据都常驻内存运行

答案:AB。合并把散乱的 L0 文件归并成有序大文件,重复和墓碑数据被清理,空间得以回收;文件数量减少、层级规整,读放大会随之下降。但合并本身要重写数据,写放大只能被调节、不可能消除;数据依然在磁盘上,D 与 LSM 的设计初衷相悖。

Q8. 与 B+ 树相比,LSM 树在写密集场景的优势主要来自?

单选题 · mid

  • A.采用内存映射文件机制加速
  • B.把随机写转化为顺序追加写← 正确答案
  • C.使用更大的页大小减少 IO
  • D.对数据做字典序压缩编码

答案:B。B+ 树每次更新要定位并改写某个随机页,随机写昂贵;LSM 把写入变成“WAL 顺序追加 + MemTable 内存插入”,后台再归并,磁盘上几乎没有随机写。内存映射、页大小、压缩都不是 LSM 独有的优势。

Q9. 关于 LSM 树的空间放大,下列说法正确的是?

单选题 · hard

  • A.指同一逻辑数据在多层存在冗余副本← 正确答案
  • B.空间放大与层级数量完全无关
  • C.删除数据后磁盘空间立即释放
  • D.布隆过滤器一定会增加空间放大

答案:A。同一键可能在 L0、L1、L2 各有副本,占用的实际空间大于逻辑数据量,这就是空间放大。层级越多、冗余越多,所以 B 错;删除要等 Compaction 才真正回收,C 错;布隆过滤器是额外元数据,但它不属于“同一逻辑数据的冗余副本”,且占用很小,D 的“一定”过于绝对。

Q10. 哪些场景更适合选择 LSM 树而不是 B+ 树?

多选题 · extreme

  • A.海量写入、写多读少的日志采集场景← 正确答案
  • B.依赖随机小写更新的事务型数据库
  • C.需要频繁删除覆盖的队列存储场景← 正确答案
  • D.读敏感点查极多的在线交易场景

答案:AC。日志采集是典型写多读少,LSM 的顺序追加优势最大;队列消费场景频繁删除和覆盖,Compaction 的墓碑机制能批量回收空间,也适合 LSM。而事务型数据库和点查极多、读延迟敏感的交易场景,B+ 树的原地更新和稳定读路径依然更合适。