<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>LSM-Tree on Neil的自留地</title><link>https://neilmin.com/zh/tags/lsm-tree/</link><description>Recent content in LSM-Tree on Neil的自留地</description><image><title>Neil的自留地</title><url>https://neilmin.com/images/papermod-cover.png</url><link>https://neilmin.com/images/papermod-cover.png</link></image><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Sat, 13 Jun 2026 07:00:00 -0700</lastBuildDate><atom:link href="https://neilmin.com/zh/tags/lsm-tree/index.xml" rel="self" type="application/rss+xml"/><item><title>RocksDB 是怎么工作的：一份 LSM-Tree 的极简笔记</title><link>https://neilmin.com/zh/posts/how-rocksdb-works/</link><pubDate>Sat, 13 Jun 2026 07:00:00 -0700</pubDate><guid>https://neilmin.com/zh/posts/how-rocksdb-works/</guid><description>准备面试时我把 RocksDB 的工作原理好好学了一遍，这是我整理的一份笔记：RocksDB 是什么、数据怎么写进去、怎么读出来、后台的 compaction 在忙什么，以及绕不开的「三种放大」权衡。不求多专业，只想把 LSM-Tree 的核心思路讲清楚，分享给同样想搞懂它的人。</description><content:encoded><![CDATA[<p>准备面试的时候，我花了点时间，把 RocksDB 的工作原理从头到尾学了一遍——它的存储引擎到底是怎么设计的，数据是怎么写进去、又怎么读出来的。RocksDB（以及它背后的 LSM-Tree）是那种很多人听过、但真要讲清楚就容易卡壳的东西，我自己以前也是。等真的搞懂了，就把里面的核心思路整理成这份笔记，分享给同样想弄明白它的人。</p>
<p>我不敢说讲得有多专业、多全面，但希望读完，你（还有未来的我）能对「RocksDB 大概是怎么转起来的」有一个清楚的整体印象。下面这些英文词我尽量保留原样（LSM-Tree、MemTable、SST、WAL、compaction……），因为面试和文档里大家就是这么说的，硬翻成中文反而别扭。</p>
<h2 id="rocksdb-是什么">RocksDB 是什么</h2>
<p>一句话：<strong>一个可嵌入的、持久化的键值（key-value）存储引擎</strong>。</p>
<ul>
<li><strong>可嵌入（embedded）</strong>：它不是一个像 MySQL 那样单独跑的服务器，而是一个库，直接编进你的程序里，省掉了进程间通信的开销。</li>
<li><strong>持久化</strong>：数据落在磁盘上，崩了也不丢。</li>
<li>2012 年从 Google 的 <strong>LevelDB</strong> fork 出来，用 C++ 写的，专门为 <strong>SSD</strong> 和<strong>写多</strong>的场景做了优化。Meta、Microsoft、Netflix、Uber 都在用。</li>
<li>它<strong>不是分布式的</strong>——副本、分片这些得你自己在上层做。</li>
</ul>
<p>它对外提供的操作很朴素：<code>put(key, value)</code> 写、<code>get(key)</code> 读、<code>delete(key)</code> 删、<code>merge(key, value)</code> 合并、还有 <code>iterator.seek()</code> 做范围扫描。</p>
<h2 id="核心思想lsm-tree">核心思想：LSM-Tree</h2>
<p>RocksDB 的一切都建立在 <strong>LSM-Tree（Log-Structured Merge-Tree，日志结构合并树）</strong> 上。</p>
<p>它要解决的核心矛盾是：<strong>磁盘最怕随机写，最喜欢顺序写</strong>。LSM 树的思路就是——先把写攒在内存里排好序，再一次性顺序地刷到磁盘。换句话说，它<strong>把大量随机写「攒」成了顺序写</strong>，这就是它写得快的根本原因。</p>
<p>结构上，数据分成很多层：最上面一层在内存里，下面一层层在磁盘上，编号 L0、L1、L2……越往下的数据越老、容量越大（通常下一层是上一层的约 10 倍）。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>内存   ┌──────────────────────────────┐
</span></span><span style="display:flex;"><span>       │  MemTable（可写，内部有序）    │  ← 新数据先进这里
</span></span><span style="display:flex;"><span>       └──────────────────────────────┘
</span></span><span style="display:flex;"><span>- - - - - - - - - - - - - - - - - - - - - -  flush（刷盘）
</span></span><span style="display:flex;"><span>磁盘   L0   [SST] [SST] [SST]      ← 最新；文件之间 key 范围可能重叠
</span></span><span style="display:flex;"><span>       L1   [SST][SST][SST][SST]   ← 每层内部 key 不重叠，且更大
</span></span><span style="display:flex;"><span>       L2   [SST][SST] ......      ← 越往下越老、越大（约 ×10）
</span></span><span style="display:flex;"><span>       ...
</span></span></code></pre></div><p>这套结构 1996 年就提出来了，专门优化写密集的负载。除了 RocksDB，Bigtable、HBase、Cassandra、MongoDB 的 WiredTiger 引擎用的也都是 LSM 树。</p>
<h2 id="写数据是怎么进去的">写：数据是怎么进去的</h2>
<p>一次写入，会<strong>同时</strong>落到两个地方：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>put(key, value)
</span></span><span style="display:flex;"><span>      │
</span></span><span style="display:flex;"><span>      ├──► WAL    （顺序追加到磁盘，防崩溃）
</span></span><span style="display:flex;"><span>      │
</span></span><span style="display:flex;"><span>      └──► MemTable（在内存里排好序）
</span></span><span style="display:flex;"><span>                  │  写满约 64MB
</span></span><span style="display:flex;"><span>                  ▼
</span></span><span style="display:flex;"><span>            转为只读，后台线程刷成一个 SST 文件 → 落到 L0
</span></span></code></pre></div><p><strong>MemTable</strong>：内存里的写缓冲，所有增删改都先到这。它内部是<strong>按 key 排好序</strong>的（默认用<strong>跳表 skip list</strong> 实现），这样后面刷盘和范围查询才高效。一个细节：删除不是真的把数据抹掉，而是写一条<strong>墓碑</strong>（tombstone）记录，表示「这个 key 删了」——真正的清理留给后面的 compaction。</p>
<p><strong>WAL（Write-Ahead Log，预写日志）</strong>：MemTable 在内存里，断电就没了。所以每次写也会<strong>顺序追加</strong>一条记录到磁盘上的 WAL 文件，里面有 key、value、操作类型和校验和（checksum）。崩溃重启后，靠重放 WAL 把 MemTable 恢复出来。注意 WAL 是<strong>按写入顺序追加的，不排序</strong>——它图的就是个快。</p>
<p><strong>Flush（刷盘）</strong>：MemTable 写满后会变成只读，换一个新的继续接客；后台线程把这个只读的 MemTable 刷成一个 <strong>SST 文件</strong>，落在 L0。刷完，对应的 WAL 就可以丢了。因为 MemTable 本来就是有序的，这一刷就是一次<strong>顺序写</strong>——LSM 树的精髓就在这儿。</p>
<h2 id="sst-文件长什么样">SST 文件长什么样</h2>
<p><strong>SST（Static Sorted Table，静态有序表）</strong> 是磁盘上真正存数据的文件，一旦写好就不再修改。它里面是一堆<strong>排好序的 key-value</strong>，并且为了查询方便做了精心的分块设计（block，默认 4KB，可以用 Snappy、LZ4、ZSTD 等压缩）。</p>
<p>一个 SST 大致分几部分：</p>
<ul>
<li><strong>数据块（data）</strong>：有序的 key-value。因为相邻 key 很像，可以只存差异（delta encoding）省空间。</li>
<li><strong>索引（index）</strong>：记录每个数据块「最后一个 key → 在文件里的位置」，这样查的时候能<strong>二分</strong>直接定位到某个块，而不用扫整个文件。</li>
<li><strong>布隆过滤器（Bloom filter，可选）</strong>：一个概率型结构，能极快地回答「这个 key <strong>一定不在</strong>这个文件里」。它可能误报「在」，但绝不漏报「不在」——所以特别适合在读的时候先挡掉一大批根本不用查的文件。</li>
</ul>
<h2 id="读数据是怎么找出来的">读：数据是怎么找出来的</h2>
<p>读一个 key，要<strong>从新到老</strong>一层层找，因为新写的值在上层、旧值在下层，第一个找到的就是最新的：</p>
<ol>
<li>先查正在写的 MemTable；</li>
<li>再查还没刷完的只读 MemTable；</li>
<li>再查 L0 的每个 SST 文件（L0 文件之间 key 范围会重叠，所以得挨个看，从新到旧）；</li>
<li>L1 及以下，每层内部 key 不重叠，所以<strong>每层只需定位并查一个文件</strong>。</li>
</ol>
<p>而在<strong>单个 SST 文件</strong>里找，又是三步走：先用 <strong>Bloom filter</strong> 问一句「key 在不在」，不在就直接跳过这个文件；在的话，用 <strong>index 二分</strong>定位到对应的数据块；最后把那个块读出来，在块内找到 key。</p>
<p>所以读的代价，关键看要翻多少层、多少文件——这也引出了下一节要解决的问题。</p>
<h2 id="compaction后台一直在打扫">Compaction：后台一直在「打扫」</h2>
<p>前面说了，删除只是写墓碑、更新也只是写新值盖在旧值上面。时间一长，磁盘上就会堆满<strong>过期的旧版本和墓碑</strong>：既白占空间，又让读的时候要翻越更多文件。</p>
<p><strong>Compaction（压缩合并）</strong> 就是后台干这个清理活的：把某一层的若干 SST 文件，和下一层有重叠的文件合在一起，<strong>丢掉那些被覆盖的旧值和被删的 key</strong>，再写成新的、干净的 SST 放到下一层。因为每个文件本来都有序，合并用的是<strong>多路归并（k-way merge）</strong>，就是归并排序里「合并」那一步的放大版。整个过程在后台线程跑，不挡前台的读写。</p>
<p>RocksDB 默认用 <strong>leveled compaction（分层合并）</strong>：</p>
<ul>
<li><strong>L0</strong> 比较特殊，文件之间 key 范围<strong>允许重叠</strong>（因为它们是 MemTable 直接刷下来的）；当 L0 文件数攒到阈值（默认 4 个）就触发合并。</li>
<li><strong>L1 及以下</strong>，每一层内部所有文件的 key 范围<strong>互不重叠</strong>、整体有序；当某层的总大小超过设定值，就把超出的部分往下一层合并，有时会一路连锁往下压好几层。</li>
</ul>
<h2 id="一切都是权衡三种放大">一切都是权衡：三种「放大」</h2>
<p>理解 RocksDB（其实是所有 LSM 引擎）调优的关键，是三个<strong>放大</strong>（amplification）指标：</p>
<ul>
<li><strong>空间放大（space amplification）</strong>：实际占的磁盘空间 ÷ 数据本身的大小。旧版本和墓碑越多，空间放大越大。</li>
<li><strong>读放大（read amplification）</strong>：读一条数据，底层实际要做几次 I/O。要翻的层和文件越多，读放大越大。</li>
<li><strong>写放大（write amplification）</strong>：写一条数据，底层实际写了几次。同一条数据会在 compaction 里被反复重写到更下面的层，所以写放大会很大。</li>
</ul>
<p>这三者按下葫芦浮起瓢：<strong>compaction 越勤，空间和读放大越小，但写放大越大</strong>；反之亦然。怎么平衡全看你的负载，参数极多且互相影响——连 RocksDB 官方都坦言很难讲清每个参数的确切效果，建议<strong>多做 benchmark，盯着这三个放大指标调</strong>。</p>
<blockquote>
<p><strong>顺带一提：merge 操作</strong></p>
<p>除了 put 和 delete，RocksDB 还有个 <code>merge</code>。当你要对一个值做大量「增量更新」（比如不停往一个计数器或列表上追加）时，传统做法是 read-modify-write：读出来、改、再写回去，很费劲。<code>merge</code> 让你只写「增量」本身，把怎么合并交给一个你自定义的 merge 函数，到读取或 compaction 时再真正算出最终值。<strong>好处</strong>是省写放大、还线程安全；<strong>代价</strong>是读变贵了——没合并之前，每次读都得把这些增量重新算一遍。</p>
</blockquote>
<h2 id="记住这几点就够了">记住这几点就够了</h2>
<p>如果只留一张「脑图」，是这样的：</p>
<ul>
<li><strong>RocksDB</strong> = 可嵌入的持久化 KV 存储，源自 LevelDB，核心是 <strong>LSM-Tree</strong>；</li>
<li><strong>写</strong>：先进内存的 <strong>MemTable</strong>（有序）+ 顺序写 <strong>WAL</strong>（防崩溃）→ 攒满后刷成 <strong>SST</strong> 文件落到 L0 → 后台 <strong>compaction</strong> 慢慢往下整理；</li>
<li><strong>读</strong>：从新到老一层层找，靠 <strong>Bloom filter</strong> + <strong>index</strong> 跳过和定位，少读冤枉文件；</li>
<li><strong>本质</strong>：用「写放大」换「把随机写变成顺序写」的高吞吐——<strong>空间、读、写三种放大之间，永远是权衡，没有免费的午餐</strong>。</li>
</ul>
<p>把这几句记住，RocksDB 的大框架就立起来了。更细的东西——skip list、delta encoding、各种 compaction 策略、参数到底怎么调——等真正用到的时候再往里钻也不迟。</p>
<blockquote>
<p>这份笔记里不少理解，来自 Artem Krylysov 的 <a href="https://artem.krylysov.com/blog/2023/04/19/how-rocksdb-works/">How RocksDB Works</a>——原文讲得非常细致，想往深里走的话很推荐读一读。</p>
</blockquote>
]]></content:encoded></item></channel></rss>