Skip to content

数据库内核复习大纲:索引、事务、恢复与分布式

约 4920 个字 1 张图片 预计阅读时间 25 分钟

使用方式

这篇文档不追求把所有知识点讲成教材,而是服务于数据库内核 / 存储引擎 / 分布式数据库面试复习。内容来源有三部分:

  • CMU 15-445 / bustub 的课程与项目知识框架。
  • OceanBase 这类工业级数据库常见设计取向:MVCC、日志恢复、LSM/Compaction、复制与主从恢复。
  • 公开面经里反复出现的提问方式:不是只问定义,而是顺着一个机制不断追问工程细节与边界条件。

建议按下面顺序复习:

  1. 先把“索引并发 + 事务隔离 + 日志恢复”串成主线。
  2. 再补“SSI/OCC、MVCC GC、DDL 与分布式复制”这些更偏设计的问题。
  3. 最后单独刷“Raft、主从恢复、新节点加入、P99 延迟预算”这类系统题。

结论先看

这类面试的核心不是背术语,而是能不能把下面这条链路讲顺:

存储结构 -> 并发控制 -> 可见性 -> 提交/恢复 -> 副本复制 -> 在线变更

如果你回答一个问题时只能停留在“定义”,通常下一问就会卡住;比较稳的答法应该总是包含四层:

  1. 这个机制解决什么问题。
  2. 它依赖哪些关键数据结构。
  3. 它在正常路径怎么工作。
  4. 它在冲突、崩溃、扩缩容时怎么兜底。

当前仓库内的回看路径

主题 优先回看文件 作用
bustub 整体框架、MVCC + OCC docs/blogs/posts/bustub通关指北.md 把项目实现和面试话术串起来
B+ 树基础 docs/notes/CMU15445/7-B+Tree.md 节点结构、分裂合并、查找路径
B+ 树并发 / 蟹行协议 docs/notes/CMU15445/8-Index Concurrency.md latch crabbing、乐观改进、叶子扫描并发
隔离级别 / OCC / 时间戳排序 docs/notes/CMU15445/15-Timestamp_Ordering_Concurrency.md OCC 适用场景、验证开销
MVCC / 快照 / GC docs/notes/CMU15445/16-Mult-Version_Concurrency.md 多版本可见性、watermark、GC
WAL / group commit / checkpoint docs/notes/CMU15445/17-Database_Logging.md Redo/WAL 协议、group commit
ARIES 恢复 docs/notes/CMU15445/18-Database_Recovery.md analysis/redo/undo、checkpoint
LSM / Compaction docs/notes/CMU15445/4-Database_Storage_II.md leveled / universal(tiered) 对比

高频问题总览

可以把你列出来的题分成六簇:

  1. 索引并发:B+ 树蟹行协议、锁和 latch 的区别、为什么从上到下加锁。
  2. 事务隔离:RC / RR / Serializable、乐观锁和悲观锁、为什么业界更常用悲观事务。
  3. 串行化实现:SSI 怎么做、前向/后向验证、为什么重点看读写冲突而不是写写冲突、读写集合怎么维护。
  4. 日志恢复:Redo / Undo / Checkpoint / 原子提交 / group commit / 崩溃恢复。
  5. 分布式复制:主从下 redo 怎么同步、log truncation、落后节点怎么追、快照怎么补齐。
  6. 在线变更与共识:create index / add column 怎么做、DDL 与 DML 并发、Raft 提交延迟、follower read、joint consensus。

下面按这个结构展开。

一、索引与并发控制

1. B+ 树蟹行协议是什么

一句话回答:

蟹行协议(latch crabbing / latch coupling)是 B+ 树并发访问时的一种逐层持锁策略:拿到子节点 latch 后,再根据子节点是否“安全”决定是否释放父节点 latch。

答题骨架:

  • 搜索通常拿 R-latch,从根走到叶,拿到子节点读锁后就可以释放父节点。
  • 插入 / 删除通常更保守,要判断子节点是否安全。
  • 安全 的常见含义:
  • 插入时子节点未满,不会 split。
  • 删除时子节点不至于低于最小占用,不会 merge / redistribution。
  • 一旦子节点安全,就可以尽早释放祖先节点,减少高层热点。
  • 如果不安全,就需要继续保留祖先节点 latch,保证结构修改期间路径稳定。

为什么重要:

  • 根节点和高层内节点竞争最激烈。
  • 提前释放父节点能显著提升并发。
  • 同时又避免了结构修改期间的悬空指针、路径失效和并发 split/merge 混乱。

常见追问:

  • locklatch 的区别?
  • lock 面向事务语义,保护逻辑数据一致性。
  • latch 面向内存临界区,保护页结构不被并发线程破坏。
  • 为什么释放顺序通常是自顶向下?
  • 越高层阻塞越强,优先释放高层 latch 更能减少系统阻塞。
  • 更好的优化是什么?
  • 15445 里常见“乐观下降到叶子附近,再在必要时回退重试”的 better latching algorithm。

本地回看:

  • docs/notes/CMU15445/8-Index Concurrency.md
  • docs/notes/CMU15445/7-B+Tree.md

2. 常见隔离级别怎么讲

建议答法:从异常出发,而不是死背名字。

隔离级别 典型能避免的问题 仍可能出现的问题
Read Uncommitted 几乎不保证 脏读、不可重复读、幻读
Read Committed 脏读 不可重复读、幻读
Repeatable Read 脏读、不可重复读 取决于实现,很多系统仍可能有幻读或写偏差
Serializable 目标是等价于某个串行顺序 代价最高

面试里要补的一句:

真正工程实现里,RRSerializable 不是一个标准答案,而是和实现绑定的。

  • MySQL/InnoDB 常通过 MVCC + next-key lock 处理很多幻读问题。
  • PostgreSQL 的 Serializable 不是严格 2PL,而是基于 SSI
  • bustub 项目语境里更接近 MVCC + OCC validation 去做可串行化。

3. 乐观锁和悲观锁的区别

建议从冲突假设、代价位置、失败方式三点回答:

  • 悲观锁:
  • 假设冲突常见,先加锁再访问。
  • 代价前置在执行阶段。
  • 等待多,死锁处理重要,但回滚成本通常较低。
  • 乐观锁 / OCC:
  • 假设冲突少,先执行,提交时验证。
  • 代价后置在验证阶段。
  • 不容易阻塞,但冲突时可能整事务回滚,长事务代价高。

什么时候悲观事务更好:

  • 热点更新多。
  • 写事务占比高。
  • 事务长、回滚成本高。
  • 业务不能接受频繁 abort/retry。

为什么业界悲观事务更多:

  • OLTP 热点冲突并不稀少。
  • 用户事务常含 RPC / 复杂逻辑,回滚重试代价高。
  • 悲观控制延迟更稳定,更容易向业务解释。
  • 乐观事务在长事务、高冲突、热点行上 abort 风暴明显。

二、LSM 与 Compaction

4. Leveled Compaction 和 Tiered Compaction 的区别

先给一句定义:

  • Leveled:除 L0 外,各层 key range 尽量不重叠,查找更稳,写放大更高。
  • Tiered:同一层允许多个重叠文件,等积累到一定规模再整体合并,写放大更低,读放大更高。

对比主线:

维度 Leveled Tiered
层内重叠 基本不重叠 允许重叠
写放大 更高 更低
读放大 更低 更高
空间放大 更低 往往更高
适合场景 读多写少、读延迟敏感 写多、吞吐优先

答题时最好补充:

  • RocksDB 里的 Universal Compaction 常被看作 tiered 思路。
  • 工业系统经常混合策略,不是非黑即白。
  • OceanBase 这类系统如果以 LSM 为底座,面试官真正想听的是你能不能把 compaction 选择和“写放大、读放大、空间放大、后台资源竞争”挂钩。

本地回看:

  • docs/notes/CMU15445/4-Database_Storage_II.md
  • docs/notes/minilsm/week1-day1.md

三、事务隔离、SSI 与读写集合

5. SSI 你怎么实现

一句话回答:

SSI(Serializable Snapshot Isolation)是在快照隔离基础上,额外跟踪事务间的读写依赖,发现可能形成危险结构时主动 abort,从而把 SI 提升到 Serializable。

注意:如果面试官前文一直在聊 bustub,这里最好主动澄清一下。bustub 项目更接近 MVCC + OCC validation 教学实现,不是 PostgreSQL 那种完整 SSI 工程实现;但二者都在解决“仅有快照可见性还不够,还要补串行化验证”这个问题。

答题骨架:

  1. 每个事务拿一个快照时间戳 read_ts
  2. 读时按 MVCC 规则找 <= read_ts 的可见版本。
  3. 维护读集 / 写集,或更细粒度的读写依赖元数据。
  4. 提交时检查和并发事务、已提交事务之间是否形成危险依赖边。
  5. 一旦检测到典型危险结构,回滚其中一个事务。

如果结合 bustub / OCC 语境来答:

  • 可以说成 MVCC 负责快照可见性,OCC/SSI 负责提交时串行化验证
  • 面试官更关心你如何维护依赖边,而不是你会不会背 PostgreSQL 术语。

6. 前向验证和后向验证区别

前向验证:

  • 当前事务提交时,检查自己是否会和未来还活着的事务冲突。
  • 关注“我提交后会不会伤到别人”。

后向验证:

  • 当前事务提交时,检查在我执行期间已经提交的事务是否和我冲突。
  • 关注“别人是否已经破坏了我的快照假设”。

工程里怎么答更稳:

  • 后向验证实现更常见,因为提交事务集合是稳定的。
  • 前向验证需要看活跃事务的读写状态,代价和同步复杂度更高。
  • 实现时常把两者结合成某种窗口化验证,而不是纯理论上的二选一。

7. 为什么 SSI 重点看读写冲突,不是写写冲突

关键点:

  • 在快照隔离下,写写冲突通常已经在更新或提交阶段被检测了,比如 first-writer-win。
  • 真正会漏掉串行化问题的是 rw 依赖导致的 write skew
  • SSI 的目标不是重复做 WW 检查,而是补上 SI 本来漏掉的危险结构。

一句标准话术:

写写冲突往往已经被底层并发控制或版本检查捕获;SSI 之所以有价值,是因为仅靠快照隔离无法识别跨记录、跨谓词的读写依赖环。

8. 读写集合怎么维护,怎么判断冲突

最基础版本:

  • 读集:记录读过的 RID、主键、范围谓词。
  • 写集:记录插入 / 更新 / 删除过的 RID 或 key range。
  • 验证时做交叉检查:
  • 我的读集和别人写集是否相交。
  • 我的写集和别人读集是否相交。
  • 谓词读还要检查 phantom。

更工程化一点的答法:

  • 点查可用 RID/key 级别集合。
  • 范围扫描必须额外记录谓词或索引范围。
  • 如果想把验证粒度降到“行级”而不是“事务级”,可以在行或索引项上维护最近读写事务信息。

9. 长事务验证太重,怎么优化

你给出的 Tmax 思路是对的,可以直接讲:

  • 维护一个可接受验证窗口,例如 [Tmax, GlobalTs]
  • 小于 Tmax 的过旧事务不再做全量精细验证,而是让长事务直接 abort 或强制重试。
  • 本质上是用吞吐换长事务公平性,避免验证集合无限膨胀。

还能补两个优化:

  • 以行 / 索引范围为单位维护冲突元数据,减少无关事务比较。
  • 对只读事务使用安全快照或只读优化,尽量不参与完整验证。

10. 如果在行和索引上记录读写事务,这个结构要持久化吗

建议答法:

一般不要求把“临时事务依赖状态”完整持久化,因为它属于运行时并发控制元数据,不是最终用户数据。

崩溃恢复后怎么处理,取决于恢复模型:

  • 如果没有 commit record,就视为事务未生效,恢复时不用保留它的运行时状态。
  • 如果有 redo + commit record,重放已提交事务即可恢复最终可见状态。
  • 运行时读写依赖边通常在重启后重建,不需要像数据页一样永久保存。

11. 快照是怎么实现的,可重复读怎么做到的

标准主线:

  • 每个事务开始时拿到 read_ts 或 snapshot。
  • 读记录时,从版本链里找到对该快照可见的最新版本。
  • 事务期间重复读同一条记录时,只要还是按同一 snapshot 判定,就能实现可重复读。

要主动补一句:

可重复读只解决“同一记录重复读取一致”,不自动等于可串行化;跨行约束和谓词读还可能出现 write skew / phantom,需要 SSI、谓词锁或 next-key lock 进一步兜底。

12. 多版本 GC 怎么实现

基础答法:

  • 维护 watermark,也就是当前最小活跃读时间戳。
  • 版本时间戳早于 watermark 且不再被任何活跃事务引用时,就可以回收。

追问:最老读事务一直不结束怎么办

  • GC 会被卡住,版本膨胀。
  • 工业系统通常会:
  • 给长事务超时。
  • 对历史版本设置保留窗口。
  • 强制终止过老事务或把它转到特殊慢路径。

四、Redo / Undo / 原子提交 / 恢复

13. Redo Log 怎么实现

建议答法:

  • 每次事务修改数据页前,先生成日志记录。
  • 日志里至少包含:事务 ID、页号、LSN、变更内容或逻辑操作。
  • 提交时必须先把 commit record 以及之前的相关 WAL 刷盘,再对外宣布成功,这就是 WAL 协议。

关键术语:

  • pageLSN
  • flushedLSN
  • WAL before data
  • group commit

14. Undo Log 怎么实现

两种答法都要会:

  • 在 ARIES 语境里:
  • Undo log / prevLSN 链用来回滚未完成事务。
  • abort 时按日志链逆向撤销,并写 CLR。
  • 在 bustub 的 MVCC 语境里:
  • Undo log 更像版本链的一部分,保存旧值或 delta,供快照读和回滚使用。
  • 表堆保存最新值,旧版本挂在 undo/version 链上。

如果面试官问“Redo 和 Undo 分工”

  • Redo 保证已提交事务不丢。
  • Undo 保证未提交事务不污染最终状态。

15. 事务原子提交怎么实现

一句话回答:

原子提交依赖“提交标记”的持久化边界,而不是依赖数据页在提交瞬间全部刷盘。

标准路径:

  1. 事务执行期间写 WAL。
  2. 提交时写 COMMIT 日志。
  3. 把该事务对应 WAL 至少刷到持久化介质。
  4. 刷盘成功后,才把事务状态从 RUNNING 改成 COMMITTED 并返回客户端成功。

为什么这能保证原子性:

  • 崩溃恢复时:
  • 有 commit record 的事务重做。
  • 没有 commit record 的事务撤销或忽略。

16. 怎么保证事务提交过程中的数据对新读者不可见

推荐答法:分“物理写入”和“逻辑可见”两层。

  • 写线程可以先把新版本写到数据页或私有工作区。
  • 但对新读者是否可见,取决于版本元数据 / commit_ts / txn state,而不是是否已经写进页里。
  • 只有当事务状态原子切换为 COMMITTED,且版本上的提交时间戳可见后,新事务才会读到它。

换句话说:

“写进内存”不等于“已提交可见”。可见性由事务状态和版本时间戳统一裁决。

17. 事务能并发提交吗,谁来推动提交

答案:

  • 事务当然可以并发提交,但通常需要一个日志子系统协调刷盘顺序。
  • 常见实现是多个事务并行进入提交队列,由 log manager / group commit 线程批量 flush。
  • 刷盘完成后,再统一唤醒对应事务返回成功。

面试加分点:

  • 并发提交不等于提交顺序任意,仍要保证 LSN 和可见性顺序满足恢复要求。
  • 很多系统的吞吐关键点就在 group commit,而不是单事务 fsync。

18. redo log 起点是什么,为什么要 checkpoint

标准答案:

  • redo 不会从时间 0 开始扫,而是从 checkpoint 相关位置开始。
  • checkpoint 记录了恢复所需的最早日志边界,例如脏页表里的最小 recLSN
  • 这样恢复时只重放可能尚未落盘的数据页相关日志,缩短恢复时间。

19. 如果没有事务,批量写多个 KV,怎么保证原子写入

面试稳妥答法:

  • 可以用 write batch + WAL + 原子安装元数据
  • 典型方案:
  • 先把这批 KV 写到 WAL。
  • 后台把它们写到 memtable / SSTable。
  • 通过一个原子 manifest/version edit 指针切换,使读者只会看到“旧版本”或“新版本”,不会看到半成品。

另外一种答法:

  • 用 intent record / batch marker:
  • 读者如果看到未完成 batch,就跳过或按旧版本读。
  • 只有完成标记出现后,整个 batch 才对外可见。

五、分布式复制、日志截断与新节点恢复

20. 一主多从下,redo log 怎么同步

按层次答:

  • 单机 WAL 解决本地崩溃恢复。
  • 分布式下还要解决副本一致性,通常不能只靠“把 redo 文件 rsync 给 follower”。
  • 更常见的做法是:
  • 主库把逻辑变更或日志条目按顺序复制给从库。
  • 从库按相同顺序落盘并应用。
  • 提交点由复制协议决定,是本地主库先提交,还是多数副本确认后提交。

如果面试官切到强一致复制:

  • 就把它升级为 Raft/Paxos 日志复制来答。
  • 事务提交成功的条件变成“日志条目在多数派 durable/accepted”。

21. 不同节点怎么截断 redo log

关键点:

  • 截断必须晚于“该日志已经被 checkpoint / snapshot 覆盖,且所有需要它恢复的副本都不再依赖它”。
  • 主从架构下通常看最慢副本的复制进度。
  • 共识系统里通常要结合 applied indexsnapshot indexlog compaction point 判断。

22. 新加入节点怎么同步数据;旧日志被回收了怎么办

标准答法:

  • 先尝试日志追赶:从某个 log index / LSN 开始补。
  • 如果 leader 已经把那段日志 compaction 掉了,新节点就不能只靠增量日志恢复。
  • 这时必须走 snapshot / checkpoint / 基线拷贝,再从 snapshot 点之后继续追日志。

一句话总结:

日志只能帮你追“还没被回收的历史”;更老的数据必须靠快照或全量副本同步。

六、DDL、索引构建与 DML 并发

23. DDL 怎么实现,以 create index 为例

单机常见思路:

  1. 进入 schema change 状态,注册一条 DDL job。
  2. 扫描基表,回填已有数据到新索引。
  3. 回填期间拦截并记录增量 DML。
  4. 把回填阶段和增量变更 merge。
  5. 原子切换元数据,让新索引变为可见。

关键不变量:

  • 任意时刻,查询要么完全不用该索引,要么看到一个一致的索引。
  • 不能让回填窗口内的 DML 漏写到索引。

24. create index 和 DML 并发,怎么保证最终一致

单机场景常见答法:

  • 双写方案:
  • 基表继续写。
  • 新索引在回填期间也接收增量写。
  • 或者用 change buffer / delta log:
  • 先记录索引构建期间发生的增量修改。
  • 回填完成后回放增量。

分布式扩展怎么讲:

  • DDL 本身必须作为一个全局元数据变更,被所有节点按同一顺序感知。
  • 每个节点在收到 DDL 生效前后,必须对本地 DML 打上 schema version。
  • 构建索引时按 schema version 划分:
  • 某个版本之前的数据做存量回填。
  • 之后的数据走增量捕获。
  • 最后通过一个全局“切换版本”原子生效。

25. 分布式数据库上 DDL 怎么原子提交,以 add column 为例

建议答法:两阶段元数据切换。

  • 阶段 1:发布新 schema,但先不让所有读写立即完全切换。
  • 阶段 2:等所有节点都确认接收并完成必要准备后,原子推进 schema version。

需要补的工程问题:

  • 老 schema 节点如何处理新写入。
  • 读请求跨节点时如何协商 schema version。
  • 回滚 DDL 时是回滚元数据,还是需要补数据清理。

七、Raft 与系统设计追问

26. 三节点系统多数派提交,单机 P99 要做到多少才有希望整体 P99 < 1ms

这题通常不是要你算精确闭式解,而是要你意识到:

  • 三节点提交至少包含:
  • leader 本地处理与落盘
  • 两条网络路径中的较慢一条
  • follower 落盘 / ack
  • 分布式 P99 不会优于其关键路径上各子项的 P99。
  • 如果整体目标是 < 1ms,那单机本地 WAL flush、网络 RTT、follower 落盘都必须远低于 1ms,通常要把预算拆得非常细,单机侧往往只能占几百微秒量级。

面试里别给死数,先讲预算拆分。

27. follower 可以提供读吗

答案:可以,但要分一致性等级。

  • 弱一致读:直接读 follower,本质上允许陈旧。
  • 线性一致读:必须额外证明 follower 没落后到违反 lease / commit index 约束。

Raft 语境下常见做法:

  • leader read
  • read index
  • lease read

28. 新增节点为什么要 joint consensus

一句话:

为了避免配置变更过程中出现“旧多数派”和“新多数派”不相交,导致系统在过渡态出现脑裂或双重提交。

答题骨架:

  • 配置从 C_oldC_new 不能一步切。
  • 先进入联合配置 C_old,new
  • 该阶段需要同时满足联合配置下的提交规则。
  • 再切到 C_new

八、可以直接背的高频短答

29. 基于 B+ 树的数据库存储引擎怎么工作

可以按这个顺序说:

  1. 表和索引以页为单位组织,B+ 树内节点做导航,叶子节点存 key -> RID 或行。
  2. Buffer Pool 缓存热点页,磁盘层负责页读写。
  3. 并发访问时用 latch 保护页结构,用 lock/MVCC 保护事务语义。
  4. 更新会写 WAL;若支持 MVCC,还会维护版本链或 undo。
  5. 崩溃后通过 redo/undo 或 ARIES 恢复。

30. 什么情况下悲观事务比乐观事务好

  • 热点写多。
  • 冲突率高。
  • 长事务多。
  • 回滚代价高。
  • 业务延迟抖动比平均吞吐更重要。

31. 为什么业界悲观事务更多

  • 更符合真实 OLTP 冲突分布。
  • 对业务更稳定。
  • 对长事务更友好。
  • 不容易出现大量 retry 风暴。

九、补充专题:15445 / MySQL / RocksDB

32. CMU-15445 有做实际的数据存储吗

可以答“有,但它是教学型实现,不是工业级完整产品”。

  • bustub 里有真正的页式存储、slotted page、buffer pool、disk manager、B+ 树索引。
  • 表数据和索引数据都不是“纯内存玩具结构”,而是按页组织并能落到磁盘文件。
  • 但它的目标是教学,不会把工业系统里的恢复、后台线程、在线 DDL、复制、复杂优化器全做全。

面试更稳的表述:

15445/bustub 已经覆盖了一个数据库内核最关键的存储闭环:page -> buffer pool -> table heap / index -> transaction visibility,只是很多地方做了教学性简化。

33. CMU-15445 的并发控制怎么实现

先分层回答:

  • 索引页结构并发:靠 latch,比如 B+ 树的 latch crabbing。
  • 事务隔离与可见性:靠 MVCC
  • 串行化验证:在 bustub 项目语境里更接近 MVCC + OCC validation

可以直接背的版本:

  1. 读事务拿快照时间戳 read_ts
  2. 写事务先改最新版本,同时生成 undo/version 信息。
  3. 读时按版本链找对当前快照可见的版本。
  4. 提交时分配 commit_ts,并做 OCC 冲突检查。
  5. 冲突则 abort,不冲突则提交并让新版本对后续事务可见。

34. 版本链的存储是怎么做的

bustub 语境下的标准答法:

  • 表堆里始终存“当前最新 tuple”。
  • 旧版本不直接堆在表页里,而是以 UndoLog 的形式挂成版本链。
  • TxnManager 维护某条记录最新 undo/version 链接的入口。
  • 每个事务自己的工作区里保存它生成的 undo logs。
  • 链上的节点通常记录旧值或 delta,以及指向上一个版本的链接。

一句话总结:

base tuple 在表里,历史版本在 undo 链里,入口指针在事务管理器维护的版本元数据里。

35. 版本链是持久化的数据吗,还是只在内存里

如果面试官问的是 bustub 项目:

  • 更准确的说法是:它主要是教学用的运行时 MVCC 结构,不是一个完整工业级持久化版本存储系统。
  • 表页数据会落盘,但版本链元数据和事务运行时状态主要在内存管理结构里维护。
  • 所以它不能直接等价类比到 InnoDB 那种“完整 crash recovery 后仍能严密恢复事务状态”的工程系统。

如果换成工业数据库:

  • 历史版本有的体现在 undo segment / rollback segment 中。
  • 有的通过 WAL + page image + 版本元数据重建。
  • 是否“原样持久化整条版本链”取决于具体实现,不是所有系统都一样。

36. 拿到 tuple 后怎么找到版本链

标准路径:

  1. 先拿到 tuple 的 RID,也就是 (page_id, slot)
  2. page_id/slot 到事务管理器维护的版本信息里查入口。
  3. 入口通常是一个 UndoLink,包含类似 (txn_id, log_index) 的定位信息。
  4. 再跳到对应事务持有的 undo log 数组或链表里,沿着 prev_version 一路往前找。
  5. 找到第一个对当前 read_ts 可见的版本后返回。

一句标准话术:

tuple 自身只负责给你当前版本和 RID;真正的历史链入口通常要靠 RID 去版本目录里查,再顺着 undo link 追。

37. MySQL 索引实现原理

面试里如果只说 “MySQL”,默认最好主动切到 InnoDB,因为事务、MVCC、聚簇索引这些都属于 InnoDB 引擎层。

核心点:

  • InnoDB 的索引底层是 B+ 树
  • 主键索引是 聚簇索引,叶子页直接存整行记录。
  • 二级索引叶子页存的是 二级键 + 主键值,不是整行地址。
  • 走二级索引查整行时,通常要再根据主键回到聚簇索引,这就是回表。
  • 如果查询列都在二级索引里,就能覆盖索引,避免回表。

为什么这样设计:

  • 聚簇索引让主键范围扫描局部性更好。
  • 二级索引带主键而不是物理地址,避免行移动后大量失效。

38. 什么是 B+ 树

一句话:

B+ 树是一种多路平衡搜索树,内节点只存导航键,所有真实数据都在叶子节点,叶子节点之间通常还按链表连接,适合磁盘/页式存储。

为什么数据库爱用它:

  • 树高低,磁盘 IO 少。
  • 范围扫描友好。
  • 单页可放很多 key,扇出高。
  • 比普通二叉搜索树更稳定,也比哈希更适合范围查询。

39. MySQL 事务怎么实现

按模块回答最稳:

  • 原子性:undo log
  • 持久性:redo log
  • 隔离性:MVCC + lock manager
  • 一致性:靠约束、应用逻辑和前面几项共同保证

如果再展开一步:

  1. 更新前先写 undo,用于回滚和快照读。
  2. 更新时写 redo,保证崩溃后已提交事务可恢复。
  3. 普通一致性读走 MVCC。
  4. 当前读、更新、删除走锁。
  5. 提交时通过 redo 刷盘保证 durability。

40. MySQL 的 MVCC 是什么

一句话:

InnoDB 的 MVCC 是“数据行隐藏字段 + undo log 版本链 + Read View”共同实现的多版本并发控制。

标准答法:

  • 每行记录有隐藏版本信息,例如创建事务 ID、回滚指针。
  • 更新时不会把旧版本直接抹掉,而是把旧值放进 undo log,形成历史版本链。
  • 一致性读根据 Read View 判断当前版本是否可见。
  • 如果不可见,就顺着回滚指针去 undo 链里找更老版本。

41. MySQL 的强项是什么,它怎么做到的

推荐答法:

MySQL 的强项是成熟稳定的 OLTP 能力,而不是单点极致理论性能。

它能做好的原因主要有:

  • InnoDB 的 B+ 树 + Buffer Pool 带来成熟稳定的页式存储访问路径。
  • MVCC + 锁 在读多写多混合场景下比较均衡。
  • Redo Log + Group Commit + Doublewrite 让崩溃恢复和持久化路径比较可靠。
  • 复制、备份、生态工具、可观测性和运维经验很成熟。

42. MySQL 容灾恢复怎么做

分两层回答:

  • 单机崩溃恢复:
  • 重启后根据 redo log 重做已提交但未落盘的数据页修改。
  • 根据 undo / 事务状态处理未完成事务。
  • 集群级容灾:
  • 用 binlog 复制做主从。
  • 可配半同步、MGR / Group Replication、备份加 binlog 回放做时间点恢复。

一句话:

本地 crash recovery 靠 redo/undo,实例级高可用靠复制,误操作恢复靠备份 + binlog。

43. MySQL 写磁盘为什么性能还能很高

关键在于它把“随机数据页写”转成了“顺序日志写优先”。

  • 提交关键路径先写 redo log,redo 是追加写,顺序 IO 友好。
  • 真正的数据页刷新可以延后,由后台线程批量刷脏页。
  • 多个事务可以 group commit,共享一次 fsync。
  • 二级索引写还有 change buffer 这类优化可减少随机 IO。
  • Buffer Pool 吸收了大量热点读写,不是每次更新都直写数据页。

一句加分话术:

高性能不是因为“它把所有数据都写得很快”,而是因为它尽量把提交路径压缩成顺序 WAL,并把昂贵的数据页落盘异步化、批量化。

44. MySQL 事务与可重复读怎么实现

可以直接背:

  • 可重复读下,事务第一次一致性读会生成一个 Read View
  • 后续一致性读都复用这同一个 Read View。
  • 所以同一事务内多次普通 SELECT,看到的是同一快照。
  • 如果是当前读,例如 SELECT ... FOR UPDATEUPDATEDELETE,则读的是最新版本并加锁。
  • 为了防很多幻读问题,InnoDB 还会配合 next-key lock

45. MVCC 在 RR 和 RC 下有什么区别

最核心的区别就是 Read View 的生命周期。

  • RC
  • 每条语句生成新的 Read View。
  • 同一事务里前后两次普通查询,可能看到后来提交的新版本。
  • RR
  • 一般是事务级 Read View。
  • 同一事务里的普通一致性读看到同一快照。

所以结果上的差异:

  • RC 更容易看到“别人刚提交的新数据”。
  • RR 更强调事务内读一致性。
  • 但 RR 也不自动等于完全 Serializable,仍要看锁和实现细节。

46. RocksDB 底层文件的存储格式

先从目录级别答:

  • WAL:写前日志。
  • SST / SSTable:真正存数据的不可变有序文件。
  • MANIFEST:记录版本元数据和文件集合变化。
  • CURRENT:指向当前使用的 MANIFEST。
  • 还有 OPTIONSLOCKLOG 等辅助文件。

再从 SST 文件内部答:

  • Data Block:真正的 key-value 数据块。
  • Index Block:帮助定位数据块。
  • Filter Block:通常是布隆过滤器。
  • Metaindex / Properties:元数据。
  • Footer:文件尾部索引入口。

一句话:

RocksDB 不是“一个大文件”,而是 WAL + 多个 immutable SST + 版本元数据 共同组成的 LSM 存储集合。

47. RocksDB 优劣势

优势:

  • 写吞吐高,特别适合高写入 KV 场景。
  • WAL + MemTable + SST 的路径天然适合顺序写。
  • 压缩、布隆过滤器、compaction 策略、column family 等可调性很强。
  • 嵌入式使用方便,很多分布式系统直接拿它做本地状态机存储。

劣势:

  • compaction 带来的写放大、读放大、空间放大很难完全避免。
  • 参数很多,调优成本高。
  • 后台 compaction 资源竞争重时,容易出现写 stall、尾延迟抖动。
  • 复杂事务、多表关系、二级索引能力不如成熟关系型引擎自然。

一句总结:

RocksDB 很强,但强在“高性能嵌入式 LSM KV 引擎”;如果需求是完整关系型事务数据库能力,就通常还得在它上面再搭一层数据库内核。

十、最后怎么复习最有效

建议你按三轮来刷:

  1. 第一轮只刷主线:
    B+ 树并发 -> 隔离级别 -> MVCC/OCC/SSI -> WAL/ARIES
  2. 第二轮专门刷追问:
    读写集合怎么维护 -> 快照怎么做 -> watermark/GC -> group commit -> checkpoint
  3. 第三轮刷系统题:
    主从复制 -> 新节点恢复 -> DDL 并发 -> Raft read / membership change -> P99 预算

如果时间有限,优先级最高的是下面 10 题:

  1. B+ 树蟹行协议。
  2. RC / RR / Serializable 区别,以及 RR 为什么不一定等于 Serializable。
  3. 乐观锁和悲观锁,为什么工业界更常用悲观事务。
  4. MVCC 快照怎么做,可重复读怎么保证。
  5. SSI 为什么关注读写冲突。
  6. 读写集合怎么维护,长事务怎么优化验证。
  7. Redo / Undo / WAL / checkpoint / ARIES 主线。
  8. 原子提交为什么靠 commit record,而不是靠数据页同时刷盘。
  9. 新节点加入时为什么需要 snapshot,日志回收后为什么不能只靠 redo。
  10. create index 和 DML 并发时怎么保证索引不丢数据。

十一、公开资料

下面这些外部资料适合和仓库内笔记交叉着看: