MySQL 架构、数据管理与数据流动学习文档
面向对象:已经会写
SELECT/INSERT等基本语句,但想真正理解"一条 SQL 语句进去之后,MySQL 内部到底发生了什么"的开发者。阅读方式建议:不必从头背诵,遇到工作中的实际问题(比如"为什么这条查询这么慢""为什么加了索引还是全表扫描")时,回来对照相应章节。
目录
- MySQL 整体架构总览
- 一条 SQL 语句的完整生命周期
- INSERT / SELECT 基础回顾(从语法到底层)
- InnoDB 存储引擎深度剖析
- 索引原理(B+树、聚簇索引、联合索引)
- 复杂查询案例解析(GROUP BY / JOIN / HAVING / LIMIT / ORDER BY)
- 数据流动全景图(总结)
- 诊断与优化工具
- 延伸学习路线
第一章:MySQL 整体架构总览
MySQL 的架构可以分为两大块:Server 层(所有存储引擎共用)和 存储引擎层(可插拔,最常用的是 InnoDB)。
┌─────────────────────────┐
│ 客户端 / 应用 │
└────────────┬─────────────┘
│ TCP / Socket
┌────────────▼─────────────┐
│ 连接器 (Connectors) │ ← 建立连接、权限校验、维持长连接
└────────────┬─────────────┘
┌─────────────────────────────┼─────────────────────────────┐
│ Server 层 │
│ ┌────────────┐ ┌───────────────┐ ┌────────────────┐ │
│ │ 查询缓存* │→│ 解析器(Parser) │→│ 优化器(Optimizer)│ │
│ │ (8.0已移除) │ │ 词法/语法分析 │ │ 生成执行计划 │ │
│ └────────────┘ └───────────────┘ └────────┬───────┘ │
│ │ │
│ ┌─────────▼────────┐ │
│ │ 执行器(Executor) │ │
│ └─────────┬────────┘ │
└───────────────────────────────────────────────┼───────────┘
│ 调用存储引擎接口 (API)
┌─────────────────────────────────────────────────────────────┐
│ 存储引擎层 │
│ InnoDB(默认,支持事务/行锁/外键) MyISAM Memory ... │
└────────────────────────────┬────────────────────────────────┘
│
┌────────────▼─────────────┐
│ 磁盘文件 / 文件系统 │
│ 表空间(.ibd) / redo log / │
│ undo log / binlog / ... │
└───────────────────────────┘
1.1 各组件职责
| 组件 | 职责 |
|---|---|
| 连接器 | 建立 TCP 连接、身份认证、权限判断,管理连接池/长连接 |
| 解析器 | 对 SQL 做词法分析(识别关键字、表名、字段名)和语法分析(是否符合 SQL 语法结构),生成语法树 |
| 优化器 | 决定"怎么做":用哪个索引、多表 JOIN 时谁做驱动表、GROUP BY 是否需要临时表等,产出执行计划 |
| 执行器 | 按执行计划真正调用存储引擎的接口去存取数据,并进行权限二次检查 |
| 存储引擎 | 真正负责数据的存储与读取、事务、锁、崩溃恢复等(这是本文档的重点) |
注意:MySQL 8.0 已经彻底移除查询缓存(Query Cache),因为它的失效策略太粗暴——只要表有一次写操作,这张表相关的所有缓存就全部失效,在写多读少的场景下命中率很低,反而是负优化。如果你在网上看到"查询缓存"的教程,要留意是不是针对旧版本(5.7 及更早)写的。
1.2 存储引擎的可插拔性
MySQL 的一个关键设计是"Server 层与存储引擎层解耦":Server 层通过一套统一的接口(handler API)去调用存储引擎,因此不同的表可以使用不同的引擎(虽然实践中几乎总是统一用 InnoDB)。这也是为什么 CREATE TABLE ... ENGINE=InnoDB 这行语句能生效的原因——引擎是可以替换的插件。
本文档之后所有涉及"存储引擎层"的内容,都特指 InnoDB,因为它是 MySQL 5.5 之后的默认引擎,也是绝大多数生产环境使用的引擎。
第二章:一条 SQL 语句的完整生命周期
在深入细节前,先建立一个整体的"数据流动"直觉。
2.1 SELECT 语句的旅程
SELECT name FROM user WHERE id = 10;
- 连接器:校验你是否有权限连
user表。 - 解析器:识别出这是一条
SELECT,表是user,条件是id = 10。 - 预处理器:检查表、字段是否存在,展开
*等。 - 优化器:判断
id是否有索引(主键天然是聚簇索引),决定采用主键索引查找而不是全表扫描。 - 执行器:
- 调用 InnoDB 的接口,"取 id=10 的第一行";
- InnoDB 先看 Buffer Pool(内存) 里有没有这一页数据;
- 有 → 直接从内存返回;
- 没有 → 从磁盘的
.ibd表空间文件里读取该数据页,加载进 Buffer Pool,再返回。
- 执行器判断是否还有下一行满足条件(本例中主键唯一,只有一行),返回结果集。
2.2 INSERT 语句的旅程(这里是大多数人理解模糊的地方)
INSERT INTO user(name, age) VALUES ('Alice', 20);
- 解析器/优化器/执行器同上,最终执行器调用 InnoDB 的写入接口。
- InnoDB 在 Buffer Pool 中找到(或从磁盘加载)该记录应该插入的数据页,直接在内存里完成插入——这一步不会立刻写磁盘的数据文件。
- 为了保证"就算现在服务器断电,这条数据也不会丢",InnoDB 会:
- 把这次修改写入 redo log(重做日志) 的日志缓冲(redo log buffer),并根据
innodb_flush_log_at_trx_commit配置,在事务提交时将其持久化到磁盘的 redo log 文件(这就是常说的 WAL:Write-Ahead Logging,预写日志)。 - 同时记录 undo log(回滚日志),用于事务回滚和 MVCC(后面详解)。
- 把这次修改写入 redo log(重做日志) 的日志缓冲(redo log buffer),并根据
- Server 层(不是 InnoDB)另外写一份 binlog(归档日志),用于主从复制和数据恢复。
- 被修改过、但还没落盘到
.ibd文件的数据页,称为"脏页",会在后台由后台线程按一定策略异步刷盘(这个过程和事务是否提交没有强绑定,提交只保证 redo log 落盘)。
关键认知:InnoDB 的设计哲学是"先写日志,再落数据"。数据页本身什么时候真正写回磁盘并不重要(可以晚一点,靠后台线程慢慢刷),重要的是redo log 必须在事务提交时落盘——这样即使数据库崩溃,重启后也能靠 redo log 把内存里"应该发生但还没写到数据文件"的修改重新做一遍,这就是"崩溃恢复(crash recovery)"能够实现的根本原因。
这一整套关系会在第四章展开。
第三章:INSERT / SELECT 基础回顾
这一章快速过一遍语法,作为后续复杂案例的基础。已经熟悉的部分可以跳过。
3.1 INSERT
-- 全字段插入
INSERT INTO user(name, age, city) VALUES ('Alice', 20, 'Singapore');
-- 批量插入(比多条单独 INSERT 快得多——因为共用一次网络往返和一次事务提交的日志刷盘开销)
INSERT INTO user(name, age) VALUES ('Bob', 22), ('Carol', 25), ('Dave', 30);
-- 插入并处理主键/唯一键冲突
INSERT INTO user(id, name) VALUES (1, 'Alice')
ON DUPLICATE KEY UPDATE name = VALUES(name);
性能提示:批量插入优于循环单条插入,本质原因是——每条独立的 INSERT 若各自提交事务,都会触发一次 redo log 落盘(fsync),fsync 是磁盘 I/O 中最昂贵的操作之一;合并成一个事务或一条多值 INSERT,可以把 N 次 fsync 降为 1 次。
3.2 SELECT
SELECT name, age FROM user WHERE city = 'Singapore' AND age > 18;
执行顺序(逻辑上,不是书写顺序):
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT
这个顺序非常重要,直接决定了你能不能在 WHERE 里用 SELECT 里定义的别名(不能,因为 WHERE 先于 SELECT 执行),以及为什么 HAVING 能用聚合函数而 WHERE 不能(因为 WHERE 执行时聚合还没发生)。
第四章:InnoDB 存储引擎深度剖析
这是理解 MySQL"底层实现"的核心章节。
4.1 InnoDB 内存结构
┌───────────────────────────────────────────────────────────┐
│ InnoDB 内存结构 │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Buffer Pool(缓冲池) │ │
│ │ - 缓存数据页(Data Page)和索引页 │ │
│ │ - 采用改良版 LRU 算法(防止全表扫描把热数据冲掉) │ │
│ │ - 大小由 innodb_buffer_pool_size 控制 │ │
│ │ - 生产环境经验值:物理内存的 50%~75% │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────┐ ┌───────────────────────┐ │
│ │ Change Buffer │ │ Adaptive Hash Index │ │
│ │ (变更缓冲区) │ │ (自适应哈希索引 AHI) │ │
│ │ 非唯一二级索引的写 │ │ 对高频访问的 B+树路径 │ │
│ │ 操作先缓存,避免 │ │ 自动建立哈希索引,加速 │ │
│ │ 随机 I/O │ │ 等值查询 │ │
│ └───────────────────┘ └───────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Log Buffer(redo log 缓冲区) │ │
│ │ redo log 先写这里,再按策略刷到磁盘 redo log 文件 │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
Buffer Pool 为什么要用"改良版" LRU?
如果用最朴素的 LRU(最近最少使用淘汰),一次大范围的全表扫描(比如一个跑批脚本 SELECT * FROM big_table)会把大量只用一次的数据页塞满 Buffer Pool,把真正的热点数据挤出去。InnoDB 的做法是把 LRU 链表分成两段:新读入的页先进入 old 区,只有在 old 区停留超过一定时间(innodb_old_blocks_time)后又被再次访问,才会被提升进 young 区。这样"只扫一次"的冷数据不会污染真正的热数据。
Change Buffer 解决什么问题? 如果你在一个没有唯一约束的二级索引列上做大量插入/更新,理论上每次都要去检查、修改对应的索引页——而这个索引页很可能不在 Buffer Pool 里(因为二级索引的顺序和插入顺序通常不一致,导致随机 I/O)。InnoDB 的做法是:如果该索引页不在内存中,就先把这个改动记录到 Change Buffer 里,等之后这个页因为别的原因被读入内存时,再"合并"(merge)进去。这样把随机 I/O 变成了顺序的、延迟的批量操作。注意:唯一索引不能用 Change Buffer,因为唯一性检查必须立刻读取索引页判断是否冲突,无法延迟。
4.2 InnoDB 磁盘结构
| 文件/结构 | 作用 |
|---|---|
表空间文件(.ibd,或共享表空间 ibdata1) |
真正存放数据和索引的地方,按"页"(默认 16KB)为单位组织 |
redo log(ib_logfile* / 8.0 后的 #innodb_redo) |
循环写入的物理日志,用于崩溃恢复,实现 WAL |
| undo log(存于 undo 表空间) | 记录"如何撤销这次修改",用于事务回滚和 MVCC 读快照 |
| binlog | Server 层产生(非 InnoDB 独有),记录逻辑操作,用于主从复制、时间点恢复(PITR) |
| double write buffer | 防止"页数据写了一半就断电"导致的页损坏(部分写失效问题) |
redo log vs binlog 的本质区别(面试高频考点):
| redo log | binlog | |
|---|---|---|
| 产生层 | InnoDB 引擎层,其他引擎没有 | Server 层,所有引擎都有 |
| 内容 | 物理日志:"某个数据页的某个位置改成了什么" | 逻辑日志:"这条 SQL 做了什么"(或行级变更) |
| 写入方式 | 循环写,空间固定,写满会覆盖(前提是对应数据已刷盘) | 追加写,可以一直保留归档 |
| 作用 | 崩溃恢复(保证持久性) | 主从复制、数据备份恢复 |
4.3 两阶段提交(2PC):为什么需要它
一个事务提交时,redo log 和 binlog 是两个独立的组件各自写各自的日志文件,如果不做协调,就可能出现"redo log 写完了,但 binlog 还没写,机器就崩了"这种情况——恢复后数据本身是有这次修改的(因为 redo log 生效了),但 binlog 里没有记录,导致从库/备份里永远缺这一条数据,主从数据不一致。
InnoDB 用两阶段提交解决这个问题:
1. 事务执行,产生 redo log,标记为 prepare 状态(未提交)
2. 写 binlog
3. 将 redo log 标记为 commit 状态(真正提交)
崩溃恢复时的判断逻辑:
- 如果 redo log 是 prepare 状态,且对应的 binlog 不完整 → 回滚该事务;
- 如果 redo log 是 prepare 状态,但 binlog 完整 → 提交该事务(补齐 commit 标记);
- 如果 redo log 已经是 commit 状态 → 什么都不用做。
这样保证了 redo log 和 binlog 逻辑上的一致性,间接保证了主库和从库数据的一致性。
4.4 事务与 ACID 是怎么实现的
| ACID 特性 | 实现机制 |
|---|---|
| Atomicity 原子性 | undo log:事务失败/回滚时,靠 undo log 把已做的修改"反做"一遍 |
| Consistency 一致性 | 是其他三者共同作用的结果,加上应用层的约束(外键、check约束、业务逻辑)一起保证 |
| Isolation 隔离性 | MVCC(多版本并发控制)+ 锁机制(行锁、间隙锁、Next-Key Lock) |
| Durability 持久性 | redo log(WAL),事务提交前必须保证 redo log 落盘 |
4.5 MVCC 原理详解
MVCC(Multi-Version Concurrency Control)是 InnoDB 实现"读不加锁、读写不冲突"的核心机制,仅对 REPEATABLE READ 和 READ COMMITTED 隔离级别下的普通 SELECT(快照读)生效。
每一行记录,InnoDB 都会隐式附带几个额外的列:
DB_TRX_ID:最后一次修改这行数据的事务 IDDB_ROLL_PTR:指向 undo log 中,这一行"上一个版本"的指针DB_ROW_ID:如果没有主键/唯一键,InnoDB 会用这个隐藏列生成聚簇索引
这样,每一行数据的历史版本会通过 DB_ROLL_PTR 串成一条版本链:
当前行(trx_id=105) → undo log(trx_id=101的版本) → undo log(trx_id=98的版本) → ...
ReadView(读视图) 是判断"我应该看到哪个版本"的核心数据结构,主要包含:
trx_ids:生成 ReadView 那一刻,系统中所有活跃(未提交)的事务 ID 列表min_trx_id/max_trx_id:活跃事务中的最小值 / 目前已分配的最大事务 ID + 1creator_trx_id:生成这个 ReadView 的事务自己的 ID
沿着版本链从新到旧查找,对每个版本的 trx_id 做判断:
- 如果
trx_id是自己(creator_trx_id)→ 可见(自己改的当然看得见) - 如果
trx_id < min_trx_id→ 说明这个版本在 ReadView 生成前就已提交 → 可见 - 如果
trx_id >= max_trx_id→ 说明这个版本是 ReadView 生成之后才开启的事务改的 → 不可见,继续找更老的版本 - 如果
min_trx_id <= trx_id < max_trx_id且在trx_ids列表中 → 说明这个事务生成 ReadView 时还没提交 → 不可见,继续找更老的版本
READ COMMITTED 与 REPEATABLE READ 的核心区别:前者是每次 SELECT 都生成一个新的 ReadView(所以同一事务内两次查询可能看到不同结果,即"不可重复读");后者是事务开始时只生成一次 ReadView,整个事务期间复用(所以能保证可重复读)。这也是为什么很多人说"MySQL 的可重复读级别在一定程度上已经解决了幻读问题"——但注意,这只对快照读成立,对当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)依然需要靠 Next-Key Lock(记录锁+间隙锁)来防止幻读。
第五章:索引原理
5.1 为什么 InnoDB 选择 B+树
| 数据结构 | 问题 |
|---|---|
| 哈希表 | 等值查询 O(1) 很快,但不支持范围查询(>、<、BETWEEN)和排序 |
| 二叉搜索树 | 数据量大时树会退化成链表(或很深),磁盘 I/O 次数太多 |
| 平衡二叉树/红黑树 | 虽然平衡,但每个节点只能存 1 个 key,树的高度随数据量线性增长,I/O 次数仍然多 |
| B树 | 多叉,矮胖,减少了树高;但每个节点都存数据,范围查询需要在树的多层间反复横跳 |
| B+树(InnoDB 的选择) | 所有数据只存在叶子节点,非叶子节点只存索引键起"路标"作用,叶子节点之间用双向链表连接 |
B+树的关键优势:
1. 非叶子节点不存数据,只存 key,因此一个节点能容纳更多的 key(InnoDB 默认页大小 16KB,一个整数主键+指针大约占用 14 字节左右,一页可以存上千个 key),使得树非常"矮胖"——常见的三层 B+树,通常就足以支撑千万级别的数据行,也就是说,任何一行数据,最多只需要 3 次磁盘 I/O 就能找到(实际上根节点和常用的中间节点通常常驻 Buffer Pool,真实 I/O 往往只需 1 次)。
2. 叶子节点是双向链表,做范围查询(WHERE id BETWEEN 10 AND 20)或排序,只需要定位到起点后沿着链表顺序遍历,不需要多次回到上层节点。
[50]
/ \
[20] [80]
/ \ / \
[10,15] [20,35] [50,65] [80,95] ← 非叶子节点:只做路标
│ │ │ │
└───────┴───────┴───────┘ ← 叶子节点用链表相连,
(每个叶子节点里存的才是 支持范围扫描和排序
完整数据行 或 主键值)
5.2 聚簇索引 vs 二级索引(非聚簇索引)
InnoDB 是索引组织表——数据本身就按照主键的顺序存放在 B+树的叶子节点里,这棵树叫聚簇索引(Clustered Index)。
- 如果表定义了主键,InnoDB 用主键构建聚簇索引;
- 如果没有主键,但有非空的唯一索引,InnoDB 会用它;
- 如果两者都没有,InnoDB 会自动生成一个隐藏的
DB_ROW_ID作为聚簇索引的 key。
这就是为什么强烈建议每张表都要显式定义主键——否则你无法控制数据的物理存储顺序,也失去了用主键做范围查询/排序的天然优势。
其他所有索引(比如你在 name 列上建的索引)叫二级索引(Secondary Index),它的叶子节点存的不是整行数据,而是该索引列的值 + 对应的主键值。
这意味着,如果你通过二级索引查询,且需要的字段不止索引列本身,InnoDB 需要再用查到的主键值,去聚簇索引里再查一次完整数据行——这个过程叫回表(back to table)。
-- 假设 name 上有二级索引,id 是主键
SELECT * FROM user WHERE name = 'Alice';
第一步:在 name 的二级索引 B+树中查找 'Alice' → 找到对应的主键值 id=10
第二步:拿着 id=10,再去聚簇索引(主键索引)B+树里查一次 → 取出完整的行数据
如果只查索引列本身或主键,就不需要回表,比如:
SELECT id FROM user WHERE name = 'Alice'; -- 不需要回表,因为二级索引叶子节点里已经有 id 了
这种"不需要回表,索引本身就能满足查询"的情况,叫覆盖索引(Covering Index),是重要的性能优化手段。
5.3 联合索引与最左前缀原则
CREATE INDEX idx_city_age_name ON user(city, age, name);
联合索引的 B+树,是按照 (city, age, name) 这个"组合值"整体排序的——先按 city 排,city 相同的再按 age 排,age 也相同的再按 name 排。
数据示例(已按联合索引排序):
('Beijing', 20, 'Amy')
('Beijing', 25, 'Ben')
('Singapore', 18, 'Carl')
('Singapore', 20, 'Dave')
('Singapore', 20, 'Eve')
('Singapore', 30, 'Frank')
最左前缀原则:因为排序是"由左到右逐级生效"的,所以查询条件必须从索引的最左列开始,且中间不能"断档",索引才能生效(或才能充分生效):
| 查询条件 | 能否用上索引 | 说明 |
|---|---|---|
WHERE city = 'Singapore' |
✅ 完全能用 | 用到最左列 |
WHERE city = 'Singapore' AND age = 20 |
✅ 完全能用 | 最左两列都用上 |
WHERE city = 'Singapore' AND name = 'Eve' |
⚠️ 只能用到 city 这一列 | 中间的 age 被跳过,索引在 age 这里就"断"了,name 条件只能在结果集里逐行过滤 |
WHERE age = 20 |
❌ 用不上 | 没有从最左列 city 开始 |
WHERE city = 'Singapore' AND age > 18 AND name = 'Eve' |
⚠️ city 和 age 能用,name 不能 | 范围查询之后的列,索引会失效——因为 age 是范围条件,之后 name 的顺序在 age 范围内不再是有序的 |
这也是为什么实践中常见的经验法则是:把等值查询的列放在联合索引前面,范围查询的列放在最后。
5.4 索引下推(ICP, Index Condition Pushdown)
沿用上面 idx_city_age_name 的例子:
SELECT * FROM user WHERE city = 'Singapore' AND name LIKE '%e';
按最左前缀原则,name 这个条件用不上索引(因为被中间隐含跳过、且是 LIKE '%e' 这种前面带通配符的模糊匹配本身也无法用索引定位)。没有 ICP 时的做法是:InnoDB 只用 city = 'Singapore' 从二级索引里筛出一批主键,然后全部回表取出完整行,再由 Server 层逐行判断 name LIKE '%e'。
有 ICP 时,InnoDB 会在遍历二级索引的阶段,顺便利用索引里已经包含的 age、name 列的值,把 name LIKE '%e' 这个条件提前在索引层过滤掉,只把真正满足条件的少数记录拿去回表——大幅减少了不必要的回表次数。这是 MySQL 5.6 引入的优化,目前默认开启(optimizer_switch 中的 index_condition_pushdown=on)。
5.5 索引失效的常见场景
- 在索引列上做函数运算或隐式类型转换:
WHERE YEAR(create_time) = 2024、WHERE phone = 13800000000(phone 是字符串类型,用数字比较会触发隐式转换) - 前导模糊匹配:
WHERE name LIKE '%son'(前面带%,无法利用索引的有序性定位起点) - 联合索引违反最左前缀(上面已讲)
- 使用
OR连接了没有索引的列 - 优化器认为全表扫描更快:如果这张表数据量很小,或者该条件命中的行占比过高(比如超过表的 20%~30%,这是一个经验值,实际取决于优化器的成本估算),优化器可能会主动放弃索引,直接全表扫描——这不是 bug,是合理的成本判断。
第六章:复杂查询案例解析
准备一张示例表,后面的案例都基于它:
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_user_id (user_id),
INDEX idx_status_created (status, created_at)
);
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL
);
6.1 GROUP BY 的底层原理
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total
FROM orders
GROUP BY user_id
HAVING total > 1000;
执行原理:
- 如果
GROUP BY的列刚好有索引(本例中user_id有索引),MySQL 可以利用索引的有序性做"松散索引扫描(loose index scan)"或至少按索引顺序读取,避免额外排序,这叫索引扫描避免临时表。 - 如果没有合适的索引,MySQL 需要:
- 先按
GROUP BY列排序(内部排序,可能用到sort buffer,数据量大时会用到磁盘临时文件); - 或者建立一张内存临时表(若结果集大小超过
tmp_table_size/max_heap_table_size,会转成磁盘临时表,这会显著变慢),一边扫描一边把每组的聚合结果(COUNT、SUM 等)更新进临时表对应的行。
- 先按
HAVING是在分组聚合完成之后才执行的过滤,因此可以引用聚合函数结果(如本例的total),而WHERE是在分组之前对原始行过滤,不能引用聚合函数。
用 EXPLAIN 观察这条语句时,如果 Extra 列出现 Using temporary,说明用了临时表;出现 Using filesort,说明发生了额外排序——这两者通常是性能优化的重点关注对象。
6.2 JOIN 的底层原理
SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'PAID';
InnoDB(准确说是 MySQL 优化器)没有真正意义上的 Hash Join(8.0.18 之前完全没有,之后在特定条件下才会用),最常见、默认的算法是 嵌套循环连接(Nested Loop Join, NLJ):
for 每一行 r1 in 驱动表(outer table):
for 每一行 r2 in 被驱动表(inner table) 中满足 join 条件的行:
输出 (r1, r2)
关键概念——驱动表的选择:优化器会根据统计信息(表的行数、索引选择性等)估算成本,通常选择"过滤后行数更少的表"作为驱动表(outer table)。比如上例中,如果 orders 表按 status='PAID' 过滤后只剩很少的行,优化器会优先把 orders 作为驱动表,对每一行去 users 表按主键查找匹配——因为 users.id 是主键,每次查找是 O(log n) 级别的索引查找,这种情况叫 Index Nested Loop Join (INLJ),效率较高。
如果被驱动表的 join 列没有索引会发生什么:MySQL 会使用 Block Nested Loop Join (BNL)——把驱动表的一批数据先放进 join_buffer(内存缓冲区),然后扫描被驱动表一次,在内存里对整批数据做比较,以此减少被驱动表被扫描的次数。但本质上这仍然是较低效的算法,实践中的关键优化建议是:永远确保 JOIN 的关联列上有索引。
用 EXPLAIN 查看时,type 列如果是 ALL,说明这个表是全表扫描(没用上索引做 join);如果是 ref 或 eq_ref,说明用上了索引;Extra 出现 Using join buffer 则说明触发了 BNL。
6.3 HAVING vs WHERE 的本质区别(再强调)
| WHERE | HAVING | |
|---|---|---|
| 作用阶段 | 分组聚合之前,过滤原始行 | 分组聚合之后,过滤分组结果 |
| 能否用聚合函数 | 不能(如 WHERE COUNT(*) > 1 是错的) |
能 |
| 性能 | 应尽量把能过滤的条件放在 WHERE(更早过滤,减少参与分组的数据量) | 只用来过滤"依赖聚合结果"才能判断的条件 |
一个常见的反模式是把本可以用 WHERE 处理的条件写进了 HAVING:
-- 不好:先对全部数据分组,再过滤 status
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id HAVING user_id > 100;
-- 更好:先缩小参与分组的数据量
SELECT user_id, COUNT(*) FROM orders WHERE user_id > 100 GROUP BY user_id;
6.4 LIMIT 与深分页问题
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
这条语句表面上只返回 20 行,但 MySQL 必须先扫描并丢弃前 1,000,000 行,才能拿到第 1,000,001 到 1,000,020 行——LIMIT offset, count 的 offset 越大,代价越高,这就是深分页问题。
常见优化手段——"延迟关联"或"书签"(利用覆盖索引缩小回表范围):
-- 优化前:直接 LIMIT,大 offset 时很慢
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
-- 优化后:先用覆盖索引(只查主键,不回表)快速定位到目标 id 区间,再关联查完整数据
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 20) AS tmp
ON o.id = tmp.id;
原理:子查询 SELECT id FROM orders ORDER BY id LIMIT 1000000, 20 如果 id 是主键(聚簇索引),这个查询完全在索引里就能完成,不需要回表读取完整行,扫描成本远低于直接对全表 SELECT * 做 LIMIT;拿到 20 个精确的 id 之后,再做一次代价很小的 JOIN 精确取出这 20 行完整数据。
更彻底的优化——游标分页(seek method),如果业务允许"不能跳页,只能翻下一页",可以记住上一页最后一条的 id,直接用范围条件代替 offset:
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;
这样无论翻到第几页,都是一次索引范围扫描 + 拿 20 行,代价恒定,不随页码增长。
6.5 ORDER BY 与排序缓冲区
SELECT id, name, age FROM user WHERE city = 'Singapore' ORDER BY age;
如果 city 和 age 上刚好有联合索引 (city, age),由于索引本身在 city 相同的前提下已经按 age 有序,MySQL 可以直接按索引顺序读取,不需要额外排序(EXPLAIN 的 Extra 里不会出现 Using filesort)。
如果没有这样的索引,MySQL 需要额外排序,会用到 sort_buffer_size 配置的内存排序区,分两种策略:
- 全字段排序(sort by rows):把需要的字段整行放进 sort buffer 排序后直接返回,省去回表,但占内存多;
- rowid 排序(sort by rowid):只把排序字段和主键放进 sort buffer 排序,排完序后再按主键回表取完整数据,省内存但多一次回表。MySQL 会根据
max_length_for_sort_data配置和行大小自动选择。
若数据量超过 sort_buffer_size,会拆分成多个有序小文件写入磁盘,再做归并排序——这时 Extra 会出现 Using filesort,是明显的性能警示信号。
第七章:数据流动全景图(总结)
把前面所有内容串成一张图,这就是一条 UPDATE 语句从发出到真正持久化的完整数据流:
客户端发出: UPDATE orders SET status='PAID' WHERE id=100;
│
▼
连接器(鉴权) → 解析器(语法分析) → 优化器(选执行计划,判断用 id 主键索引)
│
▼
执行器 调用 InnoDB 接口
│
▼
┌─────────────────────────────────────────────────────────┐
│ InnoDB: │
│ 1. 在 Buffer Pool 中定位 id=100 所在的数据页 │
│ (若不在内存,先从磁盘 .ibd 文件加载进 Buffer Pool) │
│ 2. 记录 undo log(为了能回滚,也为了 MVCC 读旧版本) │
│ 3. 直接在内存的数据页上完成修改(此时数据页成为"脏页") │
│ 4. 把这次修改写入 redo log buffer │
│ 5. 事务提交时: │
│ a. redo log 落盘(标记为 prepare)——满足持久性(WAL) │
│ b. Server 层写 binlog │
│ c. redo log 标记为 commit(两阶段提交完成) │
└─────────────────────────────────────────────────────────┘
│
▼
(脏页此时仍只在内存中,不会立刻写回 .ibd)
│
▼
后台线程(Page Cleaner)按策略异步把脏页刷回磁盘 .ibd 文件
│
▼
数据最终持久化落地
这张图回答了一个初学者最常见的疑惑:"我 UPDATE 之后马上拔电源,数据会不会丢?" —— 不会,因为只要事务提交时 redo log 已经落盘(这是默认配置 innodb_flush_log_at_trx_commit=1 的行为),哪怕内存里的脏页还没来得及写回 .ibd 文件,重启后 InnoDB 也会读取 redo log,把这次修改在数据文件上"重放"一遍。
第八章:诊断与优化工具
8.1 EXPLAIN
EXPLAIN SELECT * FROM orders WHERE user_id = 5 AND status = 'PAID';
重点关注的字段:
| 字段 | 含义 |
|---|---|
type |
访问类型,性能从好到差大致为:system > const > eq_ref > ref > range > index > ALL(全表扫描,需重点关注) |
key |
实际使用的索引,NULL 表示没用上索引 |
rows |
优化器估算需要扫描的行数(估算值,不是精确值) |
Extra |
Using index(覆盖索引,好)、Using where(有额外过滤)、Using temporary(用了临时表,需关注)、Using filesort(额外排序,需关注) |
MySQL 8.0 还提供 EXPLAIN ANALYZE,会真正执行语句并给出每一步的实际耗时和行数,比传统 EXPLAIN 的"估算"更准确,排查慢查询时优先用它。
8.2 SHOW ENGINE INNODB STATUS
可以看到当前的事务列表、锁等待情况、Buffer Pool 命中率、redo log 使用情况等,是排查锁等待(死锁)、Buffer Pool 是否够用的关键工具。
8.3 performance_schema / sys schema
performance_schema 提供更细粒度的运行时监控(等待事件、语句耗时分布等),sys schema 是在其上封装的一层更易读的视图,例如:
-- 查找最耗时的 SQL 语句
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile;
-- 查看哪些索引从未被使用(可能是冗余索引)
SELECT * FROM sys.schema_unused_indexes;
8.4 慢查询日志(slow query log)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过1秒记为慢查询
生产环境排查性能问题的第一步,通常就是打开慢查询日志,配合 pt-query-digest(Percona 工具)等做汇总分析,找出最需要优化的 Top N 语句。
第九章:延伸学习路线建议
- 锁机制专题:行锁、间隙锁(Gap Lock)、Next-Key Lock、意向锁、死锁产生的条件与排查方法(这是本文档未展开的重要主题,直接影响并发场景下的行为)。
- 主从复制原理:binlog 的三种格式(STATEMENT / ROW / MIXED)、半同步复制、组复制(Group Replication)。
- 分库分表:当单表数据量过大(经验值:单表超过几千万行、或 B+树超过 3~4 层)时,如何做水平拆分,以及分布式事务的取舍。
- 优化器成本模型:如何读懂
EXPLAIN FORMAT=JSON里的成本估算细节,理解优化器为什么选了某个执行计划而不是另一个。 - 实操建议:拿自己业务里的一张核心表,实际跑一遍
EXPLAIN,对照本文档第五、六章,看看现有索引设计是否合理,是否存在冗余索引或缺失索引。
本文档定位为体系化的原理讲解,具体数值(如页大小、buffer pool 命中率经验值等)会因 MySQL 版本和硬件环境略有差异,实践中请结合 SHOW VARIABLES 和实际的 EXPLAIN / 压测结果做判断,而不是死记数字。