本章给出后面七章的地基:一条 SQL 究竟穿过哪几层,InnoDB 把数据组织成了什么形状。

学习目标

读完本章,你应当能够:

  1. 推导一条 SQL 从连接建立到结果返回的完整路径,并说清每一步失败的报错现象;
  2. 判断一个查询为什么"看起来该走索引却没走",并用统计信息、代价模型、直方图三个角度解释;
  3. 画出 buffer pool 的分段 LRU 结构,说明全表扫描如何被 innodb_old_blocks_time 挡住;
  4. 按表空间 → 段 → 区 → 页 → 行五级复述 InnoDB 的磁盘组织,并说明"为什么按区分配";
  5. 逐字节拆解 COMPACT 行格式,计算单行最大长度与溢出条件;
  6. 串联一条 UPDATE 的十个步骤,指出每一步崩溃后的恢复结果。

前置知识检查

你需要知道在哪一节补齐
虚拟内存、页、缺页与页替换算法见第 8 章 8.3 节(buffer pool 是它在用户态的翻版)
磁盘随机 I/O 与顺序 I/O 的延迟量级见第 13 章 13.1 节
TCP 连接建立、TIME_WAIT 与应用层连接池见第 16 章 16.2 节
B+ 树的查找过程与树高估算见第 23 章 23.1 节(本章只讲索引页在内存里怎么占位)
日志先行(write-ahead logging, WAL)的思想见第 21 章 21.1 节(本章 20.9 节只做链路串联)

20.1 三层架构:一条 SQL 要穿过谁

MySQL 的逻辑架构是一条单向流水线,任何一次查询都要依次穿过连接层、SQL 层、存储引擎层。分层的意义不在"好看",而在把与存储无关的通用能力(语法、权限、优化)与与存储强相关的持久化能力(事务、索引、崩溃恢复)解耦。

20.1.1 连接层:线程模型与最大连接数

连接层(connection layer)负责 TCP 握手、账号密码认证、权限加载、以及每个连接分配一个线程。

  • 线程模型:MySQL 是"一连接一线程"(one-thread-per-connection)。连接建立时从线程缓存(thread_cache_size)取,取不到才 pthread_create。线程栈按 thread_stack(默认 1 MB 左右)预留虚拟内存,因此连接数上限本质上受内存与 ulimit 约束,而不是受某个"数据库自带上限"约束。
  • 最大连接数:max_connections 默认 151,max_user_connections 限制单账号。超过 max_connections 时新连接直接被拒并报 ERROR 1040 (HY000): Too many connections;注意超级账号会额外保留一个连接槽,所以管理员仍能登进去救火。
  • 连接池(connection pool):池属于客户端/驱动侧(HikariCP、Druid 等),MySQL 服务端并不知道"池"的存在。池的意义是省掉每次认证 + 建 TCP 的开销;同机房 RTT 约 0.1~0.5 ms,一次建连含认证通常要 1 ms 量级,而一条走主键的查询本身可能只有 0.2 ms,所以不池化时代价全在建连上。

警告

把 max_connections 调到几千是常见的错误处理方式。每个连接背后有一个线程与一份会话级内存(sort buffer、join buffer 等按需分配),连接数翻十倍会把内存和上下文切换(约 1~5 μs/次)一起推高,最终整体 QPS 下降。正确做法是先查连接为什么被长时间占用。

20.1.2 SQL 层:解析、优化、执行

SQL 层(server layer)不碰数据文件,只处理命令。它包含:

组件职责面试常问的机制点
解析器(parser)词法分析 + 语法分析,产出解析树语法错误在优化器之前就报出,与索引无关
预处理器(preprocessor)语义检查、表/列存在性、权限校验、常量折叠视图与别名的展开在此完成
优化器(optimizer)逻辑变换 + 基于代价选择执行计划本章 20.3 节整节
执行器(executor)按计划调用存储引擎接口取行逐行取,handler_read_* 计数在此累加
查询缓存(query cache)以 SQL 文本为 key 缓存结果集8.0 已移除,见下
查询缓存为什么在 8.0 被移除:它的失效粒度是表级——表上任何一行被任何语句修改,该表所有缓存条目全部失效。这意味着写密集场景下缓存命中率接近零,而每一次写都要额外维护全局互斥的缓存失效;且它仍要经过完整解析与权限校验,省下的只是执行阶段。收益趋零、成本恒定,于是被整体删除。

20.1.3 存储引擎层:可插拔的意义

存储引擎层(storage engine layer)以 handler 接口向上暴露一套统一原语:ha_open/index_read/write_row/delete_row/rnd_next/commit/rollback 等。SQL 层只认识这组接口,因此:

  • 同一套 SQL 语法、权限、优化器可以服务于不同引擎;- 引擎可以按表指定:CREATE TABLE t (...) ENGINE = InnoDB;- 引擎实现不了的能力,上层靠能力标志位降级(例如引擎不支持事务时 BEGIN 无意义)。

可插拔真正的价值不是"能换引擎",而是"SQL 层可以完全不假设数据怎么存"——这也是为什么 InnoDB 能自行决定页大小、行格式、索引结构而对优化器透明。

注意

面试官追问的下一层:既然存储引擎可插拔,为什么 EXPLAIN 的结果与引擎无关?因为 EXPLAIN 输出的是计划,不是实际执行;真正的差异要到 EXPLAIN ANALYZE 或 Handler_read_* 状态变量才看得出来。

20.2 一条 SQL 的完整生命周期

以 SELECT * FROM t WHERE a = 1 AND b > 5 为例,逐步展开:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 客户端
   │ ① TCP 三次握手
   ▼
 [连接层] ② 认证:用户名/密码/host 匹配 → 加载该账号权限
   │
   ▼
 [SQL 层]
   ③ 词法分析:切 token,"SELECT" "FROM" "t" 是标识符
   ④ 语法分析:按 SQL 标准文法构解析树(syntax tree)
   ⑤ 预处理:表 t 存在吗?列 a、b 存在吗?当前账号对 t 有 SELECT 权限吗?
             常量折叠:b > 5 中的常量先算完
   ⑥ 优化器:逻辑变换(外连接转内连接、子查询解嵌套、条件下推)
             → 枚举候选计划 → 代价模型比较 → 选出索引与访问路径
             产出执行计划(access path + 索引)
   ⑦ 执行器:先调引擎接口做第一次取数
             → 引擎在 B+ 树中定位 → 返回"一行"
             → 执行器对每行做条件复核(不能全信引擎的过滤)
   │
   ▼
 [存储引擎层] ⑧ 页不在 buffer pool → 从表空间文件读 16 KB 页
              返回的记录要被复制的列 → 组装成结果行
   │
   ▼
 ⑨ 结果集分批发给客户端(默认不缓存整个结果集)

每一步出问题时的现象:

步骤失败现象关键判据
① 连接ERROR 2003 连不上;ERROR 1040 连接数满与 SQL 无关,先看网络与 max_connections
② 认证/权限ERROR 1045 Access denied密码错与 host 不匹配是同一报文,需查 mysql.user
③ 词法ERROR 1064 You have an error in your SQL syntax报出的位置是 token 位置,不是逻辑错误
④ 语法同上,但位置在语句末尾附近常见于 JOIN 条件缺失、括号不配对
⑤ 预处理ERROR 1146 Table doesn't exist、ERROR 1054 Unknown column、ERROR 1142 SELECT command denied权限错误在这里报,不在执行时;优化器还没跑
⑥ 优化不报错,但计划变差(type: ALL、Using filesort)用 EXPLAIN / optimizer_trace 看
⑦ 执行报错或超时;也可能"不报错但慢"SHOW PROCESSLIST 看状态(Sending data、Copying to tmp table)
⑧ 引擎ERROR 1114 The table is full、ERROR 1205 Lock wait timeout引擎层错误码,见第 24 章 24.5 节

提示

面试话术:被问"一条 SQL 怎么执行的",不要背流水线名字,而要主动交代**“权限校验发生在预处理阶段,所以没权限的语句不会进入优化器”与“执行器必须自己对每行做条件复核”**——这两点是区分背过书与读过实现的分水岭。

20.3 优化器为什么可能选错索引

优化器的全部依据是代价(cost),代价来自统计信息。统计信息是抽样出来的估计值,估计就会错。

20.3.1 统计信息:索引基数与采样

InnoDB 为每个索引维护基数(cardinality),即"不同值的个数估计"。它不精确统计,而是随机采样若干页,对页内记录做基数估算再外推。

  • innodb_stats_persistent(默认 ON):统计信息持久化到 mysql.innodb_index_stats / innodb_table_stats,避免每次重启重算导致计划漂移。
  • innodb_stats_persistent_sample_pages(默认 20):采样页数。20 页 × 每页约 100 条 = 约 2000 条记录来代表上亿行,误差自然可观。
  • innodb_stats_auto_recalc(默认 ON):表变更行数超过约 10% 时自动重算,这解释了"为什么同一条 SQL 昨天快今天慢"。

SHOW INDEX FROM t 的 Cardinality 列就是它。基数错 → 优化器认为"走索引要回表 N 次" → 于是选了全表扫描。

20.3.2 代价模型与常量

MySQL 用两个基本常量把 I/O 与 CPU 折算成同一单位:

常量含义默认值
io_block_read_cost读一个数据块的成本1.0
memory_block_read_cost从内存读一个块的成本0.25
innodb_page_size页大小16 KB
innodb_old_blocks_time页进入 old 区后多久才允许晋升1000 ms

1.0 : 0.25 就是"内存比磁盘快 4 倍"的工程近似(真实量级差 10⁵ 倍,见第 13 章 13.1 节)。代价模型里最不可靠的一项正是回表代价:优化器假设"按索引顺序回表"是廉价的,实际上二级索引的顺序与主键顺序经常不一致,回表就退化成随机 I/O;缓冲池命中率低时误差被放大到几个数量级。

20.3.3 直方图与统计信息的补丁

MySQL 8.0 引入直方图(histogram),用来描述列值的分布,弥补基数只给"不同值个数"、不给"分布形状"的缺陷:

1
2
3
4
-- 用直方图补上"分布形状"这一维:优化器原本只知道 a 有 100 万个不同值,
-- 不知道 a = 1 其实占了 90% 的行,所以会误判走索引更划算
ANALYZE TABLE t UPDATE HISTOGRAM ON a WITH 64 BUCKETS;
SHOW INDEX FROM t;

单列直方图有两种:等高的 singleton(每个桶一个值)与等宽的 equi-height(用于范围查询估算)。

20.3.4 干预手段与 ANALYZE TABLE 的代价

手段写法适用与代价
强制索引SELECT ... FROM t FORCE INDEX(idx_a) WHERE ...直接指定,绕过代价比较;写死后再改数据分布可能更糟
忽略索引IGNORE INDEX(idx_a)用于排除某个被误选的索引
重算统计ANALYZE TABLE t;重新采样,代价与表大小成正比,会持有表级元数据锁(MDL)
增加采样页SET GLOBAL innodb_stats_persistent_sample_pages = 256;更准但 ANALYZE 更慢,DDL 期间也变慢
改写 SQL把 OR 拆成 UNION ALL、把函数从列上移到常量侧让代价模型能看清,而不是骗它
1
2
3
4
5
6
7
8
9
-- 为什么这样改写:优化器对 OR 的两支要分别估代价,估错就退化成全表扫描;
-- 拆成 UNION ALL 后每条子查询都能用最左前缀,代价估算的输入变简单了
SELECT id, a FROM t WHERE a = 1
UNION ALL
SELECT id, a FROM t WHERE b = 2;

-- 为什么这样改写:列上套函数会让索引失效(对索引列做运算,B+ 树无法按序定位),
-- 把运算搬到常量侧,索引才能被用上
SELECT id FROM t WHERE created_at >= '2024-01-01 00:00:00';

重要

FORCE INDEX 是急救手段而不是优化手段。它把优化器的判断替换成人的判断,一旦数据分布变化、索引被删或改名,语句会直接报错或性能崩塌。生产上优先做的是"让统计信息更准 + 让 SQL 的形状更容易被估算"。

20.4 InnoDB 与 MyISAM 的取舍

维度InnoDBMyISAMMemory
事务支持(ACID)不支持不支持
锁粒度行级(含间隙),另有表级意向锁表级表级
外键支持不支持不支持
崩溃恢复redo/undo 保证,可恢复到崩溃前已提交表易损坏,需 REPAIR TABLE,可能丢数据数据全丢
索引结构聚簇索引(主键即数据),二级索引存主键值非聚簇,索引与数据分离,索引存行地址哈希索引
COUNT(*)需遍历最小的二级索引逐行计数维护行计数,O(1) 返回O(1)
全文索引5.6+ 支持支持(更早)不支持
压缩支持(COMPRESSED 行格式)支持(myisampack,只读)不支持

为什么现在基本只剩 InnoDB:现代后端的默认前提是"崩溃恢复不能丢已提交事务"“并发写不能锁整表”。MyISAM 的 COUNT(*) 是 O(1) 这点优势,在 InnoDB 上可以用计数表或缓存替代,而它失去的行锁与崩溃安全无法替代。Memory 引擎只在临时表、会话级中间结果这类可重建数据上有位置。

小心

“MyISAM 查询比 InnoDB 快"是会被追着打的回答。要区分:纯读、无并发、数据量小时 MyISAM 因为少了事务与 MVCC 的簿记确实可能略快;但只要出现并发写,MyISAM 的表级锁会让它整体吞吐远低于 InnoDB 的行锁。用单线程 benchmark 得出"MyISAM 快"的结论,是典型的测错场景。

20.5 InnoDB 内存结构

20.5.1 buffer pool 的组织与分段 LRU

buffer pool(缓冲池)以页(page,16 KB)为管理单位缓存数据页与索引页。它的三层组织是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
 innodb_buffer_pool_size = 8 GB, innodb_buffer_pool_instances = 4
 ┌──────────────────────────────────────────────────────────────┐
 │ instance 0        instance 1      instance 2     instance 3  │
 │  2 GB              2 GB            2 GB           2 GB        │
 │  └ 由 chunk 组成(innodb_buffer_pool_chunk_size 默认 128 MB)│
 │     每个 chunk 是一段连续内存,内含多个 16 KB 的页描述符      │
 └──────────────────────────────────────────────────────────────┘
                    每个 instance 内部各有一套 LRU 链表:
   ┌──────────────────────── LRU list ────────────────────────┐
   │  young(new)子链 ~5/8           │  old 子链 ~3/8         │
   │  ← 头部 = 最近使用               │  尾部 = 即将淘汰        │
   │  新读入的页先插到 old 子链的头部(交界处中点)             │
   └──────────────────────────────────────────────────────────┘
   页被访问时:若在 old 子链 且 停留时间 < innodb_old_blocks_time(1000ms)
              → 不移动到 young 头部(防止一次性扫描把热点挤出去)

关键参数:innodb_old_blocks_pct(默认 37,即 old 区占 3/8)、innodb_old_blocks_time(默认 1000 ms)。分段 LRU 解决的不是"LRU 不准”,而是"全表扫描或备份类大查询会顺序读完所有页,把整个 LRU 冲一遍":在 old_blocks_time 内被反复访问的页不会被晋升,扫描完就自己在 old 区尾部淘汰掉。

20.5.2 change buffer:为什么唯一索引不能用

change buffer(写缓冲,5.5 前叫 insert buffer)缓存的是对非唯一二级索引页的修改。当一条 INSERT/UPDATE/DELETE 要修改的二级索引页不在 buffer pool 时,不必立刻把页从磁盘读进来,而是把"这个页要加一条记录"记在 change buffer 里,等该页因其他原因被读入时再合并(merge)。

  • 为什么唯一索引不能用:唯一性约束要求写入前必须确认"不存在重复值",这个确认必须读索引页;页都已经读进来了,缓冲就失去意义。- 适用条件:写多读少、且二级索引多的表收益最大(把多次随机 I/O 攒成一次)。

20.5.3 log buffer 与自适应哈希索引

  • log buffer(innodb_log_buffer_size,默认 16 MB):redo log 先写这里,再按 innodb_flush_log_at_trx_commit 策略刷到磁盘。它存在的意义是把"每次事务提交一次写"合并成"按块写"。
  • 自适应哈希索引(adaptive hash index, AHI):InnoDB 观察等值查询的访问模式,若同一个索引前缀被频繁以相同方式访问,就在 buffer pool 里为它建一个哈希索引,把 B+ 树的 O(log n) 查找降为 O(1)。争议在于:哈希表本身占用 buffer pool 内存、由 innodb_adaptive_hash_index_parts(默认 8)分区保护、高并发下 rw-latch 会成为争用点,且它只对等值查询有效。因此在读写混合且热点分散的负载上,关掉它反而更稳。

注意

面试官追问的下一层:AHI 与"我们自己建哈希索引"有什么区别?AHI 是自动、局部、可丢弃的:它只覆盖被证明热的前缀,且丢失后不影响正确性;手建哈希索引是全局的、要显式维护、且不支持范围查询。

20.6 InnoDB 磁盘结构:表空间到行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 表空间 (tablespace)
 ├─ 系统表空间 ibdata1:数据字典、change buffer、双写缓冲等
 ├─ 独立表空间 t.ibd(innodb_file_per_table=ON,默认)
 ├─ 通用表空间(CREATE TABLESPACE ... 多个表共享)
 ├─ 临时表空间(ibtmp1,会话级排序/中间结果)
 └─ undo 表空间(undo_001/undo_002,8.0 起独立)
      │
      └─ 段 (segment):B+ 树的非叶子段/叶子段、回滚段。
         InnoDB 按段分配区,段内区可以不连续
            │
            └─ 区 (extent):连续 64 个页 = 64 × 16 KB = 1 MB
               (innodb_page_size 变小/变大时,区大小按 1 MB 折算:
                4 KB 页→256 页,32 KB 页→32 页)
                  │
                  └─ 页 (page):16 KB,B+ 树的节点 = 一页
                       │
                       └─ 行 (row):COMPACT / DYNAMIC 等行格式

为什么按区(1 MB)而不是按页分配:若每次只向表空间申请一页,同一棵 B+ 树的相邻页会散布在整个文件里,顺序扫描退化成随机 I/O。一次申请 1 MB 连续空间,能让 B+ 树的顺序扫描在一段连续磁盘区间内完成,把随机 I/O 变成顺序 I/O(SSD 随机读约 100 μs,机械盘随机读约 510 ms,顺序 I/O 可高出 12 个数量级)。同时"区"也是段空间回收的粒度。

20.7 页的物理结构

每一个 16 KB 页的结构(页内偏移从 0 开始,以默认 16 KB 页计):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
 偏移 0        ┌──────────────────────────────┐
               │ File Header(38 字节)        │ 页号、上一页/下一页页号、
               │                              │ 页类型、LSN、校验和
 38            ├──────────────────────────────┤
               │ Page Header(56 字节)        │ 槽数、堆顶位置、记录数、
               │                              │ 空闲空间起始、最大事务 ID
 94            ├──────────────────────────────┤
               │ Infimum(最小伪记录,13 字节)│ 页内下界,固定值
               ├──────────────────────────────┤
               │ Supremum(最大伪记录,13 字节)│ 页内上界,固定值
               ├──────────────────────────────┤
               │ User Records(用户记录)      │ 按主键升序,用 next_record
               │  ↑ 从 Free Space 空间"倒着"分配│ 串成单向有序链表
               ├──────────────────────────────┤
               │ Free Space(空闲空间)        │
               ├──────────────────────────────┤
               │ Page Directory(页目录)      │ 若干"槽"(slot),每个槽指向
               │                              │ 一条记录;槽对应记录按主键有序
 16384−8       ├──────────────────────────────┤
               │ File Trailer(8 字节)        │ 校验和 + 页 LSN 低 32 位
               └──────────────────────────────┘

Page Directory 与页内查找的关系:页目录把有序记录按每 48 条一组分成若干槽,槽内记录数控制在 48 之间(平衡二分深度与插入时的维护成本)。页内定位一条记录是两段式:

  1. 在页目录上用二分查找找到"最后一个大于等于目标值"的槽,复杂度 O(log n);2. 从该槽指向的记录沿 next_record 顺序遍历若干条(≤ 8 条)。页内查找因此既不是全链表遍历(O(n)),也不需要维护一棵树(插入代价高)。File Trailer 的校验和用于检测页写了一半(partial page write),与双写缓冲(doublewrite buffer)配合完成页级崩溃修复。

注意

面试官追问的下一层:既然页目录能二分,为什么不用"槽数固定、每槽 1 条记录"让二分更纯?因为槽的分裂与合并是有代价的:每次插入都可能引起页分裂并重排槽;槽越大(每槽记录越多)、目录越小,插入时改动的槽越少。4~8 这个区间是 InnoDB 权衡后的经验值。

20.8 行格式与行溢出

四种行格式的差异集中在"变长列怎么记、长值放哪":

行格式变长列长度信息NULL 信息长列(BLOB/大 VARCHAR)处理说明
REDUNDANT每个字段 1~2 字节偏移数组字节位图前 768 字节存在页内,其余溢出兼容旧版本
COMPACT变长字段长度列表(逆序、按需 1~2 字节)字节位图前 768 字节存页内,其余溢出5.0~5.6 常用
DYNAMIC同 COMPACT同 COMPACT20 字节指针,值全部溢出到溢出页5.7+ 默认
COMPRESSED同 DYNAMIC同 DYNAMIC溢出页以 zlib 压缩,页大小可 1/2/4/8 KB换 I/O 为 CPU

COMPACT 行的完整布局:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
 ┌──────────────────┬────────────────┬──────────────────────────────┐
 │ 变长字段长度列表  │ NULL 值列表    │        记录头信息(5 字节)    │
 │ 逆序存放,每个    │ 每列 1 bit,    │ delete_mask  next_record     │
 │ 变长列 1~2 字节   │ 只记可为 NULL   │ heap_no  record_type         │
 │                  │ 的列            │ n_owned  (5 字节共 40 bit)    │
 ├──────────────────┴────────────────┴──────────────────────────────┤
 │ 列 1 值 │ 列 2 值 │ 列 3 值 │ ... │ 主键列值 由 B+ 树顺序决定      │
 └──────────────────────────────────────────────────────────────────┘
   记录头信息的关键位:
     delete_mask  0=正常 1=已删除(标记删除,不立即回收空间)
     next_record  到下一条记录地址的偏移(负数表示向左);页内记录成链
     heap_no      页内堆序号;0=Infimum 1=Supremum,用户记录从 2 开始
     n_owned      该记录所"拥有"的槽内记录数,只有槽的"最大记录"才非 0
     record_type  0=普通 1=目录槽记录(非叶子节点) 2=Infimum 3=Supremum

行溢出与单行最大长度:DYNAMIC 下,长变长列在记录中只留 20 字节指针,真正的值放在溢出页(off-page)组成的链表里,链表的头 20 字节内记录"还剩多少字节"。因此一个 16 KB 的表空间页可以容纳远超 16 KB 的逻辑行。

但行内部分仍受页大小约束,于是有两条硬限制:

  • MySQL 层:VARCHAR 的最大声明长度为 65535 字节,且所有列的长度之和(含开销)不得超过 65535。InnoDB 层:单页 16 KB 减去页头(38 + 56 字节)、伪记录(13 + 13 字节)、页目录与校验和,可用空间约 8126 字节;再减去记录头与长度列表,单行页内部分上限约为 8000 字节量级。所以 CREATE TABLE t (a VARCHAR(65532)) 必然报 ERROR 1118 Row size too large。

警告

常见错误回答:"TEXT/BLOB 一定存在溢出页"。不是——只有长到放不下页内部分才会溢出。COMPACT 是"能放多少放多少,最多页内留 768 字节";DYNAMIC 是"要么完整留页内,要么整个挪到溢出页,只留 20 字节指针"。这也是 5.7 把默认行格式换成 DYNAMIC 的原因:指针固定 20 字节,让优化器对行长度的估计更稳定。

20.9 一条 UPDATE 的完整生命周期

UPDATE t SET v = v + 1 WHERE id = 1 的执行链路(每个编号是一个可崩溃点):

1
2
3
4
5
6
7
8
9
 ① 执行器调引擎接口,按主键定位记录(引擎接口带锁语义)
 ② 目标页不在 buffer pool → 从表空间文件读入 16 KB 页(随机 I/O 约 100 μs)
 ③ 写 undo log:记录"旧值"(v = 10)。undo 页同样先写 buffer pool 的 undo 页
 ④ 修改 buffer pool 中的页:v = 11。该页被标记为脏页(dirty page)
 ⑤ 写 redo log 到 log buffer,内容包含数据页改动与 undo 页改动
 ⑥ 事务进入 prepare 阶段:redo log 从 log buffer 刷盘并标记 prepare
 ⑦ 写 binlog 到 binlog 文件(按 sync_binlog 策略刷盘)
 ⑧ 事务提交:redo log 由 prepare 改为 commit,写 commit 记录
 ⑨ 释放行锁;⑩ 脏页由后台线程(page cleaner)异步刷回表空间,change buffer 中的相关页同时完成 merge
崩溃点崩溃后的恢复结果原因
③ 之前无影响什么都没改
③ 之后、⑥ 之前改动丢失(未提交)redo 未刷盘的这部分不存在;undo 已刷盘的部分由回滚段标注为已提交/未提交
⑥ 与 ⑦ 之间(redo 已 prepare,binlog 未写)回滚两阶段提交判定 binlog 不完整 → 事务不提交
⑦ 与 ⑧ 之间(binlog 已写,redo 未 commit)提交binlog 完整 → 按 binlog 内容补提交 redo
⑧ 之后、⑩ 之前数据不丢崩溃前已提交;脏页未落盘但 redo 可前滚重放

重要

上表最后两行是两阶段提交的全部意义:redo 的 prepare 与 binlog 的完整性共同决定了"提交还是回滚",从而保证 redo log 与 binlog 不会互相矛盾。完整推导与三种崩溃时机的恢复算法见第 21 章 21.5 节;本章只要求你能把链路串起来。

本章小结

关键结论一句话表述常见错误
架构分层的意义SQL 层不假设数据怎么存,引擎层可插拔认为"可插拔"是为了让用户随时换引擎
查询缓存被移除表级失效让写密集场景命中率趋零,成本却恒定答"因为它占用内存"
权限校验位置在预处理阶段,早于优化器答"在执行时才检查权限"
优化器选错索引根因是统计信息抽样误差 + 回表代价被低估答"优化器有 bug"或"加 FORCE INDEX 就好"
buffer pool 分段 LRUold 区 + innodb_old_blocks_time 挡住全表扫描污染认为 LRU 就是标准的最近最少使用
change buffer只服务非唯一二级索引,因为唯一性必须读页确认答"它能加速所有写"
按区分配1 MB 连续区让 B+ 树顺序扫描落在连续磁盘上答"为了防止碎片"却说不清碎哪种片
行溢出的条件只有页内放不下才溢出;DYNAMIC 只留 20 字节指针答"有 BLOB 就溢出"

自测题

Q1(计算)默认配置下,innodb_page_size = 16 KB,一个区有多少页、多大?若某表主键为 BIGINT(8 字节)+ 6 字节行开销,二级索引键为 INT(4 字节)+ 主键,估算一个 16 KB 叶子页能放多少条二级索引记录。

答案与解析

一个区 = 64 页 = 64 × 16 KB = 1 MB。若页大小改为 4 KB,则一个区 = 256 页(InnoDB 保持"区约 1 MB"的口径)。

二级索引记录 = 索引列 + 主键列 + 记录头 5 字节 + 变长长度列表 + 页目录与页头开销。按 4 + 8 + 5 ≈ 17 字节/条算,16 KB 页可用约 15 KB,理论上限约 15000 / 17 ≈ 880 条;实际 InnoDB 经验值是每页 500~1000 条,取决于是否留出页分裂余量(通常保留约 1/16 的空闲)。

错在哪:最常见错误是忘了二级索引里存主键(InnoDB 的二级索引叶子节点存的是主键值),只按 4 字节算,结果偏大一倍以上。

面试官会追问:那树高是多少?按每页 800 条、非叶子节点每页能放 16000 / (8 + 6) ≈ 1100 个"键 + 页号"算,三层 B+ 树可索引 1100 × 1100 × 800 ≈ 9.7 × 10⁸ 行——所以上亿行的表树高通常仍是 3,一次等值查询最多 3 次页访问。完整估算见第 23 章 23.1 节。

Q2(辨析)有人用 SELECT COUNT(*) FROM t 在 InnoDB 与 MyISAM 上做对比,得出"MyISAM 快得多,所以大表统计行数应该用 MyISAM"。请指出这个结论的问题,并给出 InnoDB 上的替代方案。

答案与解析

MyISAM 维护了一个精确的行计数,COUNT(*) 是 O(1);InnoDB 因为 MVCC 下"不同事务看到的行数可能不同",无法维护一个全局精确计数,只能选一个最小的二级索引做全索引扫描,是 O(n)。

但这个对比只在"只需要计数"这一种负载上成立。真正的问题是:为了一个 COUNT(*) 把整张表换成没有事务、没有行锁、崩溃后可能损坏的引擎,是把次要需求当成主要需求。替代方案:维护一张计数表(在同一事务里 INSERT ... ON DUPLICATE KEY UPDATE cnt = cnt + 1),或用 Redis 计数器并接受最终一致(一致性方案见第 27 章 27.8 节)。

错在哪:把"单语句的耗时对比"当成"引擎选型依据",忽略了并发写、崩溃恢复、事务语义这些加权项。

面试官会追问:COUNT(*) 和 COUNT(1) 和 COUNT(列) 有区别吗?COUNT(*) 与 COUNT(1) 语义与性能一致(都统计行数);COUNT(列) 要判断该列是否为 NULL,因此不会走最小的索引、也不能用列可空的优化。详见第 25 章 25.6 节。

Q3(推导)一张 1 亿行的订单表,EXPLAIN 显示主键范围查询(覆盖全表 40% 的行)走了主键索引,结果比全表扫描还慢。请从代价模型角度解释优化器为什么"看起来选对了却慢了",并给出两种干预方式。

答案与解析

优化器的判断链条是:走索引的代价 = 索引页访问 + 估计行数 × 回表代价;全表扫描代价 = 页数 × io_block_read_cost。它用 rows × filtered 估出约 4000 万行要回表,把回表折算成 memory_block_read_cost = 0.25(因为它假设页会命中 buffer pool)。可是 40% 的行意味着几乎每个数据页都要回一次,而顺序扫描每页只读一次;优化器把"随机回表"低估成了"廉价的顺序读"。干预方式是 FORCE INDEX/IGNORE INDEX 直接改变候选集,或者让统计信息更准(ANALYZE TABLE、加大 innodb_stats_persistent_sample_pages、对该列建直方图)。

错在哪:直接答"因为优化器有 bug"。优化器没有 bug,它在错误的输入假设下做了正确的比较;连"回表代价被低估在缓冲池命中率低时更严重"都说不出,就停在第一层。

面试官会追问:什么情况下走索引一定比全表扫快?大致分界是结果集占比低于约 20%~30%(经验值,取决于行宽、缓冲池命中率、是否覆盖索引);如果是覆盖索引(要的列都在索引里,不需要回表),即使扫全表也能走索引且更快。

Q4(设计)一张表的 id 是自增主键,业务要频繁执行 SELECT ... FROM t WHERE status = 1 ORDER BY created_at DESC LIMIT 20,status 只有 3 个取值。现在只有 status、created_at 两个单列索引,执行计划选择了 status 索引并出现 Using filesort。请解释原因并给出索引改造方案。

答案与解析

原因有两层:一是 status 只有 3 个取值,基数极低,优化器其实倾向于忽略它直接全表扫(或扫描 status 索引叶子);二是即使走了 status 索引,索引内部的顺序是 (status, 主键),created_at 并不按序,所以 ORDER BY created_at DESC 必须额外排序(Using filesort),而 LIMIT 20 救不了——排序要在取完全部匹配行之后才能确定前 20 条。方案是建联合索引,把等值列放前、排序列放后:

1
2
3
4
-- 为什么列顺序是 (status, created_at):等值条件列必须在前,
-- 排序列在后,这样在 status = 1 这个"等值前缀段"内,created_at 天然有序,
-- 优化器就能从索引末端(DESC)反向扫描 20 条即止,完全免排序
ALTER TABLE t ADD INDEX idx_status_created (status, created_at DESC);

错在哪:只答"加索引",不解释为什么单列索引不够——联合索引的可排序性来自"等值前缀 + 后续列有序"这个结构,而不是来自列的堆叠。

面试官会追问:如果业务还要 WHERE status = 1 AND type = 2 ORDER BY created_at DESC,索引该怎么建?顺序是 (status, type, created_at);若 type 的选择性比 status 高很多,也可以 (type, status, created_at),因为两个等值列之间的顺序不影响排序段的有序性,只影响扫描的起点范围。详见第 23 章 23.5 节。

Q5(推导)解释:为什么 innodb_old_blocks_time 默认 1000 ms,而一次全表扫描通常在 200 ms 内就能读完 128 MB 的 buffer pool?这个参数设成 0 会发生什么?

答案与解析

机制是"分两次访问才晋升"的时间门槛。新页进入时插在 old 子链头部;只有在 old 区停留超过 1000 ms 后又被访问的页,才会被移动到 young 区头部。顺序扫描对每一页的访问只发生一次(或两次,间隔极短),因此永远达不到 1000 ms 的门槛,扫完就自己在 old 区尾部被淘汰,young 区的热点数据完全不受影响。设成 0 时,任何页只要被再次访问就立刻晋升到 young 区头部——这等于取消了保护:一次全表扫描会把热点页全部挤出,缓存命中率断崖式下降。

错在哪:把 innodb_old_blocks_time 理解成"页的存活时间"或"淘汰延迟"。它约束的是晋升资格,不是淘汰时间。

面试官会追问:那 innodb_old_blocks_pct = 37 又是什么?它是 old 区占整个 LRU 链表的比例(默认 37%,即约 3/8)。调大它会让"抗扫描"能力变强(更多页放在易淘汰区),代价是热点页能占据的空间变小、有效缓存容量下降。两者要一起看。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
MySQL 的逻辑架构分几层?一条查询具体经过哪些组件?是否真的读过流水线,而不是背名字错误回答:“连接层、SQL 层、引擎层"三个词就结束,说不出权限校验在预处理阶段、也说不出执行器要对每行做条件复核。加分:主动区分"解析树"与"执行计划"是两个不同产物20.1、20.2
查询缓存为什么在 MySQL 8.0 被删掉了?是否理解失效粒度与收益成本错误回答:“因为它占内存"或"因为 Redis 更快”。加分:指出失效是表级的、写密集场景命中率趋零,且删掉它还省了一次全局互斥20.1.2
优化器为什么会选错索引?统计信息、代价模型、回表估算三层错误回答:“优化器有 bug"或"MySQL 的优化器太弱”。加分:说清 Cardinality 来自 innodb_stats_persistent_sample_pages 默认 20 页抽样、回表代价被按内存成本折算20.3
ANALYZE TABLE 到底做了什么?能随便在生产上跑吗?采样机制与执行代价错误回答:“就是重建索引"或"就是把统计信息刷新一下,没影响”。加分:说明它重新采样并写 innodb_index_stats,大表上耗时与表大小成正比且持表级 MDL20.3.4
InnoDB 的 buffer pool 是怎么淘汰页的?分段 LRU 与防扫描污染错误回答:“标准 LRU,最久未用的先淘汰”,说不出 young/old 分区与 innodb_old_blocks_time 的作用。加分:给出 37% / 1000 ms 两个默认值与它们各自解决的问题20.5.1
change buffer 为什么不能用于唯一索引?唯一性检查的读依赖错误回答:“因为唯一索引更重要"或"因为唯一索引要回表”。加分:指出唯一性必须在写入前读页确认,页既然已读入就无需再缓冲;再说出"写多读少 + 二级索引多"时收益最大20.5.2
InnoDB 的页里到底长什么样?页内怎么找到一条记录?页物理结构与 Page Directory错误回答:“就是一条条记录连着放”。加分:说出 Infimum/Supremum 伪记录、next_record 单向链表、Page Directory 每槽 4~8 条记录、两段式查找(目录二分 + 链内 ≤8 条遍历)20.7
一张表最多能存多大的行?为什么 VARCHAR(65532) 建表会失败?行溢出与页大小约束错误回答:"TEXT 会溢出,所以可以随便大"或"最大就是 65535”。加分:区分 MySQL 层的 65535 与 InnoDB 层的约 8000 字节页内上限,并说明 DYNAMIC 与 COMPACT 的溢出差异20.8
一条 UPDATE 语句从接收到落盘,中间发生了什么?能否把链路串成因果错误回答:逐个背"改 buffer pool、写 undo、写 redo、写 binlog"却说不清顺序为什么必须这样。加分:指出 prepare 之后 binlog 是否完整决定提交还是回滚,并引出第 21 章20.9

延伸阅读

  • MySQL 官方手册《MySQL Internals Manual》的 Server Layer、Handler API 与《The InnoDB Storage Engine》的 InnoDB Buffer Pool、Change Buffer、Adaptive Hash Index 两处:20.1.3 节的可插拔接口与 20.5 节全部参数的默认值口径。
  • MySQL 官方手册《InnoDB Row Formats》《Overview of InnoDB Page Structure》与《Optimizer Statistics》《Cost Model》《File Space Management》:20.3、20.6、20.7、20.8 节的字段级定义与统计信息机制。
  • MySQL EXPLAIN 输出格式说明与 SHOW ENGINE INNODB STATUS 的 BUFFER POOL AND MEMORY 段:观测 buffer pool 实际命中率与页状态的手段。
  • 《MySQL 是怎样运行的:从根儿上理解 MySQL》:页内结构、行格式与 buffer pool 的分段 LRU 有逐字节级别的图示,可与 20.7、20.8 节对照阅读。