跳至主要内容

MySQL架构与数据管理学习指南

MySQL 架构、数据管理与数据流动学习文档

面向对象:已经会写 SELECT / INSERT 等基本语句,但想真正理解"一条 SQL 语句进去之后,MySQL 内部到底发生了什么"的开发者。

阅读方式建议:不必从头背诵,遇到工作中的实际问题(比如"为什么这条查询这么慢""为什么加了索引还是全表扫描")时,回来对照相应章节。


目录

  1. MySQL 整体架构总览
  2. 一条 SQL 语句的完整生命周期
  3. INSERT / SELECT 基础回顾(从语法到底层)
  4. InnoDB 存储引擎深度剖析
  5. 索引原理(B+树、聚簇索引、联合索引)
  6. 复杂查询案例解析(GROUP BY / JOIN / HAVING / LIMIT / ORDER BY)
  7. 数据流动全景图(总结)
  8. 诊断与优化工具
  9. 延伸学习路线

第一章: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;
  1. 连接器:校验你是否有权限连 user 表。
  2. 解析器:识别出这是一条 SELECT,表是 user,条件是 id = 10
  3. 预处理器:检查表、字段是否存在,展开 * 等。
  4. 优化器:判断 id 是否有索引(主键天然是聚簇索引),决定采用主键索引查找而不是全表扫描。
  5. 执行器
    • 调用 InnoDB 的接口,"取 id=10 的第一行";
    • InnoDB 先看 Buffer Pool(内存) 里有没有这一页数据;
      • 有 → 直接从内存返回;
      • 没有 → 从磁盘的 .ibd 表空间文件里读取该数据页,加载进 Buffer Pool,再返回。
    • 执行器判断是否还有下一行满足条件(本例中主键唯一,只有一行),返回结果集。

2.2 INSERT 语句的旅程(这里是大多数人理解模糊的地方)

INSERT INTO user(name, age) VALUES ('Alice', 20);
  1. 解析器/优化器/执行器同上,最终执行器调用 InnoDB 的写入接口。
  2. InnoDB 在 Buffer Pool 中找到(或从磁盘加载)该记录应该插入的数据页,直接在内存里完成插入——这一步不会立刻写磁盘的数据文件。
  3. 为了保证"就算现在服务器断电,这条数据也不会丢",InnoDB 会:
    • 把这次修改写入 redo log(重做日志) 的日志缓冲(redo log buffer),并根据 innodb_flush_log_at_trx_commit 配置,在事务提交时将其持久化到磁盘的 redo log 文件(这就是常说的 WAL:Write-Ahead Logging,预写日志)。
    • 同时记录 undo log(回滚日志),用于事务回滚和 MVCC(后面详解)。
  4. Server 层(不是 InnoDB)另外写一份 binlog(归档日志),用于主从复制和数据恢复。
  5. 被修改过、但还没落盘到 .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 → SELECTDISTINCTORDER BYLIMIT

这个顺序非常重要,直接决定了你能不能在 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 READREAD COMMITTED 隔离级别下的普通 SELECT(快照读)生效。

每一行记录,InnoDB 都会隐式附带几个额外的列:

  • DB_TRX_ID:最后一次修改这行数据的事务 ID
  • DB_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 + 1
  • creator_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 COMMITTEDREPEATABLE READ 的核心区别:前者是每次 SELECT 都生成一个新的 ReadView(所以同一事务内两次查询可能看到不同结果,即"不可重复读");后者是事务开始时只生成一次 ReadView,整个事务期间复用(所以能保证可重复读)。这也是为什么很多人说"MySQL 的可重复读级别在一定程度上已经解决了幻读问题"——但注意,这只对快照读成立,对当前读SELECT ... FOR UPDATEUPDATEDELETE)依然需要靠 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 会在遍历二级索引的阶段,顺便利用索引里已经包含的 agename 列的值,把 name LIKE '%e' 这个条件提前在索引层过滤掉,只把真正满足条件的少数记录拿去回表——大幅减少了不必要的回表次数。这是 MySQL 5.6 引入的优化,目前默认开启(optimizer_switch 中的 index_condition_pushdown=on)。

5.5 索引失效的常见场景

  • 在索引列上做函数运算或隐式类型转换WHERE YEAR(create_time) = 2024WHERE 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;

执行原理

  1. 如果 GROUP BY 的列刚好有索引(本例中 user_id 有索引),MySQL 可以利用索引的有序性做"松散索引扫描(loose index scan)"或至少按索引顺序读取,避免额外排序,这叫索引扫描避免临时表
  2. 如果没有合适的索引,MySQL 需要:
    • 先按 GROUP BY 列排序(内部排序,可能用到 sort buffer,数据量大时会用到磁盘临时文件);
    • 或者建立一张内存临时表(若结果集大小超过 tmp_table_size / max_heap_table_size,会转成磁盘临时表,这会显著变慢),一边扫描一边把每组的聚合结果(COUNT、SUM 等)更新进临时表对应的行。
  3. 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);如果是 refeq_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, countoffset 越大,代价越高,这就是深分页问题

常见优化手段——"延迟关联"或"书签"(利用覆盖索引缩小回表范围)

-- 优化前:直接 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;

如果 cityage 上刚好有联合索引 (city, age),由于索引本身在 city 相同的前提下已经按 age 有序,MySQL 可以直接按索引顺序读取,不需要额外排序EXPLAINExtra 里不会出现 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 语句。


第九章:延伸学习路线建议

  1. 锁机制专题:行锁、间隙锁(Gap Lock)、Next-Key Lock、意向锁、死锁产生的条件与排查方法(这是本文档未展开的重要主题,直接影响并发场景下的行为)。
  2. 主从复制原理:binlog 的三种格式(STATEMENT / ROW / MIXED)、半同步复制、组复制(Group Replication)。
  3. 分库分表:当单表数据量过大(经验值:单表超过几千万行、或 B+树超过 3~4 层)时,如何做水平拆分,以及分布式事务的取舍。
  4. 优化器成本模型:如何读懂 EXPLAIN FORMAT=JSON 里的成本估算细节,理解优化器为什么选了某个执行计划而不是另一个。
  5. 实操建议:拿自己业务里的一张核心表,实际跑一遍 EXPLAIN,对照本文档第五、六章,看看现有索引设计是否合理,是否存在冗余索引或缺失索引。

本文档定位为体系化的原理讲解,具体数值(如页大小、buffer pool 命中率经验值等)会因 MySQL 版本和硬件环境略有差异,实践中请结合 SHOW VARIABLES 和实际的 EXPLAIN / 压测结果做判断,而不是死记数字。

此博客中的热门博文

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。