想象你是一家快递仓库的主管。每天涌进数万件包裹,传统做法是:每来一件,立刻把它插到货架的正确位置,保持货架随时按编号有序。这意味着你得不停挪动整排已放好的包裹,位置越接近满,挪得越多——所有工人都耗在“腾位置”上,包裹堆积如山。这时候,有位老师傅提出新方案:前台放一张登记台,新包裹先堆上去、随手排个序;登记台满了,就把整批按编号整理好,封进大箱子,整箱摞进仓库。新箱子永远只放在最上层,工人再也不用挪动旧货。每隔一段时间,整理工把几个小箱子合并成一个大箱子,顺手清掉过期和重复的包裹。这就是 LSM 树(Log-Structured Merge Tree,日志结构合并树)的朴素智慧——用“先记账、后整理”的方式,把昂贵的随机写变成廉价的顺序写。

岗位关联

理解 LSM 树,你就掌握了 LevelDB、RocksDB 等现代 KV 存储引擎的命脉——这是设计写入密集型系统、优化 AI 数据管道底座的必备内功。

目录
  1. 可视化演示
  2. 写入瓶颈背景
  3. MemTable缓冲
  4. SSTable落盘
  5. Compaction合并
  6. 读放大优化
  7. 删除与更新
  8. 开源应用
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示LSM树从MemTable内存写入、SSTable刷盘到Compaction层级合并的全流程,通过模式切换理解随机写转顺序写的核心机制。

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

MemTable内存写入 WAL日志+内存表双写保障持久性 跳表结构支持有序写入 随机写转内存写实现高吞吐

写入瓶颈背景

想象一座藏书满到溢出的图书馆。管理员要求每来一本新书,必须立刻按编号插回书架的正确位置。可书架几乎满了,插一本新书意味着把后面一整排书全部往外挪一格,几百本书跟着移动,手臂酸、耗时长。数据库中的 B+ 树就像这座图书馆:叶子节点是书架,插入一条记录要定位叶子,节点满了还要分裂、向上调整父节点指针。在磁盘上,这种工作方式对应着随机写——磁头像搬运工一样反复寻道,每挪一次要花费数毫秒甚至十几毫秒。

这里的关键差距在于:机械硬盘的顺序写带宽可达每秒上百 MB,而随机写每秒只能完成几百次小 I/O。假设一次随机写要 10ms 寻道,1 万次随机写就要 100 秒;但如果把 1 万条小数据合并成一次顺序写,只需几十毫秒。两者差距可达一两个数量级。存储引擎的设计者们意识到:谁能把随机写凑成顺序写,谁就赢了。

随机写耗时≈T寻道+数据量带宽≫顺序写耗时≈数据量带宽\text{随机写耗时} \approx T_{\text{寻道}} + \frac{\text{数据量}}{\text{带宽}} \quad \gg \quad \text{顺序写耗时} \approx \frac{\text{数据量}}{\text{带宽}}

1. 随机写耗时:一次随机写入磁盘的总开销 = 磁头寻道时间 + 旋转等待 + 实际传输数据的时间。

T寻道+数据量带宽T_{\text{寻道}} + \frac{\text{数据量}}{\text{带宽}}

2. 远大于符号:\gg 表示左边远大于右边,强调随机写比顺序写慢得多。

≫\gg

3. 顺序写耗时:数据连续摆放,磁头几乎不用移动,耗时主要就是传输时间。

数据量带宽\frac{\text{数据量}}{\text{带宽}}

所以 LSM 树的发明者换了个思路:既然每次插书都要挪书架,那不如把新书全部堆在门口(内存),攒够一批后按编号排好,一次性推进仓库(磁盘)。这种“先记账、后整理”的模式,把数据库从“每步都严格排好”的教条主义,解放成了“分批入库、事后整理”的实用主义。

MemTable缓冲

新包裹到达快递仓库后,并不直接上货架,而是先放到前台登记台。工人们只需要把包裹往登记台上一放,随手按编号排个序——这个动作在内存里完成,快如闪电。这个登记台就是 LSM 树的 MemTable(内存表)。它通常用跳表(Skip List)实现:一种多层级链表,可以在内存中高效地保持 Key 有序,插入和查找的期望复杂度都是 \\(O(\log n)\\),比数组插入动辄移动整个区间要灵活得多。

你可能会问:数据只放内存,断电不就全丢了吗?仓库的解决办法是:每来一件包裹,先在签到簿上记一笔(WAL,Write-Ahead Log,预写日志),再放登记台。这本签到簿按顺序追加写在磁盘上,写着“编号 X 的包裹已签收”。哪怕内存断电蒸发,重启后照着签到簿就能把包裹重新唤回。WAL 是顺序追加写,速度极快,它用极小的代价换来了不丢数据的保证。

CostMemTable 操作=O(log⁡n)\text{Cost}_{\text{MemTable 操作}} = O(\log n)

1. 操作代价:Cost 表示一次插入或查询需要的比较/移动次数。

CostMemTable 操作\text{Cost}_{\text{MemTable 操作}}

2. 大 O 记号:O 用于描述算法耗时的增长趋势,忽略常数项。

OO

3. 对数复杂度:\log n 表示耗时与数据量的对数成正比。n 是内存中数据条数。

log⁡n\log n

有了登记台和签到簿,写入流程变成:先写 WAL(顺序写磁盘)→ 再写 MemTable(内存操作)→ 返回成功。两次都是廉价操作,所以单条写入的延迟极低,而且多条写入可以在内存里自然“攒批”——这为后面整批刷盘创造了条件。登记台看似只是临时堆放,实则是整个 LSM 快写体系的发动机。

SSTable落盘

登记台再快也有容量上限。当包裹堆到一定数量,仓库就会启动“封箱流程”:把登记台上的所有包裹按编号彻底排好序,封进一个标注好编号范围的包裹箱,然后让工人扛着这箱包裹,放到仓库的指定货区。这个动作在存储引擎里叫 flush(刷盘):先把 MemTable 冻结为不可变(Immutable)状态,不再接收新写入,然后由后台线程把它整体写成一个 SSTable 文件。

SSTable 全称 Sorted String Table(有序字符串表),它最关键的特性是内部按键有序、生成后不可修改。正因如此,刷盘过程可以一路顺序追加写磁盘,磁头从头走到尾,没有丝毫回头——这就是“新箱子永远只往最上层摞”的磁盘版本。新的写入请求会立刻使用新的 MemTable,旧表变成文件后不再变化,读写互不干扰。

第 k 层容量 Sk=S0×Rk\text{第 } k \text{ 层容量 } S_k = S_0 \times R^k

1. S_k:第 k 层可容纳的数据总量,例如 L1 层的总容量。

SkS_k

2. S_0:第 0 层(最浅层)的基础容量,由单次刷盘的 MemTable 大小决定。

S0S_0

3. 容量放大因子 R:每向下移一层,容量放大 R 倍,通常取 10 左右。

RR

4. k 次幂:S_0 乘以 R 的 k 次方——层数越深,容量指数级膨胀。

RkR^k

于是磁盘上的数据呈现出层次感:L0 层零星摆着刚刷下来的小箱子,L1、L2 层则是整理过的中箱和大箱。分层让“新数据永远在最上面、旧数据沉在下面”成为一种物理秩序,而这套秩序正是 Compaction 表演的舞台。

Compaction合并

仓库开久了,问题来了:相同编号的包裹可能散落在好几个箱子里,有的箱子贴着作废标签,有的内容重复。查找越来越费劲,空间越来越浪费。于是仓库安排了一位整理工,定期把几个小箱子倒出来,按编号重新归并为一个大箱子——这个过程就是 Compaction(压实/合并)。

Compaction 的触发时机通常看阈值:比如 L0 层的箱子数量超过 4 个,就触发一次合并;合并结果推入 L1,若 L1 又超过容量上限,就继续向 L2 合并。合并算法本质是多路归并:同时打开多个 SSTable,像流水线一样比较各个文件的当前 Key,挑最小的输出到新文件,遇到重复 Key 保留最新版本。这个“新版本覆盖旧版本”的规则,让更新操作也能延迟到合并时才落地。

Write Amplification(写放大)=实际写入磁盘的数据量真实新增的数据量\text{Write Amplification(写放大)} = \frac{\text{实际写入磁盘的数据量}}{\text{真实新增的数据量}}

1. 写放大:合并在后台重写数据文件,导致实际落盘量超过净新增量,这个倍数就是写放大。

Write Amplification\text{Write Amplification}

2. 分子:包括新写入数据和所有合并重写的老数据——磁盘实际消耗的总 I/O。

实际写入磁盘的数据量\text{实际写入磁盘的数据量}

3. 分母:用户真正提交的新数据总量。

真实新增的数据量\text{真实新增的数据量}

整理工的付出是值得的:合并后箱子更少、更大,查找时可以跳过更多文件,存储空间也得到回收。但代价是额外的磁盘 I/O——写放大。LSM 树的设计本质是在写放大、读放大、空间放大三者之间拧螺丝:层数多一层,读更慢;合并勤一点,写放大更高;合并懒一点,空间占用上涨。优秀的存储引擎正是在这三把刀之间走钢丝。

读放大优化

在仓库里找一件包裹,直觉做法是从最新的箱子往前翻——因为新版本总比旧版本新。但 LSM 的层数一多,“从最新到最旧倒着翻”就变成了噩梦:最坏情况下要把每一层的每一个箱子的封面都拆开看一眼,这就是读放大(Read Amplification)。读放大的规模 = 层数 × 每层平均箱子数。相比 B+ 树从根走到叶子的 3~4 次 I/O,LSM 的读放大可以轻松到几十倍。

仓库的解决办法是:给每个箱子贴一张快查贴纸(布隆过滤器,Bloom Filter)。贴纸的原理很巧妙——数据写入箱子时,用 k 个哈希函数把 Key 映射到位数组的 k 个位置上,全部涂黑;查询时同样算 k 个位置,只要有一个没被涂黑,就说明“这个 Key 肯定不在箱子里”。它允许偶尔误判(说“在”其实不在),但绝不漏判(说“不在”就一定不在)。

P误判≈(1−e−knm)kP_{\text{误判}} \approx \left(1 - e^{-\frac{kn}{m}}\right)^k

1. 误判率 P:布隆过滤器“误报存在”的概率,记作 P。

P误判P_{\text{误判}}

2. 参数 k、n、m:k 个哈希函数;n 个已插入的 Key;m 个 bit 的位数组。

k,n,mk, n, m

3. \frac{kn}{m}:每个 bit 被命中的平均次数,决定了某一位为 0 的概率。

knm\frac{kn}{m}

4. k 次方:k 个哈希位置全部冲突的概率——这正是误判的来源。

(1−e−knm)k\left(1 - e^{-\frac{kn}{m}}\right)^k

贴上快查贴纸后,查询一个 Key:先扫 L0 最上面几个箱子的贴纸,说“不在”就跳过;说“在”才真正打开箱子找。绝大多数不存在的 Key 会被一张贴纸拦住,只需一次内存位运算,完全不用碰磁盘。配合块缓存(Block Cache)把热数据留在内存里,LSM 的读性能虽然还是不如 B+ 树,但已经足够应对绝大多数业务场景——毕竟它换来了惊人的写吞吐。

删除与更新

快递单填错了怎么办?你没法拆开已经封好的箱子把包裹拿出来——箱子是封死的。快递仓库的做法是:在箱子上贴一张醒目的作废标签,注明“编号 X 作废”,照常把箱子放进仓库。客户来取件时,看到作废标签,就回复“这个包裹没了”。在 LSM 树里,这张作废标签叫墓碑(Tombstone):删除操作并不去磁盘删数据,而是写入一条特殊标记 key -> tombstone。

这种设计看似笨拙,实则是大智慧。更新也一样:新版本直接写入 MemTable,旧版本留在地下层躺着。真正的物理删除发生在Compaction 整理时——当合并程序遇到墓碑标记,就知道该 Key 的旧版本可以全部丢弃;当遇到同一个 Key 的多个版本,只保留最新一个。于是,被“作废”的数据像落叶一样在一次次合并中被扫出仓库,空间得以回收。

Tombstone+Keyold→Compaction∅\text{Tombstone} + \text{Key}_{\text{old}} \xrightarrow{\text{Compaction}} \varnothing

1. Tombstone(墓碑):删除时写入的特殊占位标记,表示该 Key 已逻辑删除。

Tombstone\text{Tombstone}

2. Key 的旧版本:历史上写入的旧数据,一直静静躺在深层的 SSTable 里。

Keyold\text{Key}_{\text{old}}

3. 合并箭头:只有当 Compaction 扫描到这两者时,清理动作才会发生。

→Compaction\xrightarrow{\text{Compaction}}

4. 空集符号:表示该 Key 的全部痕迹被彻底清除,物理空间被回收。

∅\varnothing

有趣的是,墓碑本身也有副作用:如果删除一个不存在的 Key,墓碑会一直存活到合并被清除,占用空间。好在工程上可以给墓碑加时间戳,到期自动丢弃。这套“延迟删除”机制让 LSM 在写密集场景下如鱼得水——删除不再是一次昂贵的磁盘操作,而是一条便宜的追加记录,完美绕开了传统 B+ 树“删除要改写文件”的痛点。

开源应用

这套仓库管理术一经问世,立刻被各家“大仓库”采用。Google 的 LevelDB 是教科书级实现,用 C++ 写就;Meta 在它基础上强化了并发与性能,推出 RocksDB,成为业界最流行的嵌入式 KV 存储引擎之一。为什么 AI 和分布式系统如此偏爱它?因为它们的写请求太疯狂了——点击日志、监控指标、特征数据,每秒几十万次写入,传统 B+ 树根本扛不住。

分布式数据库是 LSM 的最大受益者。TiKV(TiDB 的存储层)用 RocksDB 作为单节点引擎,上层做分布式事务,底层靠 LSM 的高写入吞吐支撑 OLTP 场景;HBase 的 HFile 同样源自 LSM 思想;Cassandra 更是将 LSM 与分布式哈希天然结合。可以说,现代“写多读少”的互联网基础设施,绕不开 LSM 的影子。

总代价=α×写放大+β×读放大+γ×空间放大\text{总代价} = \alpha \times \text{写放大} + \beta \times \text{读放大} + \gamma \times \text{空间放大}

1. 总代价:存储引擎在运行中的综合开销,是调优要最小化的目标函数。

总代价\text{总代价}

2. 三个放大项:写放大、读放大、空间放大,此消彼长。

写放大+读放大+空间放大\text{写放大} + \text{读放大} + \text{空间放大}

3. 权重系数:\alpha、\beta、\gamma 表示业务对三者的敏感度——写多场景让 \alpha 大,读多场景让 \beta 大。

α,β,γ\alpha, \beta, \gamma

今天的 LSM 早已不是单纯的数据结构,而是一套成熟的工程哲学。当你面临“每秒百万级写入、数据不丢、还能按序扫出来”的需求时,选择 RocksDB 或 TiKV 这类 LSM 系存储,就是在聘请那位经验丰富的仓库整理工——它或许不算完美的读书人,但绝对是顶级的搬货工人。

AI 应用场景

实时推荐特征平台

阶段:特征工程与特征存取

解决的问题:推荐系统需要实时记录用户行为(点击、点赞、浏览),作为模型推理的特征输入。传统 B+ 树存储每秒仅能承受数千次随机写,面对百万级日活用户的实时行为流会瞬间崩溃;LSM 树通过内存缓冲 + 顺序刷盘,把随机写转为顺序写,轻松支撑每秒数十万次的特征写入。

具体案例:某电商平台的推荐特征平台使用 RocksDB 作为本地状态存储。用户点击商品后,行为特征以 <user_id, item_id, timestamp> 的 Key 写入 LSM 引擎,配合布隆过滤器加速特征读取,特征更新的 P99 延迟低于 5ms。每次推荐请求从同一引擎批量读取用户最近 100 条行为特征,组成模型输入。

大模型训练日志与实验追踪

阶段:模型训练与实验管理

解决的问题:AI 训练平台每秒要记录大量 metrics(loss、accuracy、学习率)和参数快照,写入量大且伴随高频更新。LSM 的延迟删除与合并机制让旧版本指标自动归档,训练中断时还能通过 WAL 快速恢复实验状态。

具体案例:某 MLflow 部署使用基于 LSM 的存储后端保存实验记录。一次千卡分布式训练每秒产生 5 万条指标写入,LSM 引擎承接了全部写入压力;训练结束后,按 run_id + step 范围扫描出整条 loss 曲线用于可视化,得益于 SSTable 的有序性,范围查询高效完成。

Python 演示

python124 行
import hashlibimport os  class MemTable:    def __init__(self, limit=8):        self.data = {}        self.limit = limit     def put(self, key, value):        self.data[key] = value     def is_full(self):        return len(self.data) >= self.limit     def freeze(self):        items = sorted(self.data.items())        self.data.clear()        return items  class BloomFilter:    def __init__(self, size=256, k=3):        self.bits = [False] * size        self.size = size        self.k = k     def _hash(self, key, i):        raw = hashlib.md5("{}-{}".format(key, i).encode()).hexdigest()        return int(raw, 16) % self.size     def add(self, key):        for i in range(self.k):            self.bits[self._hash(key, i)] = True     def may_contain(self, key):        for i in range(self.k):            if not self.bits[self._hash(key, i)]:                return False        return True  class SSTable:    def __init__(self, path, items):        self.path = path        self.bloom = BloomFilter()        self.items = {}        for k, v in items:            self.items[k] = v            self.bloom.add(k)        with open(path, "w", encoding="utf-8") as f:            for k, v in items:                line = "{}\t{}\n".format(k, "TOMBSTONE" if v is None else v)                f.write(line)     def get(self, key):        if not self.bloom.may_contain(key):            return None, False        if key in self.items:            return self.items[key], True        return None, True  class LSM:    def __init__(self, root="lsm_demo", limit=8, levels=3):        self.root = root        self.mem = MemTable(limit)        self.levels = [[] for _ in range(levels)]        self.seq = 0        os.makedirs(root, exist_ok=True)     def put(self, key, value):        self.mem.put(key, value)        if self.mem.is_full():            self._flush()     def delete(self, key):        self.mem.put(key, None)     def _flush(self):        path = os.path.join(self.root, "sst_{}.sst".format(self.seq))        self.seq += 1        table = SSTable(path, self.mem.freeze())        self.levels[0].append(table)        if len(self.levels[0]) >= 3:            self._compact(0)     def _compact(self, level):        merged = {}        for table in self.levels[level]:            for k, v in table.items.items():                merged[k] = v        alive = [(k, v) for k, v in sorted(merged.items()) if v is not None]        target = level + 1 if level + 1 < len(self.levels) else level        for table in self.levels[level]:            os.remove(table.path)        self.levels[level] = []        path = os.path.join(self.root, "sst_{}.sst".format(self.seq))        self.seq += 1        new_table = SSTable(path, alive)        self.levels[target].append(new_table)        if len(self.levels[target]) >= 3 and target < len(self.levels) - 1:            self._compact(target)     def get(self, key):        if key in self.mem.data:            return self.mem.data[key]        for tables in self.levels:            for table in tables:                val, found = table.get(key)                if found:                    return val        return None  if __name__ == "__main__":    db = LSM()    for i in range(30):        db.put("user:{}".format(i), "value-{}".format(i))    print("查询 user:5 ->", db.get("user:5"))    db.delete("user:5")    print("删除后查询 user:5 ->", db.get("user:5"))    print("查询 user:6 ->", db.get("user:6"))    print("SSTable 文件数 ->", len(os.listdir("lsm_demo")))
目的:演示 LSM 树的完整工作链路——数据先进内存表,满了刷成有序 SSTable 文件;文件数超阈值触发 Compaction 合并导出墓碑;查询时靠布隆过滤器跳过无关文件,逐层倒查。
  1. items = sorted(self.data.items())冻结 MemTable 时先按键排序,保证刷盘出的 SSTable 内部有序。
  2. self.levels[0].append(table)新刷出的 SSTable 直接追加到 L0 层,磁盘写入天然顺序化。
  3. if len(self.levels[0]) >= 3:达到阈值立即触发 Compaction,控制 L0 文件数量,抑制读放大。
  4. merged[k] = v合并时用字典按键去重,后写入的版本自动覆盖旧版本,实现“最新版本胜出”。
  5. alive = [(k, v) for k, v in sorted(merged.items()) if v is not None]过滤掉墓碑(None 值),物理删除真正发生。
  6. if not self.bloom.may_contain(key):布隆过滤器说“没有”就直接跳过整个文件,省去一次磁盘读。
  7. db = LSM()一个迷你数据库实例,内部自包含内存表、分层文件和合并逻辑。

常见误区

误区我刚学了 LSM,感觉它又快又能抗大流量。既然写也快,读应该也不差,是不是可以把所有数据库都换成 LSM 实现?

正解LSM 只保证“写”和“范围扫描”有优势,随机点查反而慢。因为读的路径很长:先查内存表,再从 L0 倒查到底层,还有一层层布隆过滤器的判断。B+ 树的随机点查约 3~4 次 I/O 封顶,而 LSM 最坏情况要多查几个数量级的文件。它用读放大换写放大,适合写多读少,而非万能解药。就像快递仓库进件极快,但偶尔找一件压箱底的包裹要翻很久。

为何常见:初学者被“高吞吐写入”的宣传吸引,忽略了 LSM 读放大的天然代价,误以为它是全面超越的银弹。

误区Compaction 要重写大量数据文件,肯定会导致数据库卡顿很久,线上根本不敢开。

正解现代 LSM 引擎的 Compaction 是分层次、限流量、异步进行的。RocksDB 会把合并任务交给后台线程池,通过 rate_limiter 平滑合并速度;L0 到 L1 的合并只涉及少部分文件,单次停顿通常控制在毫秒级。只有在极端配置(如合并速度完全跟不上写入速度)时才可能出现显著抖动。整理工可以趁夜间加班,而不是让仓库白天空转。

为何常见:看到“大文件合并”就想到全量重写,忽略了工程实现中分片、限流、后台调度等大量优化手段。

误区如果布隆过滤器的位数组太小,或者哈希冲突厉害,真正存在的 Key 会不会被它说成“不存在”,导致读取丢失数据?

正解方向反了。布隆过滤器只允许假阳性(把不存在的说成存在),绝对不允许假阴性(把存在的说成不存在)。因为数据写入时会把对应位全部置 1,判断存在时这些位必然全是 1;只有“某些位没被全部置 1”才会判不存在,而这种情况只会发生在该 Key 从未插入时。它就像一个严谨的门卫:说“人不在”就一定不在,说“人可能在”才会进房间确认。

为何常见:容易把“宁可错杀一千”和“宁纵勿枉”搞反,对哈希冲突的方向缺乏直观理解。

误区我在 LSM 数据库里执行 `DELETE`,磁盘空间应该马上就释放了吧?删除很重才对吧?

正解LSM 的删除只是追加写入一条墓碑标记,旧数据仍然躺在 SSTable 里,物理空间并未立刻释放。要等后续某次 Compaction 扫描到这个墓碑时,旧数据才被真正清除。这意味着删除操作本身极快(也是一次顺序写),但磁盘空间回收有延迟。如果删了大量数据后立刻看磁盘占用,可能惊讶地发现空间没变——这不是没删掉,而是整理工还没上班。

为何常见:传统数据库的删除是原地标记或立刻改写索引页,人们自然会把“删除=立即释放”带到 LSM 的语境里。

面试题

Q1. LSM 树实现高写入吞吐最根本的手段是什么?

单选题 · easy

  • A.使用更快的固态硬盘
  • B.将随机写转化为顺序写← 正确答案
  • C.减少数据落盘的次数
  • D.使用更小的数据文件

答案:B。LSM 的核心哲学是把昂贵的随机写累积在内存表中,再以顺序追加的方式刷盘成 SSTable。选项 A 是硬件手段,C 和 D 是局部优化,只有 B 点中了 LSM 的灵魂——就像快递仓库把“插货架”改成“整摞入库”。

Q2. MemTable 在内存中通常用什么数据结构保持 Key 有序?

单选题 · easy

  • A.哈希表
  • B.跳表← 正确答案
  • C.数组
  • D.B+ 树

答案:B。LevelDB 的 MemTable 使用跳表(Skip List),支持高效的插入和有序遍历,期望复杂度为 O(log n)。哈希表无法保持有序,数组插入要移动元素,B+ 树主要用于磁盘索引而非内存临时缓冲。

Q3. WAL 预写日志的主要作用是什么?

单选题 · easy

  • A.减少磁盘空间占用
  • B.加速数据查询
  • C.防止内存数据丢失后的崩溃恢复← 正确答案
  • D.替代布隆过滤器

答案:C。WAL 以顺序追加方式记录每次写入,数据还在内存 MemTable 时即已落盘。若进程崩溃,内存表内容丢失,重启后可重放 WAL 恢复数据。它本质是快递仓库的签到簿,与读加速无关。

Q4. 关于 Compaction,下列说法正确的有哪些?

多选题 · mid

  • A.会带来写放大← 正确答案
  • B.可以清理被删除的数据← 正确答案
  • C.一定会导致服务长时间不可用
  • D.可能把 L0 的多个文件合并到 L1← 正确答案

答案:ABD。Compaction 重写文件产生写放大(A 对);合并时遇到墓碑会物理清理删除数据(B 对);L0 文件超阈值会合并下沉到 L1(D 对)。现代引擎通过后台限流使停顿通常在毫秒级,C 的“长时间不可用”过于绝对。

Q5. 布隆过滤器判定“某个 Key 不存在”时,真实情况可能是?

单选题 · mid

  • A.Key 一定不存在← 正确答案
  • B.Key 一定存在
  • C.可能不存在也可能存在
  • D.无法判断

答案:A。布隆过滤器没有假阴性,只要它说“不存在”,就说明至少有一个哈希位未被置 1,而该 Key 若存在则这些位必然全为 1,所以 Key 一定不存在。它唯一可能出错的方向是“存在”被误判为可能存在。

Q6. LSM 树的删除操作实际做的是什么?

单选题 · mid

  • A.立即从磁盘物理删除数据块
  • B.写入一条墓碑标记← 正确答案
  • C.重建整个 SSTable
  • D.直接修改原文件

答案:B。SSTable 不可修改,删除不能原地执行。LSM 采用延迟删除:写入一条特殊的墓碑标记(tombstone),逻辑上屏蔽旧数据,物理清理留给后续 Compaction。这和快递箱贴作废标签如出一辙。

Q7. L0 层与其他层的最大区别是什么?

单选题 · mid

  • A.L0 存放最旧的数据
  • B.L0 的 SSTable 之间 Key 范围可能有重叠← 正确答案
  • C.L0 只能存放一个文件
  • D.L0 不需要布隆过滤器

答案:B。L0 层由内存表直接刷盘而来,各文件之间没有进行过全局排序,Key 范围相互重叠,因此读取时可能需要查多个文件。从 L1 开始,每层内部文件互不重叠,有序性逐步增强。

Q8. 下列哪些数据库或系统使用或借鉴了 LSM 树?

多选题 · hard

  • A.RocksDB← 正确答案
  • B.LevelDB← 正确答案
  • C.TiKV← 正确答案
  • D.SQLite 默认的 B+ 树索引引擎

答案:ABC。RocksDB 是 Meta 基于 LevelDB 改进的 LSM 引擎,LevelDB 是 Google 的 LSM 经典实现,TiKV 用 RocksDB 作为底层单机存储,都是 LSM 家族成员。SQLite 默认使用 B+ 树,不属于 LSM 结构。

Q9. 若 LSM 分层模型每层容量放大因子为 R,共 k+1 层(L0 到 Lk),则第 k 层容量为?

单选题 · hard

  • A.S0 + k × R
  • B.S0 × R^k← 正确答案
  • C.S0 ÷ R^k
  • D.S0 + R^k

答案:B。分层容量按几何级数增长:第 0 层为 S0,第 1 层约 S0 × R,第 2 层约 S0 × R²,依此类推。指数增长保证了越深的地方能容纳越多数据,从而让大部分数据向更深层“沉淀”,减少顶层文件数量。

Q10. 以下哪些手段可以有效降低 LSM 树的读放大?

多选题 · extreme

  • A.使用布隆过滤器← 正确答案
  • B.使用块缓存(Block Cache)← 正确答案
  • C.增加 SSTable 文件数量
  • D.增加 LSM 层级数

答案:AB。布隆过滤器在内存中直接跳过不含目标 Key 的文件,块缓存把热数据留在内存避免重复读盘,两者都直接削减读 I/O。增加文件数量和层级数反而会放大读取路径的长度,属于反向操作。