MySQL 核心架构速览

理解 MySQL 的工作原理是所有优化的前提。

MySQL 核心架构图

在 InnoDB 中,数据不是杂乱无章丢进文件的,而是有着极其严密的层级结构:

表空间 -> 段 -> 区 -> 页 -> 行。其中 页(Page) 是磁盘与内存交互的基本单位,行记录就保存在页里。下面从最底层的存储结构讲起。

存储结构

页(Page):最小存储单位

行格式(Row Format)

MySQL 提供了几种行格式(如 Compact, Redundant, Dynamic, Compressed)。目前最常用的是 Dynamic(MySQL 5.7 后的默认格式)。

一行的存储结构主要分为两个部分:记录的额外信息记录的真实数据

记录的额外信息(Metadata)——让数据库引擎能“读懂”这一行:

  1. 变长字段长度列表:记录像 VARCHARTEXT 这种变长字段实际占用了多少字节。
  2. NULL 值列表:用位图(Bit Map)记录哪些列是 NULL,这样就不需要为 NULL 值浪费实际存储空间。
  3. 记录头信息:包含一些标志位(比如该行是否被删除、下一条记录的相对位置指针等)。

记录的真实数据(Real Data)——除了你定义的字段(如 name, age)外,InnoDB 还会自动加上三个隐式列

大字段与溢出页

如果一个字段特别大(比如 VARCHAR(65535)BLOB),一个 16KB 的页根本装不下:

这也是 为什么一行不能超过 8KB 的原因:虽然一个页有 16KB,但 MySQL 规定一个页至少要存 2 行数据(为了保证 B+ 树的层级结构稳定)。加上页头、页尾的开销,如果你定义的所有列加起来超过了约 8KB,MySQL 就会强制将变长字段转移到溢出页中。

页内的行排列

在页内部,行记录是按主键顺序排列成一个单向链表的。

为了提高查询效率,页内还有一个 页目录(Page Directory)。它将行记录分成几个小组(Slot),通过二分法快速定位到具体的行,避免了从头到尾遍历链表的低效。

总结:插入一行发生了什么

当你插入一行数据时,MySQL 实际上是在:

  1. 计算变长字段长度和 NULL 值情况。
  2. 加上事务 ID 和回滚指针。
  3. 将其塞进 B+ 树叶子节点的某个 16KB 的页里。
  4. 如果太大了,就“分家”存到溢出页。

延伸思考:当这一行数据被删除时,它在磁盘上是立即消失了,还是只是被标记了一下?(这涉及到了数据页的碎片处理)

索引(重点中的重点)

索引是面试中出现率最高的话题。

数据结构:B+ Tree

MySQL 主要使用 B+ Tree

B+ 树的阶(每个节点最多子节点数)在表创建时即确定,由索引键大小和页大小共同决定:

$$\text{页大小 (16KB)} \ge (\text{阶数 } M - 1) \times \text{索引键大小} + M \times \text{指针大小} + \text{页头元数据}$$

聚簇索引 vs 非聚簇索引

最左匹配与回表

事务与并发控制

ACID 特性

一致性核心定义:从一个合法状态到另一个合法状态。 一致性指事务执行前后,数据库必须处于合法状态,即满足所有完整性约束与业务不变量。

什么是“合法”状态:

  • 数据库完整性约束:主键唯一、NOT NULL、外键引用有效、数值范围限制(例如余额不能为负)等。
  • 业务实体完整性(业务不变量):由应用定义的规则。例如:在转账操作中,A 向 B 转账 100 元时,A 的余额需减少 100、B 的余额需增加 100,总金额保持不变。

如果事务执行完,导致数据库违反了上述任何一条规则,那这个事务就打破了一致性,数据库必须将其回滚。

RR 级别下 ACID 是如何实现的

MySQL 默认隔离级别是 REPEATABLE READ,以 InnoDB 为例,ACID 四性的实现分别依赖不同的底层机制:

并发问题详解

并发访问事务带来的三大问题:

隔离级别

  1. 读未提交 (Read Uncommitted)
  2. 读已提交 (Read Committed)
  3. 可重复读 (Repeatable Read) —— MySQL 默认级别,通过 MVCC + Next-Key Lock 解决大部分并发问题。
  4. 串行化 (Serializable)

MVCC(多版本并发控制)

MVCC 是 InnoDB 实现高并发读取的核心机制:

快照读与当前读

InnoDB 的所有访问可归为两类,理解二者差异是理解锁与 MVCC 的前提:

快照读(普通 SELECT,走 MVCC)

当前读(SELECT ... FOR UPDATE / LOCK IN SHARE MODE / UPDATE / DELETE / INSERT,走锁)

两个实用结论:

Spring 提示:@Transactional 只管理事务边界,不会自动加锁——普通 SELECT 依旧是不加锁的快照读。只有显式使用 SELECT ... FOR UPDATE,或配置 isolation = SERIALIZABLE(MySQL 会把普通 SELECT 隐式转为 LOCK IN SHARE MODE)才会消除幻读残余。

Repeatable Read 如何解决并发问题

快照读(普通 SELECT,走 MVCC):

当前读SELECT ... FOR UPDATE / UPDATE / DELETE,走锁):

注意:Snapshot Isolation(MVCC 的快照机制)并不完全等价于 SERIALIZABLE。在可重复读下,仍然可能发生逻辑异常,特别是在涉及多行或跨表约束的场景中。

RR 的逻辑缺陷与防护

RR 虽然消除了脏读、不可重复读和大部分幻读,但 MVCC 快照隔离仍存在几种逻辑并发异常。它们不属于标准的三类并发问题,却会在涉及多行或跨表约束的场景下悄悄破坏业务一致性,需要额外的防护手段。

Write Skew(写倾斜)

名称解释:Write Skew(写倾斜/写偏斜)是快照隔离(MVCC)下特有的逻辑并发异常。两个并发事务各自基于旧快照读取数据并做出判断,随后写入互不冲突的不同行,每个事务单独提交都满足约束,但合在一起却破坏了全局业务约束。它不属于脏读、不可重复读、幻读中的任何一类。

具体问题(医生值班例子):

为什么 RR 挡不住:MVCC 快照读不加锁,两个事务各读各的快照;写入的又是不相交的行,Next-Key Lock 也无法阻止。

丢失更新(Lost Update)

名称解释:两个事务并发读取同一行,各自基于读到的旧值在应用层计算后写回,后提交者覆盖先提交者的修改,导致一次更新"丢失"。

具体问题:计数器场景。T1 读到 count=100,T2 也读到 count=100;T1 写回 101,T2 基于旧值也写回 101 → 最终是 101,本应为 102

为什么 RR 会中招:快照读不加锁,两个事务读到的都是旧版本;若写回时用的是应用层算好的旧值而非原子自增,后提交者就会覆盖先提交者的结果。

幻读残余(Phantom Read)

名称解释:Next-Key Lock 只对当前读生效。当同一事务先做快照读、后做当前读时,仍可能看到中途插入并提交的新行,即"幻读"并未被完全消灭。

具体问题:T1 先 SELECT * FROM orders WHERE amount>100(快照读)得到 2 行;期间 T2 插入 amount=200 并提交;T1 再执行 UPDATE orders SET ... WHERE amount>100(当前读)→ 会"看见"并处理那行新数据,之前不存在的行突然出现。原因是快照读与当前读混用,二者看到的数据版本不一致。

只读事务异常(Read-only Transaction Anomaly)

名称解释:快照隔离下,一个只做 SELECT 的事务也可能读到在任意串行执行顺序下都不会出现的组合数据。

具体问题:两个写事务分别破坏了同一约束的不同片段并提交(例如上面的医生值班 write skew 已经发生);只读事务随后按快照把两边的"新状态"一起读到,拼出的整体状态自相矛盾——没有任何串行执行能产生这种结果。

防护策略

针对上述逻辑缺陷,常见的防护手段:

事务与锁

两阶段锁协议(2PL)

InnoDB 的行锁实现基于两阶段锁协议:

这种协议保证了可串行化执行,避免了常见的并发异常。

锁的粒度与类型

InnoDB 支持多种锁:

意向锁(Intention Lock)

意向锁是表级锁,由 InnoDB 自动添加,用于标记"事务打算对表内的某些行加锁",按锁的意图分为两种:

它的作用是把"表级锁"与"行级锁"之间的冲突判断提前到表层面:当事务准备对某行加锁时,会先在表上获取对应的意向锁;当其他事务申请表级 S / X 锁(如 LOCK TABLESALTER TABLE)时,只需检查表上的意向锁即可快速判断是否有行锁冲突,而无需逐行扫描全表。

意向锁之间是兼容的,但它们与表级锁存在冲突关系:

当前锁 \ 请求锁S(表级)X(表级)ISIX
S(表级)兼容冲突兼容冲突
X(表级)冲突冲突冲突冲突
IS兼容冲突兼容兼容
IX冲突冲突兼容兼容

关键点:多个事务可以在不同行上分别加行锁(各自持有 IX 锁互不阻塞),但当某个事务需要表级排他锁时,会因已存在的 IX 锁而阻塞等待,从而保证表级锁的互斥性。

读写锁(共享锁 / 排他锁)

InnoDB 的锁分为两类:

常见场景:

锁的释放与死锁

进阶提示

SQL 优化与排查

SQL 优化是面试与实战的核心场景,通常遵循"定位慢查询 → EXPLAIN 分析 → 优化索引/改写 SQL"的闭环。

索引设计原则

设计索引不是越多越好,需要权衡查询加速与写入/空间开销:

用 EXPLAIN 分析 SQL

在慢 SQL 前加 EXPLAIN 即可查看执行计划,关键列如下:

含义关注点
type访问类型从好到差:const > eq_ref > ref > range > index > ALLALL 表示全表扫描,是重点优化对象。
key实际使用的索引NULL 说明没走索引。
rows预估扫描行数越小越好,用于对比优化前后效果。
Extra附加信息出现 Using filesortUsing temporary 通常意味着性能隐患;Using index 表示覆盖索引,是好信号。

常见分析路径:

filesort 怎么解决

Using filesort 表示 MySQL 无法利用索引的有序性,只能在排序缓冲(sort_buffer_size)甚至磁盘上额外排序。

产生原因与解法:

排查顺序:先看 EXPLAINExtra 是否出现 Using filesort,再结合 key 判断是没索引还是索引序不匹配,最后按上述规则调整。

怎么发现慢 SQL

怎么排查锁问题

锁等待与死锁的排查入口:

排查思路:先定位 LOCK WAIT 的事务 → 找到阻塞它的持有者 → 检查持有者是否未提交或扫描范围过大 → 缩短事务、缩小锁范围或优化索引。

高频问题

题目考察点核心回答关键词
MySQL 为什么选 B+ 树?数据结构磁盘 IO 效率、范围查询、叶子节点双向链表。
什么是慢查询?如何优化?实战能力EXPLAIN 分析、索引失效排查、深分页优化。
如何设计索引?索引设计区分度优先、最左匹配、覆盖索引避免回表、控制索引数量。
EXPLAIN 怎么看?执行计划type(const→eq_ref→ref→range→index→ALL)、keyrowsExtra(filesort/temporary)。
如何解决 Using filesort?排序优化排序列建索引、索引序与排序序一致、排序列紧跟在等值列后、覆盖索引。
如何排查死锁?锁排查SHOW ENGINE INNODB STATUSinnodb_trxinnodb_lock_waitsperformance_schema
binlog, redolog, undolog 区别?日志系统崩溃恢复 (redo)、数据备份 (bin)、事务回滚 (undo)。
InnoDB 如何解决幻读?锁机制Next-Key Locks (行锁 + 间隙锁) + MVCC。
主从复制的原理是什么?高可用binlog、dump thread、relay log (中继日志)。

避坑指南(进阶建议)

  1. 别在索引列上做运算SELECT * FROM t WHERE id + 1 = 10; 会导致全表扫描。
  2. 小心隐式类型转换:索引列是字符串却传入数字(或反之),MySQL 会做类型转换导致索引失效。
  3. 模糊匹配别用前导通配符LIKE '%xxx' 无法利用索引,只有 LIKE 'xxx%' 可以走范围扫描。
  4. OR 要两边都能走索引WHERE a = 1 OR b = 2b 无索引,会退化为全表扫描;可改写为 UNION 或建联合索引。
  5. 小心 SELECT *:这通常是性能杀手,且无法利用覆盖索引。
  6. 深度分页LIMIT 1000000, 10 会扫描前一百万行,建议通过"上一页最大 ID"或延迟关联优化(详见 SQL 优化与排查章节)。

参考