想象有一家图书馆,管理员小绿每天要面对潮水般涌来的新书。她从不急着把书一本本插进大书架,因为那样又慢又乱。她的做法是:先在随身的笔记本上记一笔账,然后把新书堆到桌面上按书名排好;等桌面堆满了,就把整桌书装订成一本厚厚的“藏书册”——册子里的词条严格按字母顺序排列,而且装订好后就再也不许任何人涂改。读者想找书时,先查册子封底的“地图”,再查章末的“索引”,最后才翻到具体那一页。每隔一段时间,小绿还会把好几本旧册子重新合订成一本更大的新册子,腾出书架空间。 这套“先记账、再排队、批量成册、定期合订”的图书管理法,就是 LSM 树存储引擎的核心思路。而那本反复被检索、合并、压缩的“藏书册”,正是本文的主角——SSTable(Sorted String Table,有序字符串表)。

岗位关联

读懂 SSTable 就等于拿到了 LevelDB / RocksDB 等存储引擎的“内部地图”。无论是调优写入性能、排查读放大问题,还是面试时讲清 LSM 树原理,这都是绕不开的硬核内功。

目录
  1. 可视化演示
  2. 文件结构总览:藏书册的装帧
  3. 数据块格式:节省纸张的抄写法
  4. 索引块机制:章末的检索页
  5. 布隆过滤器:门口的馆藏查询机
  6. 压缩合并:定期整理书架
  7. 读取流程:逐层找书的完整路线
  8. 写路径与元数据:新书上架的流水线
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示SSTable文件结构、数据块二分查找和布隆过滤器查询三个核心机制。

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

SSTable文件结构 数据块按Key有序存储,支持二分查找 索引块记录每块最大Key和偏移量 File = Data[] + Index + Bloom + Footer 不可变磁盘文件,一次写入多次读取

文件结构总览:藏书册的装帧

小绿今天要给新到的一批书装订成册。一部好用的藏书册是有讲究的:正文必须连续,章节要分明,封底务必贴一张“整册地图”。任何读者拿到册子,第一件事不是翻第一页,而是翻封底地图——上面写着“索引在第几页”“元数据存放在哪一块”。没有这张地图,再整齐的册子也会让人抓瞎。

SSTable 文件正如这部藏书册。它由一串数据块(Data Block)和索引块(Index Block)组成,最后固定一块很小的 Footer。LevelDB 和 RocksDB 把 Meta Index Block(元数据索引块,负责指向布隆过滤器等)与 Index Block 的偏移量和大小都记录在 Footer 里。打开文件时,引擎先读取文件尾部的 Footer,再顺着它的指引去定位索引,整个过程只需要一次跳转,开销极小。

所有 key-value 条目在文件内按 key 的字典序排列,这是 SSTable 一切高效操作的基础。文件布局采用“头轻脚重”:数据块在前,索引块在后,元数据索引夹在中间,Footer 收尾。整个文件一旦生成便不可修改,新的写入只会追加出新的 SSTable,不会被篡改。这种不可变性让引擎可以放心缓存、并发读取,也让崩溃恢复变得简单。

小绿把地图印在封底,读者就不用翻遍全书找目录。SSTable 把 Footer 放在文件末尾,正是同样的智慧:用固定的入口、最小的成本,撬动整册书的全局导航。

SSTable=DataBlocks+IndexBlock+Metaindex+Footer\mathrm{SSTable} = \mathrm{DataBlocks} + \mathrm{IndexBlock} + \mathrm{Metaindex} + \mathrm{Footer}
这个式子描述了一部藏书册的四大部分:正文、索引、元数据指引、封底地图。整体是有序的、只读的、可寻址的。
  1. 数据块区(DataBlocks)存放真正的 key-value 数据,按 key 有序地切成一段段连续字节,每一段就是一个数据块。
    DataBlocks={Block1,Block2,…,Blockn}\mathrm{DataBlocks} = \{\mathrm{Block}_1, \mathrm{Block}_2, \dots, \mathrm{Block}_n\}
  2. 索引块(IndexBlock)为每个数据块记录“最后一个 key、文件偏移、块大小”,作为从数据块导航到具体位置的跳板。
    IndexBlock={index_entryi}\mathrm{IndexBlock} = \{\mathrm{index\_entry}_i\}
  3. 元数据索引(Metaindex)记录布隆过滤器等元数据块的位置,让引擎能快速找到“馆藏查询机”。
    Metaindex→BloomFilter_offset+BloomFilter_size\mathrm{Metaindex} \to \mathrm{BloomFilter\_offset} + \mathrm{BloomFilter\_size}
  4. Footer(封底地图)文件末尾固定大小的入口,记录 IndexBlock 与 Metaindex 的偏移和大小,是读取整册书的起点。
    Footer=offset(Metaindex)+offset(IndexBlock)\mathrm{Footer} = \mathrm{offset}(\mathrm{Metaindex}) + \mathrm{offset}(\mathrm{IndexBlock})

数据块格式:节省纸张的抄写法

藏书册的每一章,词条一个挨一个排列。为了节省纸张,小绿抄写时玩了个巧:如果新词条和上一个词条有相同的开头,她就只写“不同的尾巴”。比如已经写过 “apple“,下一个词是 ”apples“,她只写一个 ”s“;但每隔 16 个词条,她会完整重写一遍,作为一个”定位锚点”,防止读者一路猜前缀猜到晕头转向。

SSTable 的数据块(Data Block)正是这样组织的。数据块默认几 KB 到几十 KB 不等,内部由一条条 entry 组成。每条 entry 的 key 长度和 value 长度都用变长编码(Varint)存储——小数字只占一个字节,大数字才多占几个字节。LevelDB 与 RocksDB 还支持前缀压缩:对相邻 key 只记录公共前缀长度 shared_len、新增后缀长度 unshared_len,以及 value 本身。

每次从前缀压缩恢复时,都需要知道“上一条 key 长什么样”,所以引擎设置了 restart(重启)点。在每个 restart 点,shared_len 强制为 0,完整存储整条 key。块尾部还保存了一个 restart 偏移数组,二分查找时可以先用它跳转到某个 restart 点,再逐条向后比较,避免从头扫描整块。

这种“只写差异”的思路,就像小绿在藏书册里用省略号代替重复的单词前缀。读者只要找到最近的 restart 点,就能顺着差异链条还原出完整词条,既省纸又不迷路。

entry=shared_len+unshared_len+value_len+key_suffix+value\mathrm{entry} = \mathrm{shared\_len} + \mathrm{unshared\_len} + \mathrm{value\_len} + \mathrm{key\_suffix} + \mathrm{value}
这条公式是数据块内部的最小“词条例”。通过只记录与上一条的差异,SSTable 用极小的空间成本换来了紧凑的磁盘布局。
  1. shared_len(共享前缀长度)当前 key 与上一条 key 相同的公共前缀字节数,是前缀压缩的核心字段。
    shared_len=LCP(keyi−1,keyi)\mathrm{shared\_len} = \mathrm{LCP}(\mathrm{key}_{i-1}, \mathrm{key}_i)
  2. unshared_len(非共享后缀长度)当前 key 除去公共前缀后,新增部分的字节长度。
    unshared_len=∣keyi∣−shared_len\mathrm{unshared\_len} = |\mathrm{key}_i| - \mathrm{shared\_len}
  3. value_len(值的长度)value 字段占用的字节数,供读取时截取 value。
    value_len=∣value∣\mathrm{value\_len} = |\mathrm{value}|
  4. key_suffix + value(后缀与值)真正落在磁盘上的差异化数据,是 entry 的实际载荷。
    payload=key_suffix+ ⁣ ⁣+value\mathrm{payload} = \mathrm{key\_suffix} \mathbin{+\!\!+} \mathrm{value}

索引块机制:章末的检索页

藏书册如果只有正文,读者要找 “orange“ 就得从第一页翻起。小绿于是在每一章末尾附了一页”章节索引”:每个小节记录三个信息——这个小节的最后一个词条、小节在整个册子里的页码、小节占了几页纸。读者先用二分法在索引页上定位候选小节,再翻到那一节仔细查找。

SSTable 的索引块(Index Block)承担同样的角色。它的一条 Index Entry 对应一个数据块,记录该数据块的最后一个 key、数据块在文件中的偏移量 offset、数据块大小 size。查询某个 key 时,引擎先在索引块上做二分查找:找到第一个 last_key 不小于目标 key 的索引项,就锁定了唯一的候选数据块。

进入候选数据块后,块内仍然用二分或顺序扫描来定位具体 entry——因为块内还保留着 restart 点数组,可以借助它快速跳转。整个查找过程形成“两级二分”:先块级二分,再块内二分。相比在整份文件中线性扫描,这种导航方式把查找次数从“所有记录数”压缩到“两个对数的和”,这正是 SSTable 能在海量数据中保持低延迟的秘密。

小绿把章节标签贴在每一格的边上,读者就能跳过无关格子,直达目标。索引块就是 SSTable 贴在每一个数据块上的“侧边标签”,让随机读不再依赖从头扫描。

index_entry=last_key+block_offset+block_size\mathrm{index\_entry} = \mathrm{last\_key} + \mathrm{block\_offset} + \mathrm{block\_size}
这条公式给出了索引条目的三要素:边界 key、物理偏移、块长度。有了它,引擎就能“指哪打哪”。
  1. last_key(块的边界 key)数据块内最后一个 key,用于二分比较时判断目标是否落在这个块的范围内。
    last_key=Block.keys[end]\mathrm{last\_key} = \mathrm{Block}.\mathrm{keys}[\mathrm{end}]
  2. block_offset(块偏移)数据块在 SSTable 文件中的绝对起始位置,读取时直接 seek 到此偏移。
    block_offset=∑j<i∣Blockj∣\mathrm{block\_offset} = \sum_{j < i} |\mathrm{Block}_j|
  3. block_size(块大小)数据块占用的字节数,配合偏移量精确切出整块数据。
    block_size=∣Blocki∣\mathrm{block\_size} = |\mathrm{Block}_i|

布隆过滤器:门口的馆藏查询机

图书馆门口有一台“馆藏查询机”:它内部是一排小灯泡(位数组)。新书上架时,小绿用三把不同的“尺子”(哈希函数)量出三个位置,把对应灯泡点亮。读者问“有《MySQL 45 讲》吗?”查询机用同样三把尺子量一遍,只要发现任何一个灯泡没亮,就斩钉截铁地回答:“没有。”但如果三个灯泡都亮,它只能说:“可能有。”

这台查询机就是布隆过滤器(Bloom Filter),它是 SSTable 家族里最巧妙的“否定大师”。布隆过滤器用一个 m 位的位数组和 k 个哈希函数来标记集合。插入 key 时,把 k 个哈希位置全部置为 1;查询时,检查这 k 个位置——只要有一位是 0,就说明该 key 从未被插入过,可以百分百断定“不存在”;如果全是 1,则只能推断“可能存在”,因为其他 key 的哈希可能恰好把这些位置也点亮了。

这种“宁可误报、绝不漏报”的特性,让引擎在读取时能提前排除大量不相关的 SSTable 文件。每本藏书册门口都装一台查询机,读者还没进门就知道“这本册子里肯定没有我要的书”,连翻都不用翻。布隆过滤器的误判率是可以计算的,而且能通过调整位数组长度和哈希函数数量来控制。

小绿的查询机从不撒谎说“没有”——只要说没有就肯定没有。正是这种“拒绝空翻”的能力,让 LSM 引擎在多层 SSTable 之间查找时,能迅速跳过大量绝对不可能命中的文件,省下宝贵的磁盘 IO。

Pfp≈(1−e−knm)k,kopt=mnln⁡2P_{\mathrm{fp}} \approx \left(1 - e^{-\frac{kn}{m}}\right)^k, \qquad k_{\mathrm{opt}} = \frac{m}{n} \ln 2
误判率公式告诉我们:位数组越长、哈希函数个数越合适,误判率越低。最优 k 大约是每个元素分配位数 m/n 的 0.7 倍。
  1. 参数定义n 是已插入的元素个数,m 是位数组总位数,k 是哈希函数个数。
    n=∣已插入 key 集合∣,m=∣位数组∣,k=哈希函数数n = |\text{已插入 key 集合}|, \quad m = |\text{位数组}|, \quad k = \text{哈希函数数}
  2. 某位仍为 0 的概率插入 n 个 key、每个 key 随机置 k 位后,某一位始终未被置 1 的概率约为 $e^{-kn/m}$。
    P(某位=0)≈e−kn/mP(\text{某位}=0) \approx e^{-kn/m}
  3. 误判率查询一个不存在 key 时,恰好它对应的 k 位全为 1 的概率,即 $(1 - e^{-kn/m})^k$。
    Pfp=(1−P(某位=0))kP_{\mathrm{fp}} = (1 - P(\text{某位}=0))^k
  4. 最优哈希函数数对误判率求最小值,得到 $k_{\mathrm{opt}} = (m/n)\ln 2$。
    kopt=mnln⁡2k_{\mathrm{opt}} = \frac{m}{n}\ln 2

压缩合并:定期整理书架

日子一长,小绿的藏书册越堆越多:同一个词条可能同时出现在好几本册子里,书架都快塞不下了。于是她每周做一次“合订”:把若干本旧册子按字母顺序重新编排,合并成一本更大的新册子,把被覆盖的旧词条扔掉,腾出大片书架。虽然合订时累得满头大汗,但整理完的书架让人眼前一亮。

LSM 树的压缩合并(Compaction)正是这场“合订”。SSTable 在磁盘上是有层级的:第 0 层(L0)是内存表刷盘直接生成的,文件之间 key 范围可能重叠;从第 1 层(L1)开始,每层内部经过合并后 key 范围互不重叠,且容量按固定倍数增长。合并过程类似归并排序:多路读取旧文件,按 key 从小到大归并,写入新文件,同时重建数据块、索引块和布隆过滤器。

合并带来的最大代价是“写放大”:明明应用只写了 1 MB 数据,磁盘却可能实际写入了 10 MB、20 MB,因为旧数据要被反复读取、合并、重写。这个比率叫做写放大(Write Amplification),是评估 LSM 存储引擎性能的关键指标。层级越多,查阅越精确,但合订时搬动的书也越多;层数越少,合订越省力,但单层内的文件越多、读放大越高。小绿必须在“勤整理”和“少搬书”之间找平衡。

小绿每周末累得腰酸背痛,但换来的是工作日的轻松查找。Compaction 也一样:它把凌乱的短期文件整理成规整的长期结构,用后台的写放大换取前台的点查效率。

WriteAmp=实际写入磁盘的总字节数应用层写入的字节数\mathrm{WriteAmp} = \frac{\text{实际写入磁盘的总字节数}}{\text{应用层写入的字节数}}
写放大是衡量“整理书架”代价的标尺。理想值为 1,实际 LSM 引擎通常为 10~30,这正是压缩合并的固有开销。
  1. 分子(实际写入磁盘字节数)包含应用写入的数据,以及 Compaction 过程中读取、归并、重写旧文件产生的全部磁盘写入。
    实际写盘字节=数据写入+Compaction 重写\text{实际写盘字节} = \text{数据写入} + \text{Compaction 重写}
  2. 分母(应用层写入字节数)用户通过 put 接口真正想写的数据量,是衡量成本的基准。
    应用写字节=∑put∣key∣+∣value∣\text{应用写字节} = \sum_{\text{put}} |\mathrm{key}| + |\mathrm{value}|
  3. 比值含义WriteAmp 越接近 1 越好;大于 10 时说明大部分磁盘带宽被“整理书架”占用了。
    WriteAmp≈1⇔零额外开销\mathrm{WriteAmp} \approx 1 \Leftrightarrow \text{零额外开销}

读取流程:逐层找书的完整路线

一位读者来借《orange 种植手册》。小绿的找书流程像剥洋葱:先看桌面上正在整理的书堆(MemTable)有没有;没有,再看旁边待装订的备用桌(Immutable MemTable);还没有,才走向书架区,从最新的一层(L0)往里找。每层书的门口都有查询机,说“没有”就整层跳过,绝不多翻一本。

SSTable 的读取流程正是如此。一次 Get(k) 会依次检查:内存 MemTable、不可变内存表 Immutable MemTable、磁盘上 L0 到 Ln 各层 SSTable。L0 是刷盘直接生成的,文件之间 key 可能重叠,所以该层要检查多个候选文件;L1 及以下每层内部有序、范围不重叠,最多只需查一个候选文件。每查一个 SSTable,先用布隆过滤器判断“是否存在”——若判定不存在,整个文件直接跳过;若可能存在,再用索引块二分定位数据块,块内扫描找到记录。

由于数据的新版本总是比旧版本更早被写入,越新的层优先级越高。只要在某层找到了 key,就可以直接返回,不需要继续向更老的层查找。但如果一直找不到,就要把所有层都滤一遍,这就是“读放大”的来源:一次点查可能触发多次磁盘读取。读放大的公式很直观——它是所有层级需要检查的文件数总和。

小绿找书时“先近后远、层层过滤”,既保证能找到最新版本,又尽量少翻旧册子。读放大就是她每次找书需要翻动的册数——布隆过滤器、索引块、层级设计,全是为了让这个数字尽可能小。

ReadAmp=∑i=0Lni,n0≫1, ni≥1=1\mathrm{ReadAmp} = \sum_{i=0}^{L} n_i, \qquad n_0 \gg 1,\ n_{i \geq 1} = 1
读放大衡量一次查询要翻多少本书。L0 越大、层数越多,一次 Get 可能触碰的磁盘文件就越多。
  1. L0 贡献(n₀)第 0 层文件之间 key 重叠,最坏情况下要找遍该层所有文件,所以 n₀ 可以很大。
    n0=L0 文件数量(不可合并跳过)n_0 = \text{L0 文件数量(不可合并跳过)}
  2. 高层的贡献(nᵢ)从 L1 开始,每层 key 范围不重叠,所以每层最多只需检查一个候选文件,nᵢ = 1。
    ni=1(i≥1)n_{i} = 1 \quad (i \geq 1)
  3. 总和语义ReadAmp 越大,一次点查读取的文件块越多,P99 延迟越高,这是 LSM 读性能的瓶颈所在。
    ReadAmp=n0+(L−1)×1\mathrm{ReadAmp} = n_0 + (L - 1) \times 1

写路径与元数据:新书上架的流水线

每天都有新书涌入图书馆。小绿从不把书直接往大书架上插——那既慢又要挪动一堆旧书。她的做法是:先在随身小本本上记一笔“某年某月某日,收到《xxx》”,再把书放到桌面按字母排好。桌面满了就换一张备用桌继续放,而已经排满的桌面内容,会由管理员在后台统一装订成一本新的藏书册,放到 L0 层书架上。整个过程看似多了一步“记账”,却让每一次写入都变成快速追加,而不是昂贵的随机插入。

SSTable 的写入路径完美复刻了这套流水线。写入一条 (k, v) 时,引擎先把操作追加到 WAL(预写日志,相当于小绿的随身笔记本),保证即使机器突然断电也不会丢数据;然后更新内存中的 MemTable(跳表或有序结构)。当 MemTable 达到阈值,它被标记为只读的 Immutable MemTable,同时新开一张空的 MemTable;后台线程再把 Immutable 刷盘,生成一个新的 SSTable 追加到 L0。

SSTable 一旦落盘就永远不可变,因此不会有“修改已有文件”的操作。新数据覆盖旧数据,只是生成新文件、在更高层留下新版本,旧版本留待 Compaction 清理。元数据方面,SSTable 的 Footer 固定位于文件末尾,记录 Meta Index 和 Index Block 的位置;Meta Index 则指向布隆过滤器等元数据块。这种“尾部锚点”的设计,让小绿闭着眼睛都能从封底摸到整册书的导航信息。

小绿用“记账+排队+批量装订”的流水线,把原本又慢又乱的插书操作变成了一条高效的传送带。SSTable 的写路径也是如此:所有随机的小写请求先被吸收、排队,再批量落盘成有序文件,这就是 LSM 引擎写入快于 B+ 树引擎的根本原因。

Write(k,v): WAL→MemTable→Immutable→SSTableL0\mathrm{Write}(k,v):\ \mathrm{WAL} \to \mathrm{MemTable} \to \mathrm{Immutable} \to \mathrm{SSTable}_{L0}
这条流水线把一次随机写变成了“顺序日志 + 内存更新 + 批量落盘”。每一站各司其职,共同把写延迟压到极低。
  1. WAL(预写日志)先把 (k,v) 顺序追加到磁盘日志,崩溃后可以重放恢复,这是数据不丢失的第一道保险。
    WAL.append(k,v)⇒顺序写盘\mathrm{WAL}.\mathrm{append}(k, v) \Rightarrow \text{顺序写盘}
  2. MemTable(内存表)将 (k,v) 插入内存中的有序结构,写入只发生在内存,微秒级完成。
    MemTable.insert(k,v)⇒O(log⁡n)\mathrm{MemTable}.\mathrm{insert}(k, v) \Rightarrow O(\log n)
  3. Immutable(只读内存表)MemTable 写满后转为只读,不再接受新写入,等待后台刷盘。
    MemTable→Immutable⇒新表接替\mathrm{MemTable} \to \mathrm{Immutable} \Rightarrow \text{新表接替}
  4. SSTable_L0(刷盘落层)后台把 Immutable 内容排序、编码、构建索引和布隆过滤器,形成一个新 SSTable 追加到 L0。
    Immutable→SSTableL0⇒批量顺序写\mathrm{Immutable} \to \mathrm{SSTable}_{L0} \Rightarrow \text{批量顺序写}

AI 应用场景

分布式数据库 TiKV 的点查优化

阶段:存储引擎选型与性能调优

解决的问题:海量订单状态更新如果用传统 B+ 树,每一笔随机写都会落到磁盘,延迟和写放大难以承受。LSM 引擎通过 WAL + MemTable 吸收随机写,再批量刷成 SSTable;点查时用布隆过滤器跳过绝大多数不存在的文件。

具体案例:TiKV 使用 RocksDB 作为底层 KV 引擎,它正是基于 SSTable 格式构建的。当你在 TiDB 上执行一笔 UPDATE,数据先写 WAL、再进 MemTable、最终落到 SSTable;执行 SELECT 时,读取流程会逐层过滤,先用布隆过滤器排除不可能的文件,再用索引块精确定位。这套机制让 TiKV 在混合读写压力下依然保持稳定延迟。

物联网时序数据的写入管道

阶段:数据接入层架构设计

解决的问题:物联网传感器每秒产生海量带时间戳的写入请求,时序数据库必须吸收突发写入,同时又不能把点查做得太慢。LSM 树天然适配“写入密集、近期数据读多”的时序场景。

具体案例:InfluxDB 的 TSM 存储引擎采用类 LSM 的架构:数据先缓存在内存缓冲区(类似 MemTable),攒够一批再刷成类似 SSTable 的有序文件,后台定期合并成更大的文件。布隆过滤器(这里是 Sketch)同样用于跳过不存在的分片文件。当你查询某个传感器最近一小时的数据时,引擎只需读取时间戳索引定位到的少数几个文件。

Python 演示

python84 行
import hashlibimport struct  class BloomFilter:    def __init__(self, n, bits_per_key=10):        self.bits = n * bits_per_key        self.bitmap = bytearray((self.bits + 7) // 8)        self.k = max(1, int(bits_per_key * 0.7))     def _hashes(self, key):        h1 = int(hashlib.md5(key.encode()).hexdigest()[:8], 16)        h2 = int(hashlib.sha1(key.encode()).hexdigest()[:8], 16)        return [(h1 + i * h2) % self.bits for i in range(self.k)]     def add(self, key):        for pos in self._hashes(key):            self.bitmap[pos // 8] |= 1 << (pos % 8)     def contain(self, key):        return all(self.bitmap[pos // 8] & (1 << (pos % 8))                   for pos in self._hashes(key))  def pack(k, v):    kb, vb = k.encode(), v.encode()    return struct.pack('I', len(kb)) + kb + struct.pack('I', len(vb)) + vb  class MiniSSTable:    BLOCK = 3     def __init__(self, pairs):        self.data = sorted(pairs)        self.blocks = []        self.index = []        self.bloom = BloomFilter(len(self.data))        self.build()     def build(self):        offset = 0        for i in range(0, len(self.data), self.BLOCK):            chunk = self.data[i:i + self.BLOCK]            raw = b''.join(pack(k, v) for k, v in chunk)            self.blocks.append(raw)            self.index.append((chunk[-1][0], offset, len(raw)))            offset += len(raw)            for k, _ in chunk:                self.bloom.add(k)        self.footer = struct.pack('QQ', 0, len(self.index))     def get(self, key):        if not self.bloom.contain(key):            return None        lo, hi = 0, len(self.index)        while lo < hi:            mid = (lo + hi) // 2            if self.index[mid][0] < key:                lo = mid + 1            else:                hi = mid        if lo == len(self.index):            return None        pos = 0        raw = self.blocks[lo]        while pos < len(raw):            klen = struct.unpack('I', raw[pos:pos + 4])[0]            pos += 4            k = raw[pos:pos + klen].decode()            pos += klen            vlen = struct.unpack('I', raw[pos:pos + 4])[0]            pos += 4            v = raw[pos:pos + vlen].decode()            pos += vlen            if k == key:                return v        return None  if __name__ == '__main__':    sst = MiniSSTable([('apple', '1'), ('banana', '2'), ('cherry', '3'),                       ('durian', '4'), ('mango', '5'), ('orange', '6')])    for key in ('apple', 'mango', 'pear'):        print(key, '->', sst.get(key))
目的:用不到百行代码实现一个微型 SSTable——包含布隆过滤器、数据块、索引块和二分查找,完整复刻 LSM 文件“布隆过滤 → 索引定位 → 块内扫描”的读路径。
  1. self.index.append((chunk[-1][0], offset, len(raw)))每构建完一个数据块,记录它的最后一个 key、文件偏移和块大小,这正是索引块的核心条目。
  2. if not self.bloom.contain(key): return None查 key 前先问布隆过滤器,说“不存在”直接返回,省去一切磁盘读取。
  3. self.bitmap[pos // 8] |= 1 << (pos % 8)把多个哈希函数算出的位全部置 1,这是布隆过滤器写入的关键操作。
  4. raw = b''.join(pack(k, v) for k, v in chunk)把一个块内的所有 entry 按“长度+key+长度+value”格式打包成字节串,模拟数据块的物理编码。
  5. self.data = sorted(pairs)构建前先对全部 key-value 排序,保证 SSTable 有序不变性的根基。
  6. while pos < len(raw):从数据块起始处逐条解码 entry,比较 key 是否命中,这是块内扫描的微型实现。
  7. for key in ('apple', 'mango', 'pear'):验证读路径:apple 与 mango 能查到,pear 不存在但可能通过布隆过滤器,最终在数据块中确认无此记录。

常见误区

误区“SSTable 是 LSM 树在内存里存 key-value 的哈希表吧?我想当然地觉得它跟 Redis 的字典差不多。”

正解SSTable 的 S 是 Sorted String Table,它是一种只读的磁盘文件格式,文件内 key 有序排列。内存中真正承担写入的是 MemTable(通常用跳表实现),SSTable 是 MemTable 攒满后刷盘生成的产物。一旦生成,SSTable 就不可修改,只能被 Compaction 合并重写。

为何常见:名字里带"Table",又常与 LSM 树整体绑定讨论,初学者容易把它和内存里的表结构混为一谈。

误区“LSM 树的各层 SSTable 都严格按 key 范围不重叠,查任何一层都只要看一个文件。”

正解只有 L1 及以下各层通过 Compaction 保证 key 范围不重叠;L0 是 MemTable 直接刷盘生成的,文件之间可能重叠。因此查询 L0 时不得不检查该层的多个候选文件,这也是 LSM 读放大在 L0 层最严重的根源。

为何常见:讲解 LSM 时为了强调优点,常说“高层有序不重叠”,把 L0 这个特殊层忽略掉了。

误区“布隆过滤器返回 true,说明 key 一定在 SSTable 里,可以放心去读。”

正解布隆过滤器是“否定专家”——它说 false,key 一定不存在;它说 true,只代表“可能存在”,有误判概率。因为不同 key 的哈希可能点亮同一位,出现假阳性很正常。所以布隆过滤器只能用来跳过不存在的文件,命中后仍要进索引块和数据块精确确认。

为何常见:“过滤器”这个名称让人以为它是筛选出真数据的工具,忽略了它本质上只负责排除假数据。

误区“SSTable 的前缀压缩就是把整个数据块用 zlib 或者 Snappy 压一遍,减少磁盘占用。”

正解前缀压缩是 SSTable 特有的差分编码:相邻 key 共享前缀只存差异后缀,每个 restart 点重置。RocksDB 确实可以在块级再套 Snappy 等通用压缩,但那是另一层操作。前缀压缩是无损的、专门针对有序 key 的优化,与通用压缩算法不是一回事。

为何常见:“压缩”一词太宽泛,加上 RocksDB 同时开启块压缩,新手很容易把两个概念合并。

误区“LSM 树是顺序写友好架构,所以不会有写放大问题,磁盘磨损自然也很小。”

正解顺序写消除了“随机写”的磁道寻道开销,但 Compaction 会反复读取旧文件、归并、重写新文件,产生可观的写放大。LevelDB 的典型写放大在 10~30 倍,这也是 SSD 寿命消耗的重要来源。没有写放大的是纯追加型日志,不是 LSM。

为何常见:宣传材料常说“LSM 顺序写性能好”,把“随机写变顺序写”误推广为“零额外写入”。

面试题

Q1. 关于 SSTable 中数据的排列方式,下列说法正确的是?

单选题 · easy

  • A.按写入时间先后排列
  • B.按 key 的字典序排列← 正确答案
  • C.按 value 长度升序排列
  • D.按哈希值随机均匀排列

答案:B。SSTable 全称 Sorted String Table,核心特征就是 key 严格有序。写入时先在内存 MemTable 排好序再刷盘成文件,按时间排列会导致范围查询失效,按哈希排列则无法二分定位。

Q2. SSTable 文件中的 Footer 位于文件的什么位置?

单选题 · easy

  • A.文件开头前四十八字节
  • B.与各个数据块交错分布
  • C.文件末尾的固定区域← 正确答案
  • D.文件头部的独立区域

答案:C。SSTable 采用“从尾读起”的设计,Footer 固定在文件末尾,记录 Meta Index 和 Index Block 的偏移与大小。打开文件时先读尾部这片固定区域,就能拿到整册书的“地图”,再按图索骥。

Q3. 关于布隆过滤器查询结果,以下说法正确的有?

多选题 · easy

  • A.返回不存在则一定不存在← 正确答案
  • B.返回不存在也可能实际存在
  • C.返回存在时可能实际不存在← 正确答案
  • D.返回存在就是一定真实存在

答案:AC。布隆过滤器零漏报、有误报。只要有一个哈希位为 0,就说明该 key 从未插入,A 对;全部位为 1 时只是“可能存在”,C 对。B 和 D 恰好说反了,是初学者最容易踩的坑。

Q4. 数据块前缀压缩中,restart 点处记录的 key 有什么特点?

单选题 · mid

  • A.与上一条 key 共享全部前缀
  • B.shared_len 为 0 并写完整 key← 正确答案
  • C.只记录 key 的哈希值
  • D.与上一条 key 完全相同

答案:B。restart(重启点)每隔若干条出现一次,它把 shared_len 置为 0,重新写完整 key,作为块内二分查找的锚点。若共享全部前缀则没有区分度,也不能作为查找起点。

Q5. 一次点查定位候选数据块时,首先使用 SSTable 的哪个部分?

单选题 · mid

  • A.用布隆过滤器直接定位块
  • B.用索引块二分查找定位← 正确答案
  • C.用 Meta Index 遍历定位
  • D.用 Footer 逐项扫描定位

答案:B。布隆过滤器只负责“过滤不存在”,并不是定位器;真正的定位交给 Index Block。索引块记录每个数据块的末 key、偏移和大小,二分找到候选块后读入该块内部再查。Footer 只在打开文件时读一次。

Q6. 关于 LSM 树的层级关系,下列说法正确的有?

多选题 · mid

  • A.L0 由刷盘生成且文件间有重叠← 正确答案
  • B.L1 及以下各层内部不重叠← 正确答案
  • C.每层容量按固定倍数递增← 正确答案
  • D.所有层都必须一次查多个文件

答案:ABC。L0 是 MemTable 直接落盘,不经过合并,文件间可以重叠;L1 以下通过 Compaction 保证不重叠;容量通常按 T、10T、100T 增长。只有 L0 需要查多个文件,高层每层最多一个候选文件,D 错。

Q7. 位数组长度 m、元素数 n、哈希函数 k 时,最优 k 的近似值为?

单选题 · mid

  • A.m 与 n 的乘积
  • B.m 除以 n 再乘 ln2← 正确答案
  • C.n 除以 m 再乘 ln2
  • D.恒定为八个哈希函数

答案:B。最优哈希函数数 k_opt = (m/n)·ln2。比如每个元素分配 10 bit 时约用 7 个哈希。C 选项分子分母写反了,A 是量纲错误,D 是工程经验值而非理论最优。

Q8. 前缀压缩字段 shared_bytes 表示什么含义?

单选题 · hard

  • A.与块内第一条 key 的公共前缀长度
  • B.与上一条 key 的公共前缀长度← 正确答案
  • C.与下一个 key 的公共前缀长度
  • D.与索引块首个 key 的公共前缀长度

答案:B。shared_bytes 是当前 key 相对“上一条已编码 key”的公共前缀字节数。restart 点处 shared_bytes 为 0,写完整 key。它是按顺序局部差分,与块内第一条 key 或索引块无关,理解这一点才能读懂数据块布局。

Q9. 一条写入 (k,v) 在 LSM 引擎中的完整路径包括?

多选题 · hard

  • A.先追加写 WAL 日志← 正确答案
  • B.更新内存中的 MemTable← 正确答案
  • C.原地修改已落盘的 SSTable
  • D.MemTable 满后刷盘成新 SSTable← 正确答案

答案:ABD。写路径是 WAL → MemTable → Immutable → 刷盘生成新 SSTable。SSTable 一旦生成不可变,绝不原地修改已有数据块,覆盖靠新文件在更高层压过旧版本,所以 C 错。

Q10. 某 SSTable 有 N 个数据块,每块平均 K 条记录,块内二分查找,一次点查的时间复杂度约为?

单选题 · extreme

  • A.O(N + K) 需线性扫描两级
  • B.O(N × K) 需全文件扫描
  • C.O(logN + logK) 两级二分← 正确答案
  • D.O(log(N × K)) 整文件二分

答案:C。先在索引块二分定位数据块,耗时 O(logN);再在数据块内二分定位 entry,耗时 O(logK),总复杂度 O(logN + logK)。SSTable 的数据块之间物理不连续,不能把整文件当成一个数组做一次二分,D 错。