数据库原理:存储引擎、索引、事务与锁
从磁盘到内存,从 B+树到 MVCC,搞懂关系型数据库的底层 | Part 02
本主题的底层路线(万变不离其宗):
数据库底层只有两条主线,所有 SQL 性能与一致性问题都源于这两条线:
(1) 怎么存 / 怎么找: B+树(或 LSM)索引 + 缓冲池(Buffer Pool)+ WAL(预写日志)。这是"快"的来源,也是慢的根源。
(2) 怎么一致: 事务(ACID)+ MVCC + 锁。这是"对"的来源,也是并发冲突的根源。
上层框架(ORM、分库分表、读写分离、各种"数据库中间件")都只是这两条线的实例化与取舍 。先吃透这两根骨头,再看任何框架都只剩"参数不同"。
0. 读前必读:关系型数据库的两条主线
你有数学背景,这恰恰是读数据库最大的优势:数据库的每个设计都是一道"在磁盘很慢、内存很小、并发很多"的约束下求最优解的工程题,背后是清晰的结构与权衡。框架(MyBatis、ShardingSphere、各种连接池)之所以让你晕,是因为它们把"底层机制的实例化"又包了一层名词。
一句话记住: 关系型数据库只解决两件事——把数据放哪、怎么快速找到(存储与索引) ,以及 多人同时改怎么不出错(事务与锁) 。InnoDB 的全部复杂度,都围绕"磁盘比内存慢 10 万倍、崩溃不能丢数据、并发要高"这三大约束展开。先抓这两条线,再回头看任何 SQL 慢、死锁、主从延迟,都能落到具体机制上。
本篇约定:以 MySQL / InnoDB 为主要实例来讲透底层(它最主流、源码开放、概念最全)。其他数据库(PostgreSQL、Oracle)在机制上高度同构,差异只是取舍参数不同。
1. 存储引擎概览:页 / 行存 / 列存 / InnoDB vs MyISAM
存储引擎 = 数据"真正落盘的方式 + 访问方式"的引擎层。MySQL 把"怎么存"和"怎么执行 SQL"解耦,所以同一套 SQL 可以换不同引擎。先理解最底层的物理单位。
1.1 为什么一切以"页"为单位
磁盘(即使是 SSD)和内存交换数据的最小单位是块/页 ,不是行。InnoDB 默认页大小 16KB。原因很朴素:
磁盘寻址有固定开销,一次读 1 行和读 16KB 的"启动成本"差不多,不如一次多读点(局部性原理 )。
索引、缓冲、锁的计量都以页为单位,CPU 和内存管理也更高效。
直觉: 页就像书的一"页",你翻书不会为了看一个字把整本书摊开,也不会只看一个字就合上——按"页"翻最划算。数据库把"页"当作 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: 最核心。把磁盘页缓存到内存,按 LRU 淘汰。一次查询命中缓冲池就几乎不读盘 ——所以"加内存"往往是最直接的性能药。
Change Buffer: 对非唯一二级索引的"插入/更新",如果目标页不在缓冲池,先不读盘 ,把改动记在这里,等以后读该页时再合并。省掉大量随机读。
Log Buffer: redo 日志先写进这块内存,再按策略(每秒 / 提交时)刷到磁盘 redo 文件。把"随机写页"变成"顺序写日志",极大提速。
直觉: 内存三件套各管一件事——Buffer Pool 缓存读、Change Buffer 攒着写、Log Buffer 把写序列化。目的都是一个:减少直接碰磁盘的次数 。
2.2 磁盘:表空间 / 段 / 区 / 页
InnoDB 把磁盘空间层层划分,像"省→市→区→街道":
层级 大小 含义
表空间 Tablespace 一个 .ibd 文件 / 共享 一张表的数据与索引集合
段 Segment — 数据段(B+树叶)、索引段(B+树非叶)
区 Extent 1MB(64 个页) 连续空间,减少碎片与随机 I/O
页 Page 16KB I/O 与存储的最小单位(行存在页里)
2.3 缓冲池命中、脏页与刷脏
命中(hit): 要的页已在缓冲池,直接内存返回。命中率( buffer pool hit rate)是性能第一指标。
脏页(dirty page): 缓冲池里被改过、但磁盘上还是旧版本的页。InnoDB 不会每次提交都写回磁盘(太慢),而是标记脏、后台异步刷 。
刷脏(flush): 后台线程(或触发阈值)把脏页写回磁盘。注意:脏页还没刷就崩溃,靠 redo log 恢复;脏页刷到一半崩溃,靠 double write 兜底(见下)。
坑: 脏页太多会触发" 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 log undo log
性质 物理日志("某页某偏移改成某值") 逻辑日志("把某行某列还原成旧值")
作用 崩溃恢复、保证持久性 D 回滚事务、支撑 MVCC
写入时机 事务中持续写 改数据前先记"反向操作"
存储 固定大小文件、循环写(覆盖旧) undo 表空间,靠版本链保留旧版本
一句话记住: redo 是"重做已提交的"(保证不丢),undo 是"撤销未提交的 / 找回旧版本"(保证可回滚 + 读旧值)。一个向前、一个向后。
3.3 binlog:Server 层、归档与主从
层级不同: redo/undo 是 InnoDB 引擎层 的;binlog 是 MySQL Server 层 的,任何引擎都有。
用途: 主从复制(从库靠重放 binlog 同步)、时间点恢复(PITR)。它是"逻辑归档",可长期保留;redo 是"循环写"的短期恢复用。
三种格式(详见 §8.1): statement / row / mixed。
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+树是为"磁盘"量身定做:
高扇出、矮: 每个节点(一页 16KB)能放很多键,百万级数据树高通常只有 3~4 层——意味着一次查找只需 3~4 次 I/O。这正是"B+树比二叉树矮"的工程意义。
非叶子只存键、不存数据: 同样一页能塞更多键,扇出更高、树更矮。
叶子用链表串起来: 范围查询(WHERE id BETWEEN 10 AND 100)不用回到根节点,顺着叶子链表扫一遍即可——这是 B+树相对 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 二级索引(回表)
聚簇索引(主键索引): InnoDB 把"整行数据 "直接存在 B+树叶子节点里。也就是说,主键索引的叶子 = 数据本身。一张表只有 一个聚簇索引。
二级索引(普通索引): 叶子节点存的是"索引列值 + 主键值 "。查到主键后,再拿主键去聚簇索引找整行 ——这一步叫回表 。
直觉: 聚簇索引像"按学号排的档案柜,柜格里就是本人档案";二级索引像"按姓名的检索卡,卡上只写学号",找到学号还得去柜格里翻本人。回表就是这第二次翻柜。
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
覆盖索引: 查询所需字段全在二级索引叶子 里,无需回表。如 SELECT a,b FROM t WHERE a=1 且索引为 (a,b)。
索引下推 ICP(Index Condition Pushdown): 把 WHERE 中能用的条件在存储引擎层、扫描索引时就过滤 ,减少回表次数。例:WHERE a=1 AND b LIKE '%x%',b 的过滤下推到索引扫描阶段。
自适应哈希 AHI: InnoDB 发现某些索引键被频繁等值查询 ,会在内存里为这些键自动建哈希索引,把"树查找"变成"哈希 O(1)"。它是自动的、局部的 ,不是你建的索引。
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 的增强)。
脏读: 读到别人未提交 的改动(可能回滚,读到假数据)。
不可重复读: 同一事务内两次读同一行,结果变了 (被别人已提交的 UPDATE 改了)。
幻读: 同一事务内两次"范围查询",多/少了行 (被别人已提交的 INSERT/DELETE 影响)。核心区别:不可重复读针对"已存在行的值",幻读针对"结果的行集合"。
5.3 MVCC:隐藏事务 ID / undo 版本链 / ReadView
MVCC(多版本并发控制)让"读不加锁、读写不阻塞"成为可能——核心是同一行存多个版本,读的人按自己的"快照"挑合适的版本 。
隐藏事务 ID: 每行有 DB_TRX_ID(最近修改它的事务ID)和 DB_ROLL_PTR(回滚指针,指向 undo 里的上一版本)。
undo 版本链: 每次改行,旧版本写入 undo,新行 roll_ptr 指向它,形成"链"。一行因此可能有一串历史版本。
ReadView(读视图): 一个事务"某一刻"看世界的快照,含:当前活跃事务ID集合 m_ids、最小活跃 min_trx_id、下一个将分配的事务ID max_trx_id、自己的 creator_trx_id。可见性规则:某版本的 DB_TRX_ID 若 < 最小活跃 或 = 自己 → 可见;若在活跃集合内 → 顺着 undo 链找更早版本。
当前行 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 锁的粒度与类型:行/表、共享/排他、意向锁
共享锁 S / 排他锁 X: S 允许多人一起读;X 独占,读写都互斥。写必加 X。
行锁 / 表锁: 行锁粒度细、并发高(InnoDB 默认靠索引加行锁);表锁粒度粗、并发低(MyISAM 只用表锁)。
意向锁 IS / IX: 表级"意向"标记,表示"我(或某人)打算在表里某些行加 S/X 锁"。作用是让"表锁"能快速判断 是否有人正在行级加锁,避免逐行扫描——它是一种协调信号 ,不阻塞行锁。
直觉: 意向锁像"我要在 3 楼装修(行锁)"前,先在楼门口挂个"3 楼有人施工"的牌子(意向锁)。这样管理员(表锁请求)一看牌子就知道别锁整栋楼,省得挨个房间敲门问。
6.2 记录锁 / 间隙锁 / Next-Key Lock 与幻读
光锁"已存在的行"挡不住幻读(别人 INSERT 新行就插进来了)。InnoDB 用三种锁配合:
记录锁 Record Lock: 锁住某条已存在的索引记录。
间隙锁 Gap Lock: 锁住两条记录之间的"空隙 ",阻止往里插新记录。
Next-Key Lock = 记录锁 + 前面的间隙锁 ,即锁住"(左邻居, 当前记录]"。RR 下默认用这个,既锁住记录、又锁住它左边的空隙,从而彻底封死"插入新行造成幻读" 。
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
Nested-Loop(嵌套循环): 驱动表每行,去被驱动表逐行匹配。小表驱动大表时可用索引,最通用。
Hash Join: 把小表建哈希表,大表逐行探测。无索引也能快,但仅等值、内存哈希 (MySQL 8.0 引入)。
Merge Join: 两表都按连接键有序 时,像归并一样线性扫描匹配。适合已排序的大表。
取舍: 有索引 → 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 三种格式
格式 记录内容 优点 缺点
statement SQL 语句原文 日志小 带不确定性函数(NOW()/UUID)会主从不一致
row 每行改前/改后的值 绝对一致、可靠 日志大(大事务尤甚)
mixed 两者自动混合 折中 边界复杂
现状: 生产多用 row 格式,牺牲一点空间换"主从强一致"。
8.2 异步 / 半同步复制
异步(默认): 主库提交即返回客户端,binlog 异步发给从库。主宕机可能丢已提交事务(数据在主机内存、未同步)。
半同步: 主库提交后,至少一个从库确认收到 binlog 才返回。牺牲一点延迟,换"不丢已确认事务"。
(全同步:所有从库都确认,延迟更大,少用。)
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 + 2PC Seata/XA 分布式事务、binlog 订阅(Canal) 本地 2PC vs 跨服务事务的一致性代价
binlog 复制 ShardingSphere 读写分离、CDC 数据同步 异步低延迟风险 vs 同步高可靠
成本模型/EXPLAIN 慢查询平台、SQL 审核 统计信息质量决定估算质量
一句话记住: 所谓"数据库中间件",九成是在替你做两件事之一——把读流量导到从库(复制线) ,或 把数据按某键切开分到多机(索引/分片线) 。看清它动的是哪条底层线,就再也不会被名词吓住。
10. "一句话记住"速查表
页: 数据库 I/O 的最小筹码(16KB),一切以页为单位。
InnoDB vs MyISAM: 事务/行锁/聚簇/可恢复 vs 表锁/无事务/易损。
WAL: 先记日志(顺序写)再改页(随机写),崩溃靠 redo 重放。
redo vs undo: 一个向前(重做已提交)、一个向后(撤销/旧版本)。
B+树胜出: 矮(少 I/O)+ 叶子链表(范围友好)+ 稳定。
回表: 二级索引叶子只存主键,要整行得回聚簇索引再查一次。
最左前缀: 联合索引像电话簿,跳前缀就废。
ACID: A 靠 undo、D 靠 redo、I 靠锁+MVCC、C 是结果。
RR vs RC: 差在 ReadView 何时重建——RR 只在第一次读建,RC 每次读都建。
Next-Key Lock: 记录锁 + 左间隙锁,RR 防幻读的关键。
索引失效: 函数 / 隐式转换 / 前导模糊,本质都是破坏有序性或最左前缀。
主从: 读写分离建在 binlog 上;延迟源于从库重放,写后读从要小心。
11. 自测:能答出来才算过关
为什么 InnoDB 不直接改磁盘页,而要先进 Buffer Pool 再异步刷?脏页没刷就崩溃,靠什么不丢数据?
Double Write 解决的是什么具体问题?redo 为什么"救不了"页写坏?
如果一行数据有 5 个历史版本,MVCC 下某个 RR 事务该读哪个版本?依据是什么?
RR 真的能完全避免幻读吗?它是靠什么机制做到的,和标准的"可重复读"有何不同?
一张表只有 (a,b,c) 联合索引,写 6 条能命中 / 失效的查询并解释。
什么情况下优化器会"弃用"你建的索引?EXPLAIN 的 type=ALL 意味着什么?
两阶段提交里,如果 binlog 写盘后、redo 还没 commit 就崩溃,重启后这条事务是提交还是回滚?为什么?
主从延迟为什么必然存在?写主库后立刻读从库可能看到什么?怎么规避?