Appearance
MySQL 数据存储与读取过程
这篇文章专门讲一个很核心、也很容易被讲散的话题:MySQL 里的数据,到底是怎么存进去的,又是怎么被读出来的。
如果不特别说明,下面主要讨论的是 InnoDB,因为它才是现在业务系统里最主流的 MySQL 存储引擎。
这篇文章重点回答 4 个问题:
- 一条 SQL 进入 MySQL 后,总体会经过哪些阶段
- InnoDB 里的数据和索引到底是怎么组织的
select、insert、update、delete的执行链路分别是什么- 为什么 MySQL 能同时兼顾性能、事务和崩溃恢复
1. 抓住整体主线
很多人一上来就去背:
Buffer Poolredo logundo logMVCC- 聚簇索引
结果会越学越散。
更稳妥的顺序是抓住一句话:客户端把 SQL 发给 MySQL,MySQL 先理解这条 SQL,再决定怎么执行,最后由 InnoDB 真正去读写页和记录日志。
1.1 一条 SQL 的总体执行过程
如果先不急着扎进底层细节,可以把 MySQL 执行 SQL 的主线理解成下面 6 步:
- 连接器:建立连接、认证身份、校验权限
- 解析器:把 SQL 拆开,看懂语法结构
- 预处理器:检查表、字段、别名等语义是否合理
- 优化器:决定走哪个索引、按什么执行计划来跑
- 执行器:按执行计划调度执行
- 存储引擎:真正去读写索引、数据页和日志
可以把这 6 步压缩成一句更容易记的话:连接器负责接入,解析器负责看懂 SQL,优化器负责决定怎么跑,执行器负责调度,InnoDB 负责真正读写数据。
1.2 用一张图先建立总体认知
mermaid
flowchart TD
A[客户端提交 SQL] --> B[连接与权限校验]
B --> C[语法与语义检查]
C --> D[优化器生成执行计划]
D --> E[执行器调用存储引擎]
E --> F[InnoDB 读写页和日志]
F --> G[返回结果给客户端]这张图最重要的意思是:
- MySQL Server 层主要负责“理解 SQL、组织执行”
- InnoDB 层主要负责“真正读写数据、保证一致性”
- SQL 真正落到磁盘之前,通常还会经过内存页、日志和事务机制
1.3 Server 层和 InnoDB 层分别在做什么
这是理解这篇文章最关键的边界。
| 层次 | 主要职责 |
|---|---|
| MySQL Server 层 | 连接管理、SQL 解析、语义检查、优化、执行调度、binlog |
| InnoDB 存储引擎层 | 页管理、索引访问、Buffer Pool、undo log、redo log、锁、MVCC、事务恢复 |
实际写项目时,更多是这样:Server 层决定“怎么执行更合适”,InnoDB 决定“怎么安全地把数据读出来或写进去”。
2. 先补齐 InnoDB 的底层存储基础
如果不了解数据到底是怎么放在 InnoDB 里的,后面的读写链路会很抽象。
2.1 数据最终不是按“行”,而是按“页”读写
很多人第一次接触数据库时,会下意识以为:一张表就是一堆行顺序排在文件里。
这个理解太粗了。
在 InnoDB 里,更应该先记住下面几个层次:
- 行:一条记录
- 页:若干行记录组成一个页,常见页大小是
16KB - 区:多个页组成更大的分配单位
- 表空间:表和索引最终落在表空间文件中
🌟 真正重要的是:InnoDB 的基本 I/O 单位通常不是“行”,而是“页”。
也就是说,不管你查一行、改一行,数据库真正从磁盘读写时,通常都是按页处理。
2.2 一张表本质上是按聚簇索引组织的
对 InnoDB 来说,一张表最核心的不是“表结构定义”,而是:表数据本身就是按聚簇索引组织存储的。
如果表有主键,那么主键索引通常就是聚簇索引。聚簇索引叶子节点里存的不是“地址”,而是整行记录。
这意味着:
- 表数据和主键索引本质上是一体的
- 数据天然按主键顺序组织
- 按主键查询,本质上就是查聚簇索引
而二级索引则不一样,它的叶子节点通常保存的是:
- 二级索引列值
- 对应记录的主键值
所以通过二级索引查整行时,往往还要再回到聚簇索引里取整行,这就是:回表
2.3 一行记录里为什么还会有隐藏信息
站在更底层看,一行记录除了业务字段值,并不只是:id, name, age
它通常还会带一些隐藏信息,用来支持事务和 MVCC。可以记住两个最关键的隐藏列:
trx_id:最后一次修改这行记录的事务 IDroll_pointer:指向undo log的指针
它们的作用是:
- 帮数据库判断这条记录版本属于哪个事务
- 让数据库在需要时回溯到旧版本
这也是为什么 InnoDB 能实现“一致性读”和“当前读”的重要基础。
3. 写操作的共享主线
下面我们先不急着拆 insert、update、delete 的差异,而是看它们共同的写入链路。
3.1 先用一句话抓住写入主线
可以把写入链路压缩成一句话:定位记录或页 -> 页进 Buffer Pool -> update/delete 先写 undo -> 修改内存页并维护索引 -> 写 redo -> 提交时可能写 binlog -> 后续刷脏页
这条线是理解写操作最核心的主干。
3.2 写入流程图
mermaid
flowchart TD
A[收到写操作请求] --> B[定位目标记录或页]
B --> C[页进入 Buffer Pool]
C --> D[记录旧版本信息]
D --> E[修改内存页并维护索引]
E --> F[写入重做日志]
F --> G[提交事务并记录逻辑日志]
G --> H[后台刷脏页到磁盘]这里要特别注意两点:
- 数据通常不是直接在磁盘原地修改,而是先改内存页
- 事务提交成功,也不等于数据页已经在同一时刻刷进磁盘
3.3 写入链路拆开看
3.3.1 先定位记录或页
当你执行一条写语句时,MySQL 不会先想“我要改这一行字符串”,而是先想:目标记录在哪个索引页、哪个数据页里。
如果是按主键写,通常会走聚簇索引快速定位。
如果是按普通索引条件查到目标记录,可能先走二级索引,再回表定位聚簇索引页。
3.3.2 页先进入 Buffer Pool
如果目标页当前不在内存里,InnoDB 会把对应页从磁盘加载到 Buffer Pool。
你可以把 Buffer Pool 理解成数据库最重要的缓存区,它缓存了:
- 数据页
- 索引页
- 一些辅助结构
所以绝大多数数据修改,并不是直接在磁盘文件上原地改,而是:把页放到 Buffer Pool,再在内存里修改。
3.3.3 为什么 update 和 delete 要先写 undo log
如果是 update 或 delete,为了支持事务回滚和 MVCC,InnoDB 通常会先记录旧版本信息,也就是:
undo log
它解决的是两个问题:
- 事务失败时,可以把改动撤回去
- 其他事务做一致性读时,必要时还能看到旧版本
所以 undo log 可以看成 为了回滚和旧版本可见性,把“改之前是什么样”记下来。
3.3.4 真正的数据修改先发生在内存页
接着,真正的数据页会先在内存中改掉,这时页就变成了:脏页
脏页不是坏页,而是:内存中的页已经被修改,但磁盘里的原始页还没同步更新。
这一步很关键,因为它让写操作不需要每次都立刻同步磁盘,从而显著提升写入性能。
3.3.5 为什么还要写 redo log
只在内存里改还不够,因为一旦机器宕机,内存数据会丢。
所以 InnoDB 还会把这次修改对应的物理变更记录到:redo log
它解决的是 崩溃恢复时,怎么把已经提交但还没来得及刷到数据页的改动补回来。
这背后对应的就是数据库经典的 WAL 思想,也就是 Write-Ahead Logging:
先写日志,再考虑把数据页真正刷到磁盘。
3.3.6 提交时为什么还可能写 binlog
如果 MySQL 开启了 binlog,那么事务提交时还会记录逻辑日志。
这里要区分:
undo log:解决回滚和旧版本可见性redo log:解决崩溃恢复binlog:解决主从复制和归档恢复
它们不是重复设计,而是分别解决不同问题。
3.3.7 提交成功为什么不等于数据页已落盘
很多人会以为事务提交成功就代表“数据页已经写进磁盘了”。其实不一定。
把这件事说完整一点:
- 提交成功,通常至少意味着关键日志已经按策略落盘
- 数据页本身可能稍后才由后台线程刷盘
也就是说,事务提交和数据页落盘不是同一时刻的事。
4. 读取主线
和写操作相比,select 的核心问题不是“怎么改”,而是:怎么以更低成本把正确版本的数据找出来。
4.1 先用一句话抓住读取主线
可以把读取链路压缩成一句话:优化器选路径 -> 从缓存或磁盘拿页 -> 沿索引定位记录 -> 必要时回表 -> 按事务视图判断是否可见 -> 返回结果
4.2 读取流程图
mermaid
flowchart TD
A[收到查询请求] --> B[优化器选择访问路径]
B --> C[从 Buffer Pool 或磁盘取页]
C --> D[沿索引或扫描定位记录]
D --> E[必要时回表]
E --> F[判断版本可见性]
F --> G[返回结果]这张图里最重要的意思是:
- 查询的第一步通常不是“去磁盘拿数据”,而是先决定“怎么拿更划算”
- 读到的也不一定总是最新版本,而是当前事务应该看到的版本
4.3 读取链路拆开看
4.3.1 优化器先决定访问路径
优化器会先判断:
- 走哪个索引
- 是否要全表扫描
- 是否需要排序、回表、临时表
也就是说,读取过程的第一步并不是“去磁盘拿数据”,而是先决定:怎么拿更划算。
4.3.2 从 Buffer Pool 或磁盘拿页
如果目标页已经在 Buffer Pool 中,直接在内存里读就可以了,这叫缓存命中。
如果不在,就需要从磁盘加载对应页到内存,再从页中定位记录。
所以数据库查询快不快,很大程度上和两件事有关:
- 访问路径是否合理
- 页是否已经命中缓存
4.3.3 通过索引定位记录
如果是按主键查询:
sql
select * from user where id = 1001;那么通常会沿着聚簇索引 B+ 树一路找到对应叶子节点,最终拿到整行记录。
如果是按二级索引查询:
sql
select * from user where name = 'Tom';那么通常流程是:
- 先走
name对应的二级索引 - 在二级索引叶子节点拿到主键值
- 再回到聚簇索引里取整行
这就是为什么很多按二级索引查整行的 SQL,成本里常常包含一次回表。
4.3.4 一致性读为什么还要判断版本可见性
如果当前查询是普通 select,在可重复读等隔离级别下,很多时候它拿到的不是“最新版本”,而是:
当前事务视图下可见的版本。
这时 InnoDB 会利用记录里的事务信息和 undo log 来判断:
- 这条记录版本是不是当前事务可见
- 如果最新版本不可见,要不要沿着
undo log找旧版本
这也是 MVCC 真正发挥作用的地方。
所以“读取一条数据”并不总是单纯把最新值返回,而是:返回当前事务应该看到的那个版本。
4.3.5 当前读和一致性读的区别
这一点很容易混。
一致性读
普通 select 常常属于一致性读。它更关注:读到一个事务视图下自洽的数据版本。
当前读
像下面这些通常更接近当前读:
select ... for updateselect ... lock in share modeupdatedelete
它们更关注“现在这条记录的最新状态”,并且常会配合锁一起工作。
5. 四类 SQL 的执行链路对照
前面已经分别讲了:
- 所有 SQL 共用的前置阶段
- 写操作共享的主线
select的读取主线
所以这里不再把“连接器 -> 解析器 -> 优化器 -> 执行器”对四类 SQL 各重复一遍,而是只保留它们真正拉开差异的部分。
5.1 一张压缩对照表
| SQL 类型 | 更偏读取还是写入 | 执行主线 | 最该关注的成本点 |
|---|---|---|---|
select | 读取 | 选执行路径 -> 取页 -> 走索引 -> 可能回表 -> 判断版本可见性 -> 返回结果 | 索引选择、回表、排序、临时表、缓存命中 |
insert | 写入 | 定位插入位置 -> 页进内存 -> 插入记录 -> 维护索引 -> 写 redo/binlog -> 后续刷脏页 | 页分裂、二级索引维护、自增主键设计 |
update | 写入 | 找到目标记录 -> 写 undo -> 改内存页 -> 维护索引 -> 写 redo/binlog -> 后续刷脏页 | 索引列变更、锁冲突、二级索引维护 |
delete | 写入 | 找到目标记录 -> 写 undo -> 标记删除 -> 写 redo/binlog -> 后续回收空间 | 批量删除、锁冲突、空间回收滞后 |
🌟 这张表真正想表达的是:
select的核心成本更偏“怎么找到、怎么读出来”insert/update/delete的核心成本更偏“怎么改、怎么记日志、怎么保证一致性”update往往是四类 SQL 里最容易被低估的一种,因为它既像读,也像写,还常常连带索引维护
5.2 它们各自最容易被忽略的点
select
如果把共用前置阶段折叠起来,一条普通 select 真正值得记住的是:优化器选择访问路径 -> InnoDB 取页 -> 沿索引或扫描定位记录 -> 必要时回表 -> 按 MVCC 判断可见版本 -> 返回结果
它最容易慢的地方,往往不是“磁盘慢”这么简单,而是:
- 走错索引
- 扫描范围太大
- 回表次数太多
- 还叠加了排序、分组、临时表
insert
如果把共用前置阶段折叠起来,一条 insert 更适合记成:定位插入页 -> 页进 Buffer Pool -> 插入新记录 -> 维护聚簇索引和二级索引 -> 写 redo -> 提交时写 binlog -> 后续刷脏页
它最容易被忽略的是:
- 不只是写一行数据,还要维护相关索引
- 插入位置不理想时,可能触发页分裂
- 表上索引越多,插入成本越高
工程上为什么常说自增主键更友好,本质就在于:插入位置更连续,页分裂和随机写的概率通常更低。
update
如果把共用前置阶段折叠起来,一条 update 更适合记成:先定位旧记录 -> 写 undo -> 修改 Buffer Pool 中的页 -> 如果改了索引列则维护索引 -> 写 redo -> 提交时写 binlog -> 后续刷脏页
它最容易被低估的点在于:
- 先要找到旧记录
- 再要保留旧版本
- 最后还要写入新版本并维护索引
尤其当你更新的是索引列时,代价往往不只是“把某个字段值改掉”,而更像:旧索引项要处理,新索引项要写入,相关页和日志也都要跟着动。
delete
如果把共用前置阶段折叠起来,一条 delete 更适合记成:先定位旧记录 -> 写 undo -> 标记删除 -> 写 redo -> 提交时写 binlog -> 后续由后台机制回收空间
这里很多人最容易误解的是:
delete 不一定等于“这一行立刻从磁盘彻底消失”。
更常见的情况是:
- 先让这条记录在逻辑上对当前操作不可见
- 保留足够的信息支持事务回滚和旧版本可见性
- 再在后续时机做空间清理
所以批量删除为什么经常危险,不只是因为“删得多”,还因为它可能同时带来:
- 大量
undo/redo - 更长时间的锁占用
- 页空间回收滞后
6. 为什么同样是 SQL,有时很快,有时很慢
把前面的流程串起来,你就会发现 SQL 快不快,通常受这些因素共同影响:
- 有没有选对索引
- 是主键查还是二级索引查
- 是否需要回表
- 页是否命中
Buffer Pool - 读取的是单点还是大范围扫描
- 是否还要做排序、分组、临时表
- 当前并发和锁冲突情况
- 写操作是否触发大量索引维护、页分裂或长事务
所以“SQL 慢”很少是某一个原因单独造成的,而是访问路径、缓存、I/O、索引结构和事务机制共同作用的结果。
7. 崩溃后为什么还能恢复
这是理解 InnoDB 执行链路的关键闭环。
数据库崩溃时,最麻烦的问题是:
- 有些数据页已经改了
- 有些数据页还没来得及刷盘
- 有些事务已经提交
- 有些事务还没提交完
如果没有日志体系,数据库很难知道自己应该恢复到哪个一致状态。
InnoDB 能恢复,核心依赖就是:
- 用
redo log把已经提交但还没落到数据页的修改重做出来 - 用
undo log把未完成事务影响的数据回滚掉
所以可以简单记成:
redo:把该有的改动补回来undo:把不该保留的改动撤回去
这也是为什么前面说“事务提交成功不等于数据页已经落盘”依然可以成立。
8. 一份更适合记忆的心智模型
如果你想把这篇文章真正记住,可以把 InnoDB 的数据存储和读取总结成下面两条主线。
8.1 写入主线
定位页 -> 页进内存 -> 写 undo -> 改 Buffer Pool -> 写 redo/binlog -> 提交 -> 后续刷脏页
8.2 读取主线
优化器选路径 -> 从缓存或磁盘拿页 -> 沿索引定位 -> 必要时回表 -> 按事务视图判断是否可见 -> 返回结果
只要这两条线在脑子里清楚,MySQL 里很多看似零散的概念就会自动串起来:
- 为什么主键设计会影响性能
- 为什么索引会影响读写成本
- 为什么事务需要
undo log - 为什么崩溃恢复依赖
redo log - 为什么查询不只是“查磁盘”
9. 总结
你可以把 MySQL InnoDB 的数据存储和读取理解成三层:
- 结构层:数据按页组织,表本质上按聚簇索引存储
- 执行层:写操作先改内存并记日志,读操作先选路径再读页
- 事务层:通过
undo、redo、MVCC、锁来保证一致性和恢复能力
真正理解这一套之后,你再看这些概念就不会觉得它们是彼此孤立的:
Buffer Pool- 脏页
- 聚簇索引
- 回表
undo logredo log- MVCC
- 崩溃恢复
它们其实都围绕着同一个核心问题:
数据库如何在高并发、可恢复、可扩展的前提下,把数据安全地存进去,再稳定地读出来。