数据库原理:存储引擎、索引、事务与锁

从磁盘到内存,从 B+树到 MVCC,搞懂关系型数据库的底层  |  Part 02

本主题的底层路线(万变不离其宗):
数据库底层只有两条主线,所有 SQL 性能与一致性问题都源于这两条线:
(1) 怎么存 / 怎么找:B+树(或 LSM)索引 + 缓冲池(Buffer Pool)+ WAL(预写日志)。这是"快"的来源,也是慢的根源。
(2) 怎么一致:事务(ACID)+ MVCC + 锁。这是"对"的来源,也是并发冲突的根源。
上层框架(ORM、分库分表、读写分离、各种"数据库中间件")都只是这两条线的实例化与取舍。先吃透这两根骨头,再看任何框架都只剩"参数不同"。
目录 0. 读前必读:关系型数据库的两条主线
1. 存储引擎概览:页 / 行存 / 列存 / InnoDB vs MyISAM
   1.1 为什么一切以"页"为单位
   1.2 行存 vs 列存:物理布局即访问模式
   1.3 InnoDB vs MyISAM 一句话差异
2. InnoDB 架构:内存与磁盘的分工
   2.1 内存三层:Buffer Pool / Change Buffer / Log Buffer
   2.2 磁盘:表空间 / 段 / 区 / 页
   2.3 缓冲池命中、脏页与刷脏
   2.4 Double Write:写失效(partial write)的防护
3. WAL:先写日志再改页
   3.1 为什么"先写日志"能让崩溃可恢复
   3.2 redo log(物理、循环写)vs undo log(逻辑、回滚与 MVCC)
   3.3 binlog:Server 层、归档与主从
   3.4 两阶段提交(2PC):让 redo 与 binlog 一致
4. 索引:为什么是 B+树
   4.1 磁盘友好的数据结构:高扇出、矮、叶子链表
   4.2 对比 B树 / 红黑树 / 跳表 / 哈希
   4.3 聚簇索引 vs 二级索引(回表)
   4.4 联合索引与最左前缀
   4.5 覆盖索引 / 索引下推 ICP / 自适应哈希 AHI
   4.6 选择性(离散度):索引为什么有时不生效
5. 事务与隔离级别
   5.1 ACID 各字母靠什么实现
   5.2 四种隔离级别与三大并发异常
   5.3 MVCC:隐藏事务 ID / undo 版本链 / ReadView
   5.4 RR 与 RC 的 ReadView 差异(为什么 RR 防不可重复读)
6. 锁:并发控制的硬手段
   6.1 锁的粒度与类型:行/表、共享/排他、意向锁
   6.2 记录锁 / 间隙锁 / Next-Key Lock 与幻读
   6.3 死锁与检测
7. 查询优化:成本模型与索引失效
   7.1 优化器与成本模型
   7.2 读懂 EXPLAIN(type / key / rows)
   7.3 JOIN:nested-loop / hash / merge
   7.4 索引失效:函数 / 隐式转换 / 前导模糊
8. 主从复制:binlog 与读写分离
   8.1 binlog 三种格式
   8.2 异步 / 半同步复制
   8.3 读写分离与主从延迟
9. 总表:底层路线 → 框架映射
10. "一句话记住"速查表
11. 自测:能答出来才算过关

0. 读前必读:关系型数据库的两条主线

你有数学背景,这恰恰是读数据库最大的优势:数据库的每个设计都是一道"在磁盘很慢、内存很小、并发很多"的约束下求最优解的工程题,背后是清晰的结构与权衡。框架(MyBatis、ShardingSphere、各种连接池)之所以让你晕,是因为它们把"底层机制的实例化"又包了一层名词。

一句话记住:关系型数据库只解决两件事——把数据放哪、怎么快速找到(存储与索引),以及 多人同时改怎么不出错(事务与锁)。InnoDB 的全部复杂度,都围绕"磁盘比内存慢 10 万倍、崩溃不能丢数据、并发要高"这三大约束展开。先抓这两条线,再回头看任何 SQL 慢、死锁、主从延迟,都能落到具体机制上。

本篇约定:以 MySQL / InnoDB 为主要实例来讲透底层(它最主流、源码开放、概念最全)。其他数据库(PostgreSQL、Oracle)在机制上高度同构,差异只是取舍参数不同。

1. 存储引擎概览:页 / 行存 / 列存 / InnoDB vs MyISAM

存储引擎 = 数据"真正落盘的方式 + 访问方式"的引擎层。MySQL 把"怎么存"和"怎么执行 SQL"解耦,所以同一套 SQL 可以换不同引擎。先理解最底层的物理单位。

1.1 为什么一切以"页"为单位

磁盘(即使是 SSD)和内存交换数据的最小单位是块/页,不是行。InnoDB 默认页大小 16KB。原因很朴素:

直觉:页就像书的一"页",你翻书不会为了看一个字把整本书摊开,也不会只看一个字就合上——按"页"翻最划算。数据库把"页"当作 I/O 的最小筹码。

1.2 行存 vs 列存:物理布局即访问模式

维度行存(InnoDB / OLTP)列存(ClickHouse / OLAP)
存储方式一行所有字段连在一起同一列所有值连在一起
擅长按主键取整行、增删改、点查对少数列做聚合(sum/avg/count)
压缩弱(同列类型混杂)强(同列同类型,易编码压缩)
典型场景交易、订单、用户(事务型)报表、分析、数据仓库
取舍本质:"把经常一起读的数据放一起"——行存赌你常读整行,列存赌你常只算几列。这就是底层布局对上层能力的决定作用。

1.3 InnoDB vs MyISAM 一句话差异

一句话记住:InnoDB = 支持事务 + 行锁 + 聚簇索引 + 崩溃可恢复(默认引擎);MyISAM = 只支持表锁、无事务、索引与数据分离、崩溃易损(老式、只读/日志类场景)。今天新项目几乎都用 InnoDB。

2. InnoDB 架构:内存与磁盘的分工

InnoDB 的本质是一个"用内存加速、用日志保命、最终落到磁盘"的缓存系统。下图把内存与磁盘的分工一次看清。

内存(InnoDB 缓冲区) Buffer Pool 缓冲池 缓存数据页+索引页(LRU) 含:干净页 / 脏页 / AHI 命中率高 = 几乎不读盘 Change Buffer 写缓冲 非唯一二级索引的改,先攒着 Log Buffer 日志缓冲 redo 先写这里,再批量刷盘 Adaptive Hash Index 自适应哈希 Undo Log Buffer(回滚/版本) 磁盘 表空间 .ibd(段/区/页) 数据页、索引页(B+树) 聚簇索引 = 数据本身 Redo Log 文件(ib_logfile) 物理日志、循环写、崩溃恢复 Undo 表空间 逻辑日志、版本链、回滚 Double Write Buffer(文件) 防止页写坏(partial write) binlog(Server 层,见 §3.3) 读入/刷脏 redo刷盘 undo刷盘
图 1:InnoDB 内存—磁盘分工。内存是"快但易失"的缓存,磁盘是"慢但持久"的真相;日志(redo/undo/binlog/double write)是连接两者的保险绳。

2.1 内存三层:Buffer Pool / Change Buffer / Log Buffer

直觉:内存三件套各管一件事——Buffer Pool 缓存读、Change Buffer 攒着写、Log Buffer 把写序列化。目的都是一个:减少直接碰磁盘的次数

2.2 磁盘:表空间 / 段 / 区 / 页

InnoDB 把磁盘空间层层划分,像"省→市→区→街道":

层级大小含义
表空间 Tablespace一个 .ibd 文件 / 共享一张表的数据与索引集合
段 Segment数据段(B+树叶)、索引段(B+树非叶)
区 Extent1MB(64 个页)连续空间,减少碎片与随机 I/O
页 Page16KBI/O 与存储的最小单位(行存在页里)

2.3 缓冲池命中、脏页与刷脏

坑:脏页太多会触发" sharp flush ",瞬间大量写盘,导致抖动。这就是为什么"写多读少"的业务也可能周期性变慢。

2.4 Double Write:写失效(partial write)的防护

页是 16KB,但操作系统/磁盘一次可能只写 4KB 就断电,于是会出现页写了一半(torn page / partial write)——这页既非旧也非新,redo 也救不了它(redo 记录的是"页内偏移的改动",前提是页本身完整)。

一句话记住:Double Write = 先把脏页整页顺序写到一个"双写区"(连续、快速),再写真正的数据文件。崩溃恢复时,若数据文件页损坏,就用双写区的副本还原这一页,再用 redo 重放。相当于给 16KB 页上了一份"整页快照保险"。

3. WAL:先写日志再改页

WAL(Write-Ahead Logging,预写日志)是一条贯穿数据库的铁律:任何对数据的修改,必须先记到日志并落盘,再去改内存/磁盘上的数据页

3.1 为什么"先写日志"能让崩溃可恢复

直接改页是"随机写"(慢且可能半途而废);写日志是"顺序写"(快且原子)。WAL 把"我要改成 X"先顺序记下来并保证落盘,内存页可以慢慢改、后台刷。崩溃后,只要日志在,就能把没来得及落盘的改动 重放(redo),从而"不丢数据"。

直觉:WAL 像记账——你先在小本子上写"张三 +100 元"(日志落盘),再慢慢去翻总账本改余额(改页)。哪怕翻账本时地震了,小本子还在,就能照着重做一遍。

3.2 redo log(物理、循环写)vs undo log(逻辑、回滚与 MVCC)

维度redo logundo log
性质物理日志("某页某偏移改成某值")逻辑日志("把某行某列还原成旧值")
作用崩溃恢复、保证持久性 D回滚事务、支撑 MVCC
写入时机事务中持续写改数据前先记"反向操作"
存储固定大小文件、循环写(覆盖旧)undo 表空间,靠版本链保留旧版本
一句话记住:redo 是"重做已提交的"(保证不丢),undo 是"撤销未提交的 / 找回旧版本"(保证可回滚 + 读旧值)。一个向前、一个向后。

3.3 binlog:Server 层、归档与主从

3.4 两阶段提交(2PC):让 redo 与 binlog 一致

既然"提交"既要写 InnoDB 的 redo,又要写 Server 的 binlog,二者必须要么都成功、要么都没生效,否则主库和从库会对不上(崩溃恢复后状态不一致)。两阶段提交就是这道保险:

InnoDB(引擎) ① redo 写盘 ② prepare 状态 ④ commit MySQL Server ③ binlog 写盘 事务协调者(提交线程) 保证两处"原子"一致 引擎内部状态机 Server 层归档
图 2:两阶段提交。prepare(redo 落盘)→ 写 binlog(落盘)→ commit(redo 标记提交)。崩溃恢复时若只有 prepare 没有 binlog,则回滚;两者都有则提交。以此保证 redo 与 binlog 终态一致。
崩溃恢复判定:重启时 InnoDB 扫描 redo,遇到 prepare 状态的事务,去 binlog 查对应记录:binlog 完整 → 提交;binlog 缺失 → 回滚。这就是 2PC 的核心价值。

4. 索引:为什么是 B+树

索引的本质就是一个加速查找的数据结构。在"数据在磁盘、按页读取"的前提下,选哪个结构,直接决定查询快不快。

4.1 磁盘友好的数据结构:高扇出、矮、叶子链表

B+树是为"磁盘"量身定做:

[20 | 40] [5 | 10] [25 | 30] [45 | 50] 1,5,10 +行指针 11,15,20 +行指针 21,25,30 +行指针 31,35,40 +行指针 41,45,50 +行指针 51,55 +行指针 ← 叶子层双向链表:范围扫描(20~50)顺着链扫即可 → 树高=O(log_扇出 N),百万数据≈3~4 次 I/O
图 3:一棵简化的 B+树。非叶子节点只存"路由键",所有真实数据/行指针都在叶子层;叶子之间用链表相连,范围查询无需回溯根节点。

4.2 对比 B树 / 红黑树 / 跳表 / 哈希

结构磁盘友好度范围查询为何不是默认
B+树★ 高扇出矮树★ 叶子链表——(胜出)
B树中(节点存数据,扇出小些)✗ 需中序遍历范围弱、扇出低
红黑树/AVL✗ 二叉树太高,I/O 多树高=数量级,磁盘杀手
跳表中(Redis 用,内存友好)★ 天然有序内存结构,落盘成本高
哈希★ 等值 O(1)✗ 无序无法范围/排序,仅 Memory 引擎等特例
一句话记住:数据库选 B+树,不是因为它"查找最快",而是它在"矮(少 I/O)+ 范围友好(链表)+ 稳定"三者间最均衡。哈希查单点最快但废了范围,红黑树在磁盘上高得离谱。

4.3 聚簇索引 vs 二级索引(回表)

直觉:聚簇索引像"按学号排的档案柜,柜格里就是本人档案";二级索引像"按姓名的检索卡,卡上只写学号",找到学号还得去柜格里翻本人。回表就是这第二次翻柜。

4.4 联合索引与最左前缀

联合索引 (a, b, c) 在 B+树里按"先 a、再 b、再 c"的字典序排序。因此能命中索引的条件是最左前缀

WHERE a=1            -- ✔ 命中
WHERE a=1 AND b=2    -- ✔ 命中
WHERE a=1 AND b=2 AND c=3  -- ✔ 命中
WHERE b=2            -- ✗ 跳过了 a,无法用树的有序性
WHERE a=1 AND c=3    -- 仅用 a,c 失效(范围/跳列)
一句话记住:联合索引像电话簿(先按省、再按市、再按姓名)。你"只查某省某市"能快速翻到;但若"只查姓名"或"跳过省只查市",电话簿的顺序就帮不上忙——这就是最左前缀。

4.5 覆盖索引 / 索引下推 ICP / 自适应哈希 AHI

4.6 选择性(离散度):索引为什么有时不生效

索引选择性 = 不重复值数量 / 总行数。性别只有 2 个值(选择性≈0.0001),对这类低离散度列建索引,优化器往往宁可直接全表扫:因为回表成本高于顺序读。

经验:"状态/性别/是否删除"这类列单独建索引基本无效;而有高离散度的列(用户ID、手机号)才值得建。EXPLAIN 里看到 type=ALL 或优化器弃用索引,常因选择性太差或预估回表代价高。

5. 事务与隔离级别

事务把多个操作打包成"要么全成、要么全不成"的单位。理解事务,关键是把 ACID 各靠什么机制实现隔离级别到底解决了什么 拆开。

5.1 ACID 各字母靠什么实现

字母含义底层靠什么
A 原子性要么全做要么不做undo log(回滚)+ 2PC/事务状态机
C 一致性数据库始终合法由 A/I/D + 应用约束共同保证(约束、外键)
I 隔离性并发互不影响锁 + MVCC
D 持久性提交后不丢WAL(redo log 落盘)+ double write
一句话记住:原子性靠 undo、持久性靠 redo、隔离性靠锁+MVCC、一致性是三者(加约束)共同达成的"结果"。记不住就记这句。

5.2 四种隔离级别与三大并发异常

隔离级别脏读不可重复读幻读实现思路
读未提交 RU✗ 可能读不加锁、不查版本
读已提交 RC✔ 避免✗ 可能MVCC,每次读新 ReadView
可重复读 RR(MySQL默认)✔ 避免✔ 基本避免*MVCC 快照 + Next-Key Lock
串行化 SERIALIZABLE读也加锁(无 MVCC 优化)

* InnoDB 在 RR 下通过 Next-Key Lock 解决幻读(标准 SQL 的 RR 不保证防幻读,这是 MySQL 的增强)。

5.3 MVCC:隐藏事务 ID / undo 版本链 / ReadView

MVCC(多版本并发控制)让"读不加锁、读写不阻塞"成为可能——核心是同一行存多个版本,读的人按自己的"快照"挑合适的版本

当前行 id=1 name='C', trx_id=200 roll_ptr → undo v2: name='B', trx_id=100 roll_ptr → undo v1: name='A', trx_id=50 roll_ptr = null(链尾) ReadView(某事务的快照) m_ids(活跃事务)= {100, 200} min_trx_id = 100 max_trx_id = 201 creator_trx_id = 200 可见性:trx_id < min 或 =自己 → 可见 参照
图 4:MVCC 版本链 + ReadView。当前行通过 roll_ptr 串起一串 undo 旧版本;读取时按 ReadView 的可见性规则沿链挑选"自己该看到的版本",从而不加锁也能读到一致快照。

5.4 RR 与 RC 的 ReadView 差异(为什么 RR 防不可重复读)

隔离级别ReadView 生成时机效果
RC 读已提交每条 SELECT 都新建 ReadView总能看到"最新已提交",所以同事务两次读可能不同(不可重复读)
RR 可重复读事务中第一条 SELECT 建一次,之后复用整个事务看到同一快照,别人提交的改动对自己"不可见"→ 可重复读
一句话记住:RC 是"每次读都看最新世界",RR 是"第一次读就拍张照,整段事务只看这张照"。差异只在 ReadView 什么时候重建——这就是"可重复读"的全部秘密。

6. 锁:并发控制的硬手段

MVCC 解决了"读不阻塞写、写不阻塞读",但当真要改同一行时,必须靠锁来串行化,否则会写丢。锁是数据库并发正确性的最后一道硬闸。

6.1 锁的粒度与类型:行/表、共享/排他、意向锁

直觉:意向锁像"我要在 3 楼装修(行锁)"前,先在楼门口挂个"3 楼有人施工"的牌子(意向锁)。这样管理员(表锁请求)一看牌子就知道别锁整栋楼,省得挨个房间敲门问。

6.2 记录锁 / 间隙锁 / Next-Key Lock 与幻读

光锁"已存在的行"挡不住幻读(别人 INSERT 新行就插进来了)。InnoDB 用三种锁配合:

5 10 15 ← 锁目标 20 Gap (10,15) Next-Key (10,15] Gap (15,20) Next-Key Lock 锁定 (10,15]:左边空隙 + 记录15 一起锁, 新行无法插进 (10,15],范围查询不会"幻"出多一行。 注意:间隙锁之间是兼容的(都只为防插入),所以不会因间隙锁直接死锁。
图 5:Next-Key Lock 防幻读。对记录 15 加 Next-Key Lock = 锁住其左边的空隙 (10,15) 再加上记录本身。任何想插入 10~15 之间新值的操作都会被阻塞,从而范围查询结果稳定。

6.3 死锁与检测

两个事务互相持有对方想要的锁(A 等 B 的行、B 等 A 的行),就死锁。InnoDB 用等待图(wait-for graph)做死锁检测:发现环即选择一个代价小的事务回滚(牺牲者)。也可靠 innodb_lock_wait_timeout 超时放弃。

预防要点:① 事务尽量短小;② 多语句操作多行时,约定统一加锁顺序(都按主键升序);③ 降低隔离级别到 RC 可减少间隙锁,从而减少死锁面。

7. 查询优化:成本模型与索引失效

SQL 是"声明式"的——你只说要什么,优化器决定怎么拿。理解优化器"怎么选路",比背语法重要得多。

7.1 优化器与成本模型

优化器基于成本模型估算每种执行计划的代价(主要=I/O 次数 + CPU),选最便宜的。成本估算依赖统计信息(表行数、列基数/直方图、索引区分度)。所以"优化器选错索引"常因统计信息过期,ANALYZE TABLE 可刷新。

7.2 读懂 EXPLAIN(type / key / rows)

字段看什么
type访问类型,越好越靠左:system > const > eq_ref > ref > range > index > ALL。看到 ALL 就是全表扫描(警惕)
key实际用了哪个索引;NULL 表示没用索引
rows预估扫描行数,越大越慢(估算值)
Extra关键提示:Using index(覆盖)、Using where(回表后过滤)、Using filesort(额外排序,慢)、Using temporary(用临时表,慢)
一句话记住:看慢查询先 EXPLAIN,盯三样——type 别是 ALL、key 别是 NULL、rows 别太大;Extra 里出现 filesort / temporary 基本要优化。

7.3 JOIN:nested-loop / hash / merge

取舍:有索引 → NLJ(用索引跳);两表都大且无索引 → Hash;都已排序 → Merge。优化器按成本挑,你可通过"小表驱动""建连接索引"引导它。

7.4 索引失效:函数 / 隐式转换 / 前导模糊

WHERE YEAR(createtime) = 2024   -- ✗ 对列套函数,索引失效(每行都要算)
WHERE phone = 13800000000       -- ✗ 列是字符串,传入数字 → 隐式转换,失效
WHERE name LIKE '%明'           -- ✗ 前导%,无法用最左前缀
WHERE name LIKE '张%'           -- ✔ 后缀确定,可用索引
本质:索引失效的根因是"破坏了 B+树的有序性 / 最左前缀"。函数让列值变了序、隐式转换让类型对不上、前导模糊让前缀不确定——树就无从二分。

8. 主从复制:binlog 与读写分离

当单机扛不住,或要容灾,就用"一主多从":主库写,从库读(读写分离)。它的底层完全建立在 §3.3 的 binlog 之上。

8.1 binlog 三种格式

格式记录内容优点缺点
statementSQL 语句原文日志小带不确定性函数(NOW()/UUID)会主从不一致
row每行改前/改后的值绝对一致、可靠日志大(大事务尤甚)
mixed两者自动混合折中边界复杂
现状:生产多用 row 格式,牺牲一点空间换"主从强一致"。

8.2 异步 / 半同步复制

8.3 读写分离与主从延迟

从库靠单线程(或并行)重放 binlog 追主库,天然有延迟。业务若"刚写完主库立刻读从库",可能读到旧值。

应对:① 写后强读走主库;② 关键路径避免"写后立即读从";③ 监控 Seconds_Behind_Master;④ 大事务会放大延迟(binlog 是事务级提交)。

9. 总表:底层路线 → 框架映射

底层机制上层框架 / 概念(实例化与取舍)取舍点
Buffer Pool + 页连接池、缓存中间件、Redis 前置缓存用内存换 I/O,代价是失效/一致性
B+树索引ORM 的 @Index、分库分表的"分片键"分片键本质就是选"用什么当聚簇/路由键"
MVCC + 隔离级别Spring @Transactional(isolation=...)隔离越高越安全,并发越低
行锁 + Next-Key Lock分布式锁、乐观锁版本号单机锁 vs 跨机锁的取舍
WAL + 2PCSeata/XA 分布式事务、binlog 订阅(Canal)本地 2PC vs 跨服务事务的一致性代价
binlog 复制ShardingSphere 读写分离、CDC 数据同步异步低延迟风险 vs 同步高可靠
成本模型/EXPLAIN慢查询平台、SQL 审核统计信息质量决定估算质量
一句话记住:所谓"数据库中间件",九成是在替你做两件事之一——把读流量导到从库(复制线),或 把数据按某键切开分到多机(索引/分片线)。看清它动的是哪条底层线,就再也不会被名词吓住。

10. "一句话记住"速查表

11. 自测:能答出来才算过关

  1. 为什么 InnoDB 不直接改磁盘页,而要先进 Buffer Pool 再异步刷?脏页没刷就崩溃,靠什么不丢数据?
  2. Double Write 解决的是什么具体问题?redo 为什么"救不了"页写坏?
  3. 如果一行数据有 5 个历史版本,MVCC 下某个 RR 事务该读哪个版本?依据是什么?
  4. RR 真的能完全避免幻读吗?它是靠什么机制做到的,和标准的"可重复读"有何不同?
  5. 一张表只有 (a,b,c) 联合索引,写 6 条能命中 / 失效的查询并解释。
  6. 什么情况下优化器会"弃用"你建的索引?EXPLAIN 的 type=ALL 意味着什么?
  7. 两阶段提交里,如果 binlog 写盘后、redo 还没 commit 就崩溃,重启后这条事务是提交还是回滚?为什么?
  8. 主从延迟为什么必然存在?写主库后立刻读从库可能看到什么?怎么规避?
本篇为《计算机底层学习路线》Part 02。核心主线:存储与索引(快)+ 事务与锁(对)。
配套 Markdown 版本见同名 .md 文件。图示建议结合 HTML 版查看(内联 SVG 更清晰)。