目录
-
- 一、Server层:SQL解析与执行
-
- [1 连接器:认证与权限](#1 连接器:认证与权限)
- [2 解析器:解析SQL](#2 解析器:解析SQL)
- [3 预处理器:处理语义逻辑](#3 预处理器:处理语义逻辑)
- [4 优化器:优化SQL](#4 优化器:优化SQL)
- [5 执行器:调度存储引擎](#5 执行器:调度存储引擎)
- 二、排查手册:错误码与EXPLAIN
-
- [1 错误码对照表](#1 错误码对照表)
- [2 EXPLAIN 诊断速查](#2 EXPLAIN 诊断速查)
- 三、存储引擎层:InnoDB架构与磁盘存储
-
- [1 InnoDB架构](#1 InnoDB架构)
- [2 MySQL 8.0的文件结构和DDL变化](#2 MySQL 8.0的文件结构和DDL变化)
- [3 数据在磁盘上到底怎么存的](#3 数据在磁盘上到底怎么存的)
- [4 一行数据在页上怎么摆的](#4 一行数据在页上怎么摆的)
- [四、索引:B+ Tree与回表](#四、索引:B+ Tree与回表)
-
- [1 B+ Tree:InnoDB的导航系统](#1 B+ Tree:InnoDB的导航系统)
- [2 B+ Tree与存储层级的关系](#2 B+ Tree与存储层级的关系)
- [3 聚簇索引和二级索引](#3 聚簇索引和二级索引)
- [4 一条查询在InnoDB里的完整旅程](#4 一条查询在InnoDB里的完整旅程)
- 五、内存与并发
-
- [1 Buffer Pool:内存里的数据高速公路](#1 Buffer Pool:内存里的数据高速公路)
- [2 一致性读:MVCC](#2 一致性读:MVCC)
- 六、写在最后
MySQL从拿到请求到返回数据,一共经历了这两个层面:Server层,存储引擎层。
一、Server层:SQL解析与执行
Server 层把一条 SQL 拆成了五道工序:连接器、解析器、预处理器、优化器、执行器。
Server层负责连接管理、SQL解析、预处理、优化、执行调度,以及内置函数、权限校验、日志记录等,全部在这层搞定。
1 连接器:认证与权限
TCP 三次握手之后,连接器做三件事:协商协议版本、协商字符集、身份认证(challenge-response 机制,不是明文传密码)。
有以下两个细节:
第一个,权限:权限是连接建立那一刻一次性加载到内存的。之后你 GRANT 了新的权限,已经存在的连接根本不知道。所以你加完权限发现程序还是报 Access denied,别急着怀疑自己------把连接池重建一下,或者断开重连,立马好。
第二个,超时 :wait_timeout 默认 28800 秒,也就是 8 小时。一个连接空闲超过这个时间,MySQL 主动断掉。但问题是,interactive_timeout 也默认 28800,它管的是 mysql 命令行连接的。JDBC 连接走的是 wait_timeout。所以你在连接池里配的 keepalive 或者 validationQuery,不是在"浪费性能",是在救命。
2 解析器:解析SQL
你写的 SQL 在 MySQL 眼里就是一串没有意义的字符。解析器第一件事就是词法分析------把 SELECT、FROM、>、123 一个个拆成 Token,相当于把一句话拆成单词。然后语法分析上场,把这些 Token 组装成一棵 AST(抽象语法树)。
举个简单的例子:
sql
SELECT id, name FROM users WHERE age > 18 ORDER BY name;
拆完之后的结构大概是这样:
SELECT语句节点
├── 选择列:id, name
├── FROM:users
├── WHERE:age > 18
└── ORDER BY:name
没拆对呢?ERROR 1064,经典语法错误。这时候检查两件事:第一,SQL 是不是真的写错了;第二,有没有拿保留字当字段名。遇到保留字加反引号,简单粗暴。
sql_mode 这里插一句。ONLY_FULL_GROUP_BY 这个参数,开了之后你 SELECT 的列要么在 GROUP BY 里,要么套聚合函数。很多从 5.6 升上来的 SQL,到了 5.7/8.0 突然报错了,根因就在这。
解析这个阶段的要点就一句话:语法对不一定语义对。
3 预处理器:处理语义逻辑
解析器只管你"写得像不像 SQL",预处理器才管你"说得对不对"。
表存不存在?ERROR 1146。列存不存在?ERROR 1054。你有权限吗?没权限直接拦。这些都在这个阶段校验。
同时它还会帮你做一些"善意的改写"------SELECT * 展开成具体列名,标注隐式类型转换,展开简单子查询。
4 优化器:优化SQL
SQL 语法对、语义也对,怎么干最快?这就是优化器的活。
优化器分两步走:逻辑优化、物理优化。
5 执行器:调度存储引擎
拿到执行计划,确认权限,调存储引擎 API 开干。
执行方式叫火山模型 。名字挺唬人的,其实就是每个算子三个方法:open() 初始化、next() 取下一行、close() 释放。顶层往下调 open,数据从底层一层层往上交,每次内存里只持有一行。
读数据对应的 API:ha_index_read / ha_index_next 走索引,ha_init_scan / ha_next 全表扫,DML 对应 ha_write_row / ha_update_row / ha_delete_row。
大部分情况结果是流式返回的,处理一行发一行。但 ORDER BY 是个例外,索引本身有序的话直接用,无序的话就得把所有数据收齐排完序再发------这就是 filesort。
sort_buffer_size 默认才 256KB,不够就写磁盘临时文件,性能直接断崖。
不过这里有个好消息:LIMIT 是 filesort 的好朋友。 比如 ORDER BY c LIMIT 10,排序的时候只要维护一个 10 行的优先队列就够了,不需要全量排序。所以 EXPLAIN Extra 里看到 Using filesort 先别慌,看 rows 和 LIMIT 的关系再下结论。
tmp_table_size 和 max_heap_table_size 都默认 16MB,临时表超了这个尺寸就转磁盘,又是一个性能断崖。GROUP BY 生成的大临时表特别容易踩这个坑。
最后提一下查询缓存。5.7 有,8.0 砍了。原因是 SQL 文本要完全一致才命中,DML 操作还清空相关表缓存。写多的场景命中率低到可怜,留着反而拖性能。
二、排查手册:错误码与EXPLAIN
1 错误码对照表
出问题时先确定是哪个环节炸的,缩小排查范围:
- 连接器错误 :Access denied(密码错或者认证插件不对)、Too many connections(超了
max_connections)、Host blocked(max_connect_errors超了,FLUSH HOSTS解)。 - 解析器错误:ERROR 1064,语法写错了,检查保留字和拼写。
- 预处理器错误:ERROR 1146 表不存在、ERROR 1054 列不存在。
- 执行器错误 :ERROR 1205 锁等待超时(
innodb_lock_wait_timeout)、ERROR 1213 死锁(InnoDB 自动回滚事务较小的一方,业务层做好重试)。
2 EXPLAIN 诊断速查
最后给一个 EXPLAIN 快速诊断指南,日常排查够用了:
看 type 列 :ALL → index → range → ref → eq_ref → const,从左到右越来越好。key 为 NULL 就是没走索引。possible_keys 有值但 key 为 NULL?回表太多或统计信息过期了,ANALYZE TABLE 试试。
rows 和 filtered 一起看:rows 乘以 filtered% 约等于实际返回行数。filtered 低说明大量数据扫出来以后被 WHERE 条件过滤掉了,考虑加联合索引把过滤条件也包进去。
Extra 关键词解读:
| Extra | 含义 |
|---|---|
Using index |
覆盖索引,不用回表,最优解 |
Using index condition |
ICP(Index Condition Pushdown),引擎层就过滤了,减少回表 |
Using filesort |
额外排序,有 LIMIT 不一定慢。没有 LIMIT 且 rows 很大就需要注意 |
Using temporary |
用了临时表,GROUP BY / DISTINCT / UNION 的场景常见,注意 tmp_table_size |
Using join buffer |
被驱动表缺索引的信号,赶紧加 |
8.0.18 开始支持 EXPLAIN ANALYZE,会实际执行 SQL 然后返回真实耗时和行数。估算值和实际值差太远,就是统计信息过期了。
三、存储引擎层:InnoDB架构与磁盘存储
1 InnoDB架构
InnoDB不是一个人干活,是一堆组件协同工作。最上层是SQL接口层,执行器通过handler API跟它通信,传进来的是"给我读某条索引的下一行"。中间是事务、锁、日志这些子系统。最核心的是Buffer Pool,它负责在内存和磁盘之间搭桥。
背后还有一个后台线程军团:Master Thread负责刷脏页和合并插入缓冲,IO Thread处理读写请求,Purge Thread清理不再需要的undo日志,Page Cleaner专门负责把脏页刷到磁盘。各司其职。

2 MySQL 8.0的文件结构和DDL变化
8.0的文件组织方式和5.7比有个很大的不同:没有 .frm 文件了。
5.7时代,一张表对应三个文件:
xxx.frm--- 表结构定义xxx.ibd--- 表数据和索引
8.0把 .frm 文件彻底移除了。表结构完全由InnoDB自己的数据字典表管理,所有表(不管是 mysql 系统库还是你自己的业务表)的表结构定义,全部统一存在 mysql.ibd 里。所以8.0的情况是:
xxx.ibd--- 用户表数据和索引(只存数据,不存结构)mysql.ibd--- 系统表空间,里面存了所有表的结构定义(数据字典表)
这些文件在哪?在MySQL的数据目录下,每个库一个文件夹:
MySQL数据目录(datadir)/
├── mysql/ # mysql系统库
│ └── xxx.ibd
├── test/ # 你自己的库
│ ├── users.ibd # 用户表的数据+索引
│ ├── orders.ibd
│ └── ...
├── mysql.ibd # 所有表的表结构定义(8.0特有)
└── undo_001, undo_002 # undo日志
连上MySQL执行 SHOW VARIABLES LIKE 'datadir'; 就能看到你机器上的实际路径。Windows默认 C:\ProgramData\MySQL\MySQL Server 8.0\Data\,Linux默认 /var/lib/mysql/。注意 mysql.ibd 在数据目录的根下,业务表的 .ibd 在各自库名的文件夹里。
注意,这两个全是 .ibd 文件,格式是一样的,只是用途不同。另外每个 .ibd 文件里也备份了一份SDI(序列化字典信息)页,用于表空间导入导出这类场景。
这个改动带来的好处很实在:DDL操作变成事务性的了。以前你改表结构,改了就是改了,没法回滚。8.0可以 ROLLBACK 一个 ALTER TABLE 操作------因为表结构的修改也写在InnoDB的事务日志里了。
另外8.0支持了即时DDL(Instant DDL),加列操作瞬间完成,不需要重建表。
3 数据在磁盘上到底怎么存的
很多人以为数据在磁盘上就是一张大表,一行一行摆着,InnoDB读的时候像读文件一样从头扫到尾。其实完全不是这么回事。
InnoDB把数据组织成几个层级:表空间(tablespace)→ 段(segment)→ 区(extent)→ 页(page)→ 行(row)。
表空间你可以理解成一个大的逻辑容器。MySQL 8.0默认每张表一个独立的.ibd文件,这就是它专属的表空间。里面分成各种段:索引段、数据段、回滚段等等。
段下面分区(extent),一个区固定1MB,包含连续64个页(每个页16KB)。为啥要分区?因为InnoDB认为一次申请1MB是合理的批量大小,按需分配,避免零散申请浪费。
页是InnoDB磁盘管理的最小单位。对的,不是行,是页。不管你读一行还是读一万行,InnoDB一次至少读一页(16KB)到内存。这个设计跟操作系统的页缓存一个道理------局部性原理,你读了一行,旁边几行大概率也会被读到。
页里面才是行数据。数据行按照主键顺序存储在页中,页与页之间通过双向链表连接,同一页内的行通过单向链表连接。
说个冷知识,一个16KB的页除去页头页尾这些元数据,大概能存几百到几千行数据(取决于一行有多大)。如果一行数据太大(比如有个TEXT字段存了一整篇文章),InnoDB会把部分数据溢出到额外的页里,原行只保留一个20字节的指针。

4 一行数据在页上怎么摆的
这可能是很多人没想过的问题。一页16KB,几百行数据,具体怎么摆的?
一个数据页的结构从前往后依次是:文件头(File Header,38字节)、页头(Page Header,56字节)、最大最小记录(Infimum + Supremum)、行记录(User Records)、空闲空间(Free Space)、页目录(Page Directory)、文件尾(File Trailer,8字节)。
行记录从页的中间开始往后摆,页目录从后面开始往前摆。如果你写数据,就是往中间插。
重点说下行记录的格式。InnoDB的行格式有好几种:COMPACT、DYNAMIC、COMPRESSED、REDUNDANT。MySQL 5.7以后默认是DYNAMIC。
一行数据在页上存的东西包括:变长字段长度列表(记录每个VARCHAR字段的实际长度)、NULL值位图(哪些字段是NULL)、行头信息(记录类型、下一条记录的偏移量等)、各列的实际数据。
行头里有个很重要的字段叫 next_record,指向下一行数据的偏移量。这样页内的行记录就形成了一个单向链表。
页目录又把行记录分组管理,每组最后一行是"最大行",记录该组有多少行。通过页目录做二分查找,能快速定位到行所在的分组,然后再在组内遍历。这也是B+ Tree能在页级别做搜索的原因。

四、索引:B+ Tree与回表
1 B+ Tree:InnoDB的导航系统
InnoDB的索引长什么样?B+ Tree。
很多人背过B+ Tree的概念,但不理解它到底怎么解决实际问题。
如果没有索引,你要查 WHERE id = 10086,InnoDB只能从第一页开始,一页一页读进来,一行一行比对,直到找到为止。这叫全表扫描,代价巨大。
B+ Tree把数据按主键排好序,用分层的方式组织。整棵树分三层:根节点、内部节点、叶节点。根节点和内部节点只存键值+指针,不存数据。叶节点存实际的行数据。
当你要查 id = 10086 的时候,InnoDB从根节点出发。根节点有一组目录项,比如"小于10000的走左边,10000到20000的走中间,大于20000的走右边"。它通过在节点内部做二分查找,快速定位到下一层的指针。
一层一层往下,直到叶节点。叶节点里按照主键顺序排好了数据行,再二分查找一次,定位到具体的行。
三层B+ Tree能存多少数据?我们来算一下。每个页16KB,假设主键是BIGINT(8字节),指针6字节,每个目录项14字节。一个根节点页能存约16384/14 ≈ 1170个目录项。每个内部节点同理。每个叶节点假设能存100行。那三层B+ Tree就能存:1170 x 1170 x 100 ≈ 1.37亿行。
也就是说,查询1.37亿行数据里的某一行,只需要3次磁盘IO(每层一次)。如果没有索引,最坏情况要读100多万个页。

2 B+ Tree与存储层级的关系
主键索引的各个节点就是物理上的数据页。 B+ Tree从上到下分成三层------根节点、内部节点、叶节点。
根节点页存的是宽范围的目录项(如 [1-1000]→页号A),内部节点页存的是更小范围的目录项(如 [1-100]→页号C),叶节点页存的是完整的行数据(如 id=34, name=王五, age=28)。
那"表段区页行"跟B+ Tree是什么关系?
| 概念 | 含义 | 跟B+ Tree的关系 |
|---|---|---|
| 行 | 一条记录 | 存在B+ Tree的叶节点页里 |
| 页 | 16KB,磁盘读写的最小单位 | 就是B+ Tree的一个节点 |
| 区 | 64个连续页=1MB | InnoDB按区为B+ Tree批量申请空间 |
| 段 | 一组区 | 叶节点页归数据段,非叶节点页归索引段 |
| 表空间 | 就是.ibd文件 | 包含整棵B+ Tree的所有页 |
查 id=34 的过程,就是这个关系的最好体现:
| 步骤 | 读了什么 | 对应B+ Tree哪层 | 找到了什么 |
|---|---|---|---|
| 第1次IO | 一个16KB数据页 | 根节点 | 目录项:34在1-1000范围,去页号A |
| 第2次IO | 另一个16KB数据页 | 内部节点 | 目录项:34在1-100范围,去页号C |
| 第3次IO | 又一个16KB数据页 | 叶节点 | 行数据:id=34, name=王五, age=28 直接返回 |
三次读了三个页。根节点和内部节点页里是指路牌(目录项),叶节点页里就是数据行本身。
所以不存在"索引放一个地方、数据放另一个地方"的说法。主键索引的B+ Tree就是数据本身,叶节点页里直接就是行数据,没有第二份数据文件。

3 聚簇索引和二级索引
InnoDB的表数据本身就是一棵B+ Tree,这棵树的叶节点存了完整的行数据,这叫聚簇索引。
那二级索引呢?你给其他列建了索引,InnoDB会再建一棵B+ Tree,但它的叶节点不存完整行数据,只存对应行的主键值。
所以执行一条 SELECT * FROM users WHERE name = '张三',如果name有索引,过程是:
- 在name的二级索引B+ Tree里找到
name='张三'对应的主键值 - 拿着这个主键值,去聚簇索引B+ Tree里查完整行数据
第二步就叫回表。如果name索引扫描出来100行满足条件,就要回表100次。这就是为什么有时候虽然用了索引,但查询还是慢的原因------回表次数太多了。
覆盖索引就是解决这个问题的,对比两个查询:
sql
-- 查询A:需要回表
SELECT id, name, age FROM users WHERE name = '张三';
走name二级索引,叶节点存的是 (name, id),里面没有 age。所以得先拿到 id=10086,再去聚簇索引里查完整行取出 age。这个步骤就叫回表。
sql
-- 查询B:覆盖索引,不用回表
SELECT id, name FROM users WHERE name = '张三';
走的还是name二级索引,叶节点里有 (name, id)。你要的 id 和 name 这两个字段,索引页里已经有了,不需要再去聚簇索引查了。这就是覆盖索引。
怎么判断?EXPLAIN 看一下 Extra 列:
- 显示
Using index→ 覆盖索引,不用回表 - 没有这个标识 → 需要回表
覆盖索引本质上就是:索引覆盖了查询需要的所有列,不需要回表。 在频繁查询的字段组合上建联合索引,让索引"覆盖"你的查询,是常见的优化手段。

4 一条查询在InnoDB里的完整旅程
现在把Buffer Pool、B+ Tree、数据页、行格式全部串起来,看看 SELECT * FROM users WHERE id = 34 在InnoDB内部每一步做了什么。
第1步:执行器发指令
MySQL Server层的执行器通过handler API告诉InnoDB:"读id=34,走主键索引"。
第2步:读根节点页
InnoDB先检查根节点页在不在Buffer Pool里:
- 命中:直接在内存读,纳秒级
- 未命中:从磁盘读一个16KB的页到Buffer Pool,约10ms
拿到根节点页后,通过Page Directory(页目录的slot数组)做二分查找,找到目录项"34在1-1000范围,去页号A"。
第3步:读内部节点页
同样的流程------检查Buffer Pool → 没有就从磁盘读 → 二分查找 → 找到"34在1-100范围,去叶节点页号C"。
第4步:读叶节点页
同样先查Buffer Pool,没有就从磁盘读。拿到叶节点页后,Page Directory二分查找定位到 id=34 行在页内的具体位置。
第5步:解析行记录
找到行之后,InnoDB按照DYNAMIC行格式解析这行数据:
- 读变长字段长度列表 → 知道VARCHAR字段各有多长
- 读NULL值位图 → 知道哪些字段是NULL
- 读行头信息(含next_record指针)
- 读各列实际数据 →
id=34, name=王五, age=28
第6步:返回给Server层
通过handler API把这行数据交回给MySQL执行器。执行器做投影(只取需要的列),组装成结果行,通过网络发给客户端。
全流程成本总结:
- 最少0次磁盘IO:三层页全在Buffer Pool里
- 最多3次磁盘IO:三层页全要从磁盘读(约30ms)
- 每次读16KB,不是只读一行
- 读过的页留在Buffer Pool里,后续查询可能直接命中

五、内存与并发
1 Buffer Pool:内存里的数据高速公路
如果每次查询都去磁盘读,那MySQL的QPS能上三位数就烧高香了。磁盘随机读一页大概要10ms,而内存读只要几十纳秒,差了十万八千里。
所以InnoDB搞了个Buffer Pool,一个巨大的内存缓存区域。
查询数据的时候,InnoDB先把磁盘上的页读到Buffer Pool里,后续所有读写操作都在内存里完成。被修改过的页就成了脏页,后台线程在合适的时机再刷回磁盘。
Buffer Pool默认大小是128MB,但生产环境128MB塞牙缝都不够。一般建议设到物理内存的60%-80%。
Buffer Pool内部用改良版的LRU算法管理缓存。所谓的"改良"是指它把LRU链表分成了5/8的新区(young sublist)和3/8的旧区(old sublist)。新读入的页不会直接放到链表头部,而是先放到旧区的头部。只有被再次访问到的页,才会被提升到新区。
为啥要这么设计?因为全表扫描会把大量页读到内存,如果不加这个限制,一次全表扫描就把热点数据全挤出去了------这叫Buffer Pool污染。有了old sublist,全表扫描的页如果只读一次就被淘汰了,不会冲击热点数据。
这就是为什么有的SQL第一次跑很慢,第二次跑飞快。因为第一次是从磁盘读的,第二次数据已经在Buffer Pool里了,直接命中。

2 一致性读:MVCC
说完了数据怎么存的,再看个关键问题:为什么你在一个事务里反复读同一行,结果始终一样,即使别的事务已经改了?
MVCC的核心是:为一行数据维护多个版本快照(存在undo log里),读操作不等待行锁释放,直接读快照版本。读写分离,减少锁冲突,提高并发能力。
InnoDB在事务开始时(严格说是执行第一条SELECT时),会创建一个当前活跃事务的快照。这个快照基于三个东西:全局活跃事务ID数组、低水位ID、高水位ID。
后续的SELECT读数据时,每读一行就判断这行的事务ID在不在快照可见范围内。如果这行被修改了但不可见,InnoDB就去undo log里找这行的历史版本。undo log记录了每次修改前的旧值,通过rollback pointer串成一个版本链。
所以你的SELECT永远不会被写操作阻塞------你读的是快照,写的是当前行,互不干扰。
但也有例外。SELECT ... FOR UPDATE 和 SELECT ... LOCK IN SHARE MODE 走的是当前读,读到的是最新已提交的数据,同时加锁。
这就是MVCC(多版本并发控制)。网上说MVCC的帖子很多,但就一句话:写操作写出版本链,读操作按照可见性规则选择版本。读不阻塞写,写不阻塞读。

六、写在最后
InnoDB这套机制从MySQL 5.5开始成为默认引擎,到现在十多年了,核心架构几乎没有变过。Buffer Pool、B+ Tree、MVCC这些概念,你理解了它们,MySQL调优和问题排查就不再是玄学。
文章结束,喜欢就给个一键三连吧,你的肯定是我最大的动力,点赞上一千我就是脑瘫也出下章