从SQL到磁盘的Mysql全链路解析
简介:以一条SQL语句从客户端发出到数据落盘、再到被缓存和同步为主线,系统介绍MySQL的执行细节
引言
MySQL本质上是一个将SQL转变为对磁盘的IO机器,对于用户来说即使他了解SQL语句、知道如何组织索引、各种事务各种隔离级别,但一旦出现各种问题:为什么这个SQL使用了索引仍然执行缓慢、为什么可重复读会导致死锁、为什么删除缓存比更新缓存安全。对于一个成熟的工程师来说,不仅要知道是什么还要知道为什么。由此本文从MySQL的角度解析一条SQL语句是如何被执行的。
MySQL概览:到底是哪部分在执行SQL语句
MySQL服务并不直接管理数据页,它只负责解析SQL 和调度存储引擎。

一个完整的MySQL服务由这几部分组成:
- 客户端连接层:
MySQL面向各种编程语言(JDBC、Python、Go 等)开放TCP连接,MySQL针对每个连接(客户端fd)都分配一个连接线程,用于处理对应客户端的SQL命令。因此MySQL不需要处理大量的连接,采用select不仅可以更好地适配平台、也便于后续调试和维护。
MySQL接收到一条客户端连接,此时连接池分配一条线程阻塞地 处理该客户端连接。因此在基于阻塞队列的数据库连接池这篇文章中,初始化的工作线程与MySQL线程池中初始化的线程数量一一对应。
- SQL Interface、Parser:
来自客户端的SQL语句被解析成SQL对象:数据查询语言(DQL)、数据操作语言(DML)、数据定义语言(DDL)、视图、存储过程等。
解析后的SQL对象被交给Parser分析器,生成语法树、校验合法性等。
- Optimizer:
Parser 输出的语法树会进入 Optimizer。此时优化器会:1)找出可用索引。2)估算各种执行路径的代价。3)选择成本最低的执行计划。换句话说,一条SQL语句是否走索引,是优化器通过一系列统计信息计算出来的。
- 执行器:
执行器按执行计划调用存储引擎接口。只负责将条件、索引返回传给存储引擎,获得存储引擎返回的数据。
- 存储引擎:
存储引擎才是和数据打交道的部分。
undolog:MySQL同时支持事务,因此存储引擎在收到执行器传来的数据时首先记录undolog**,支持MVCC的readview机制,可以让其他事务读取旧版本数据;最后根据当前隔离性判断操作的数据对当前事务是否可见。
***Buffer Pool:***类比与计算机操作系统中的快速缓存,存储引擎使用了一块内存用于缓存之前查找的结果。这一步存储引擎会根据索引定位到底层B+树的叶子节点,而后去Buffer Pool中查找。若命中则本次查询就是一个纯粹的内存操作;反之则需要去磁盘中将数据读出。
***redolog:***类比与Redis的AOF机制,这部分记录了这次操作在某页某个偏移量的位置进行了操作。用于解决事务崩溃后的恢复,以及将随机写磁盘变成顺序写入。
***binlog:***binglog作为MySQL服务层的日志,只有当其内容与redolog一致时才会认为这次数据操作成功了。
因此提交事务时,存储引擎先将redolog数据刷入磁盘进入commit-prepare阶段,尔后才将binlog刷入磁盘完成提交。简单来讲,在存储引擎在一次操作中采用了各种机制确保事务的一致性、数据的完整、主从同步的正确:undolog 管历史操作、buffer pool 记录当前操作、redolog 负责未来提交正确、binlog确保分布式一致。
SQL语法:从范式到 CRUD 的数据库建模与操作逻辑
数据库范式
数据库范式并不是简单地约束用户的规则:第一范式、第二范式、第三范式讲成"字段原子性、部分依赖、传递依赖"。而MySQL的所有数据都是从索引出发而构建的B+树,因此数据库只关心:页是否稳定、索引是否可预测、写入是否局部化。而范式,正是为这三件事服务的。
***一范式(1NF):***要求每一列都是不可再分的原子值,看起来是为了规范化,实际上是为了让 InnoDB 能正确构建 B+ 树索引。如果你把多个值塞进一个字段,比如在 phones 里存 "138...,139...",那么这一列在 B+ 树中就无法形成可比较、可排序的键值。索引无法按手机号定位记录,查询就只能走全表扫描。此时违反 1NF 等价于:该列不能作为可搜索的 B+ 树路径。所以 1NF 的物理意义是:让字段成为可以被 B+ 树排序和查找的最小单元。
***二范式(2NF):***消除对联合主键的部分依赖,但对于存储引擎来说它的本质是:不要把不属于同一条记录生命周期的数据,绑在同一棵聚集索引上 。假设有 (order_id, product_id) 做主键,把订单信息(下单时间、用户ID)和商品信息(商品名、价格)都放在一张表里,那么商品价格变动一次,就会导致这张表中所有包含该商品的记录都要被更新。这意味着同一棵 B+ 树中大量叶子页被修改,redo log 内容剧增,Buffer Pool 中大量页被标脏,页分裂概率急剧上升。因此2NF 的真正作用是:把不同生命周期的数据拆进不同的 B+ 树,让一次修改只影响一小棵索引树。
***三范式(3NF):***消除传递依赖。比如用户表中既存 city_id 又存 city_name。这种冗余等价于:在多棵 B+ 树里复制了同一份变化源。当城市名改一次,你要更新所有用户记录,造成大范围页修改和锁扩散。3NF 的物理意义是:让变化只存在于一棵索引树中,其他表只引用它。这样城市名更新,只会修改城市表那一棵 B+ 树,而不会扰动用户表的页结构。
在工程实践中,会考虑使用空间换取更高的查找效率。反范式一定程度上违反范式规则:把 city_name 冗余进 user 表,在查询用户时就不需要联合城市表进行查询,在底层减少了一次B+树的查找和Buffer Pool的访问。在读远大于写的系统中,这种做法可以显著提高效率。
存储引擎视角的CRUD
对MySQL用户来说,CRUD 是四种数据操作。但对于存储引擎来说,它们是四种完全不同的物理行为,也对应着对 B+ 树、Buffer Pool 与日志系统的不同操作。
select :它首先会根据优化器选定的索引,从 B+ 树的根节点一路向下走到叶子节点,定位到包含目标记录的数据页。如果这个页已经在 Buffer Pool 中,那么读取只是一次内存访问;如果不在,就必须触发一次磁盘 IO 把整个 16KB 页读入内存。因此一个 where 条件,实际上是在决定 InnoDB 要走哪一条 B+ 树路径、命中多少层缓存、是否触发随机 IO。这也是为什么"是否走索引"会带来数量级的性能差异,因为它决定的是:读一页,还是全表扫描。
insert :存储引擎必须在主键 B+ 树中找到新记录应该插入的位置,如果这个位置所在的数据页还有空间,就直接写入;如果页已经满了,就要触发页分裂,把一页拆成两页,并重新调整父节点中的指针。页分裂不仅意味着一次额外的内存和磁盘写入,还会导致索引结构变得更深、更碎。因此主键是否自增,决定了插入是顺序写入同一批页,还是不断地触发页分裂。
update :对于存储引擎来说是一个典型的"写时复制"过程。引擎首先为这条记录生成一份 undo log,保存旧版本,以便回滚和 MVCC 使用,然后在 Buffer Pool 中修改记录所在的数据页,并写一条 redo log 描述这次物理修改。这意味着一次 update 至少会触发三处状态变化:undo 区新增一条记录,数据页变脏,redo 日志追加。因此一次update操作对存储引擎来说是一整套版本链和日志链在被推进。
delete :存储引擎并不会立即把这条记录从页中移走,而是给它打上一个 delete mark,把它变成"逻辑删除"。这样做是为了保证正在进行的事务仍然可以通过 undo log 看到旧版本,同时避免频繁移动页内记录导致页碎片和锁复杂化。真正的物理删除,是由后台的 purge 线程在合适的时机完成的。这也是为什么 delete 很多数据后,表文件不会立刻变小,因为 B+ 树里的空间只是被标记为可复用,而不是被释放。
SELECT 在驱动 B+ 树遍历和缓存命中,INSERT 在改变树的形状,UPDATE 在推进版本链和 redo 流,DELETE 在制造碎片并等待回收。
理解这一点之后,对于某条SQL执行效率过慢就能从更底层的角度出发:这条 SQL 让 B+ 树怎么动、让 Buffer Pool 怎么抖、让 redo 和 undo 怎么膨胀。
五大约束
创建表时定义:NOT NULL 、UNIQUE 、PRIMARY KEY 、FOREIGN KEY 、AUTO_INCREMENT 时,本质上是在告诉存储引擎哪些规则必须被强制执行。
NOT NULL 避免了记录中出现没有意义的空值,使得存储格式更加紧凑,也减少了执行时的分支判断。UNIQUE 和 PRIMARY KEY 直接体现在 B+ 树层面,它们要求索引中不能出现重复 key,这意味着存储引擎在插入时必须先检查索引树是否已有该值,这个检查本身就需要读页和加锁。PRIMARY KEY 更进一步,它决定了整张表的数据物理顺序,主键的选择会影响页分裂频率和写入顺序性。AUTO_INCREMENT 则引入了存储引擎的自增锁,用来保证多个事务插入时不会生成重复主键。FOREIGN KEY 则确保写入时必须跨表校验引用关系,这在高并发下代价极高。
高级SQL操作
SQL从简单的单表 CRUD 到 delete、group by、join、subquery 时,它已经不再只是"取几行数据",而是在同时调度 InnoDB 的索引结构、Server 层的排序器和内存临时表。这些操作,需要我们明白:MySQL 要如何在 B+ 树与内存中重新组织数据。
delete、truncate 与 drop :delete 是标准的事务性操作,它会逐行扫描记录,对每一行生成 undo log、写 redo log,并打上删除标记,这意味着它会推动 B+ 树、undo 链和 redo 流一起向前滚动。当数据量很大时,delete 的本质是:让存储引擎对整棵B+索引树做一次完整的"逻辑修改";而 truncate 则完全跳过这一切,它直接丢弃整棵 B+ 树并重新创建表结构。因此它几乎是 O(1) 的操作,但也无法回滚;drop 更进一步,连树和文件都一起删除。因为要操作的内容不同,他们的执行效率依次提高。如果看到某条delete语句很慢,说明存储引擎正在对每一个叶子页进行一次完整的事务和日志流程。
where、distinct、group by :这些语句决定:**能不能利用索引的有序性尽可能少的遍历B+树。**如果 where 条件可以匹配索引前缀,那么 B+ 树就能在很小的范围内定位叶子页;如果不能,就只能从根节点一路扫到每一个叶子页。group by 如果与索引顺序一致,就可以在扫描索引的同时完成分组;如果不一致,MySQL 就必须把大量数据拉入临时表,再通过排序器重排后做聚合。distinct 也是同理,如果索引已经天然有序,就可以在扫描时去重,否则就只能用排序或 hash 表来消除重复。
sql
-- 聚合函数
SELECT sum(`num`) FROM `score`; -- 计算总和
SELECT avg(`num`) FROM `score`; -- 计算平均值
SELECT max(`num`) FROM `score`; -- 计算最大值
SELECT min(`num`) FROM `score`; -- 计算最小值
SELECT count(`num`) FROM `score`; -- 统计总数
sql
-- 分组查询
-- 以gender分组 将每个分组中的值合并在一起
SELECT `gender`, group_concat(`age`) as ages FROM `student` GROUP BY `gender`;
/*gender ages
男 18,20,19
女 18,22*/
-- 查询结果按照某个字段分组显示
SELECT `gender` FROM `student` GROUP BY `gender`;
-- 将查询结果按照聚合函数进行计算
SELECT `gender`, count(*) as num FROM `student` GROUP BY `gender`;
-- 将查询结果按照聚合函数以及条件进行计算
SELECT `gender`, count(*) as num FROM `student` GROUP BY `gender` HAVING num > 6;
JOIN 将操作范围扩大到了多棵 B+ 树之间。从执行流程上看,它只是一个嵌套循环:MySQL 先选一张表作为驱动表,在它的索引树上扫描出一批记录,然后对每一条记录,再去另一张表的索引树上寻找匹配项。如果内表的关联字段有索引,那么每次匹配都是一次对数级别的 B+ 树查找;如果没有索引,那么对外表的每一行,内表都要被全表扫描一次。
JOIN联表查询可以参考集合数学中的集合操作:

sql
-- INNER JOIN 求两张表指定属性的交集
SELECT cid
FROM `course`
INNER JOIN `teacher` ON course.teacher_id = teacher.tid;
-- LEFT/RIGHT JOIN 在交集的基础上保留左表/右表对应属性的内容
SELECT course.cid
FROM `course`
LEFT/RIGHT JOIN `teacher` ON course.teacher_id = teacher.tid;
子查询则更加直观。in、exists、派生表,本质上是在告诉 MySQL:先执行一段 SQL,把结果集物化成一个中间结构,再用它去驱动另一段 SQL。这些中间结果往往会被放进内存临时表,必要时还要排序、去重,再参与外层查询的匹配。对于MySQL 来说:**一段查询的结果被当成了另一段查询的输入数据源。**当数据量变大时,这种"嵌套执行器"模式通常比一条等价的 JOIN 成本更高。
sql
select * from course where teacher_id =
(select tid from teacher where tname = 'teacher1')
预处理语句
在普通的 SQL 执行模型中,每一条 SQL 语句都会完整地走一遍 Server 层流程:解析、语法树生成、优化器选索引、生成执行计划、再交给 InnoDB 执行。即便只是参数不同的两条语句,例如:
sql
select * from user where id = 1;
select * from user where id = 2;
在 MySQL 看来,它们是两条完全独立的 SQL 字符串,每次都要重新解析、重新做优化器成本计算、重新生成执行计划。这意味着优化器和解析器会被反复触发,尤其在高 QPS 的系统中,这部分 CPU 消耗会冗余。
引入预处理语句则改变了这个情况,它将SQL拆成了两个部分:结构 、参数。
sql
prepare stmt from 'select * from user where id = ?';
MySQL会对这条 SQL 做一次完整的解析和优化,生成一个可执行的内部对象,并缓存下来。这个对象中已经确定了:用哪棵索引、走哪条 B+ 树路径、执行顺序是什么。此时,这条语句在 Server 层已经不再是一个字符串,而是一个固定的执行计划模板。后续每次执行:
sql
execute stmt using @id;
MySQL 做的事情只是:把参数绑定到已经存在的执行计划中,然后直接调用执行器交给存储引擎。Parser 和 Optimizer 都不会再参与,这就把一条 SQL 从"每次都重新思考如何执行",变成了"反复执行同一条 B+ 树访问路径"。
SQL语句练习
根据提供的表分别写出下列操作的SQL语句。

- 查询平均分大于60分的同学的学号和平均成绩。
sql
-- 每个学生有不止一种课程成绩 因此需要按学生编号来分组查看每个学生的各课程成绩
SELECT student_id, num
FROM score
GROUP BY student_id;
-- 得到每个学生的各科成绩 对每个组求平均值
SELECT student_id, AVG(num) AS avg_num
FROM score
GROUP BY student_id;
-- 最后根据平均成绩字段 筛选平均分>60的学生
SELECT student_id, AVG(num) AS avg_num
FROM score
GROUP BY student_id
HAVING avg_num > 60;
- 查询 'c++高级架构' 课程比 '音视频' 课程成绩高的所有学生的学号;
sql
-- 需要明确的是 需要对同一个student_id找到两条num记录(c++、音视频)而后比较num
select cid from course where cname='c++高级架构'; -- 1
select cid from course where cname='音视频'; -- 2
-- 因此实际上是比较 course_id=1 和 course_id=2 的成绩
-- 将score表拆成两份 s1表示c++ s2表示音视频 最后使用student_id进行对齐
SELECT s1.student_id
FROM socre s1
JOIN course c1 ON s1.course_id = c1.cid
JOIN score s2 ON s1.student_id = s2.student_id
JOIN course c2 ON s2.course_id = c2.cid
WHERE c1.cname = 'c++高级架构'
AND c2.cname = '音视频'
AND s1.num > s2.num;
- 查询所有同学的学号、姓名、选课数、总成绩;
sql
-- 结合联表查询 分组查询 聚合函数
-- 需要所有同学的学号 因此联表查询要保留没有选课同学的信息
-- 以学号、姓名分组分别显示每个学生选择课程和总成绩
SELECT
s.sid,
s.sname,
COUNT(sc.course_id) AS course_count,
SUM(sc.num) AS total_score
FROM student s
LEFT JOIN score sc -- 联表查询要保留没有选课同学的信息 因此选择LEFT JOIN
ON s.sid = sc.student_id
GROUP BY s.sid, s.sname;
- 查询没学过 '谢小二' 老师课的同学的学号、姓名;
sql
-- 只需要查找出一条:学过谢小二老师的课的记录 使用NOT EXISTS排除即可
SELECT 1
FORM score sc
JOIN course c ON sc.course_id = c.cid
JOIN teacher t ON c.teacher_i = t.tid
WHERE t.tname = '谢小二老师'
-- 在student表基础上 NOT EXISTS掉这条记录即可 同时还需要把student与score的学生id对齐
SELECT s.sid, s.sname
from student s
WHERE NOT EXISTS (
SELECT 1
FORM score sc
JOIN course c ON sc.course_id = c.cid
JOIN teacher t ON c.teacher_i = t.tid
WHERE sc.student_id = s.sid
AND t.tname = '谢小二老师'
);
- 查询学过课程编号为 '1' 并且也学过课程编号为 '2' 的同学的学号、姓名;
sql
-- 参考c++ 音视频例题 使用两次自连接 得到两个集合:课程编号1、课程编号2
SELECT s.sid, s.sname
FROM student s
JOIN score sc1 ON sc1.student_id = s.sid AND sc1.course_id = 1
JOIN score sc2 ON sc2.student_id = s.sid AND sc2.course_id = 2;
- 查询学过 '谢小二' 老师所教的所有课的同学的学号、姓名;
sql
-- 先找出'谢小二老师'教的所有课程
-- 对于每个学生 只看他在'谢小二老师'教的所有课程中的选课记录
-- 判断学生选课数量是否等于'谢小二老师'教的所有课程数量
SELECT s.sid, s.sname
FROM student s
JOIN score sc ON sc.student_id = s.sid
JOIN course c ON c.cid = sc.course_id
JOIN teacher t ON t.tid = c.teacher_id
WHERE t.tname = '谢小二老师'
GROUP BY s.sid, s.sname
HAVING COUNT(DISTINCT c.cid) = ( -- 统计学生在该老师课堂学过几门
SELECT COUNT(*)
FROM course c2
JOIN teacher t2 ON t2.tid = c2.teacher_id
WHERE t2.tname = '谢小二老师'
);
- 查询有课程成绩小于 60 分的同学的学号、姓名;
sql
-- 使用EXISTS
SELECT s.sid, s.sname
FROM student s
WHERE EXISTS (
SELECT 1
FROM score sc
WHERE sc.student_id = s.sid
AND sc.num < 60
);
- 查询没有学全所有课的同学的学号、姓名;
sql
-- 全集与子集问题
-- 全集:course表中所有课程
-- 子集:score表中对应的课程
-- 通过COUNT聚合函数 如果学生选课数<总课程数则不合格
SELECT s.sid, s.sname
FROM student s
LEFT JOIN score sc ON sc.student_id = s.sid -- 确保没选课的学生也能被统计
GROUP BY s.sid, s.sname
HAVING COUNT(DISTINCT sc.course_id) < (SELECT COUNT(*) FROM course);
- 查询至少有一门课与学号为 '1' 的同学所学相同的同学的学号和姓名;
sql
-- 本质上是一个求交集的操作
-- 先找到学号为1学生修的所有课
SELECT course_id
FROM score
WHERE student_id = 1
-- 再求出所有学生选课的集合 求交集 若存在一门则满足
SELECT s.sid, s.sname
FORM student s
WHERE EXISTS (
SELECT 1
FROM score sc
WHERE sc.student_id = s.sid
AND sc.course_id IN (
SELECT course_id
FROM score
WHERE student_id = 1
)
);
-- 若需要排除学号1自己
SELECT s.sid, s.sname
FORM student s
WHERE s.sid <> 1 -- 排除学号1
AND EXISTS (
SELECT 1
FROM score sc
WHERE sc.student_id = s.sid
AND sc.course_id IN (
SELECT course_id
FROM score
WHERE student_id = 1
)
);
- 查询至少学过学号为 '1' 同学所有课的其他同学学号和姓名;
sql
-- 集合中的包含关系
-- 找出其他同学的课程 要包含学号为1同学的课程集合(T)
-- 只统计其他同学在T集合中的覆盖数 如果相等的说明该同学覆盖了T
-- 查找某个同学 他的选课集合与学号为1同学的集合相同
SELECT s.sid, s.sname
FROM student s
JOIN score sc ON sc.student_id = s.sid
WHERE s.sid <> 1 -- 排除学号1
AND sc.course_id IN (
SELECT course_id
FROM score
WHERE student_id = 1
);
-- 再基于上述集合 统计该条件下该同学总的选课数 与学号1同学进行比较
SELECT s.sid, s.sname
FROM student s
JOIN score sc ON sc.student_id = s.sid
WHERE s.sid <> 1 -- 排除学号1
AND sc.course_id IN (
SELECT course_id
FROM score
WHERE student_id = 1
)
GROUP BY s.sid, s.sname
HAVING COUNT(DISTINCT sc.course_id) = (
SELECT COUNT(DISTINCT course_id)
FROM score
WHERE student_id = 1
);
SQL题目总结
用"集合"理解题目
先找全集 → 再看是存在 还是覆盖 → 决定 EXISTS 还是 COUNT → 再决定是否要自连接
| 题目在说什么 | 转化为 | SQL |
|---|---|---|
| 学过某课 | 这个学生的课程集合里是否包含它 | EXISTS |
| 没学过某课 | 这个集合里是否不包含它 | NOT EXISTS |
| 至少一门相同 | 两个集合是否有交集 | EXISTS + IN |
| 学过所有 | 子集是否覆盖全集 | COUNT = 全集大小 |
| 没学全 | 子集是否小于全集 | COUNT < 全集 |
| 学 1 和 2 | 是否同时包含两个元素 | 自连接 |
| A 比 B 高 | 同一人两条记录的比较 | score 自连接 |
| 所有学生 | 驱动表是 student | LEFT JOIN |
| 只要满足条件的人 | 不用保留全集 | JOIN / EXISTS |
- 存在型(有没有)
出现:有...、学过...、至少一门...、是否...
sql
WHERE EXISTS (
SELECT 1
FROM score
WHERE score.student_id = s.sid
AND 条件
)
表示集合里是否存在元素。
- 覆盖型(全不全)
出现:所有、全部、覆盖、学全。
sql
GROUP BY 学生
HAVING COUNT(DISTINCT 命中课程) = (全集大小)
说明子集是否覆盖全集。
- 对比型 (A vs B)
出现:比、同时、即...又...
sql
FROM score s1
JOIN score s2 ON s1.student_id = s2.student_id
表示同一个人在同一张表中的两条记录。
- 数值聚合约束
出现:平均分、总分、最高分、大于 60。
sql
GROUP BY student_id
HAVING 聚合函数(num) 条件
本质上求的是对每个学生,将某个列值聚合后该数值是否满足条件。
| 题目 | 用的模型 |
|---|---|
| 平均分 > 60 | 数值聚合 |
| C++ > 音视频 | 同一实体对比 |
| 所有同学 统计 | 数值聚合 |
| 没学过谢小二 | 集合排除 |
| 学 1 且学 2 | 同一实体对比 |
| 学完谢小二所有课 | 集合包含 |
| 有不及格 | 集合存在 |
| 没学全所有课 | 集合包含 |
| 与 1 有共同课 | 集合交集 |
| 覆盖 1 的课程 | 集合包含 |
| 母题 | SQL |
|---|---|
| 集合存在 | EXISTS |
| 集合排除 | NOT EXISTS |
| 集合交集 | EXISTS + IN |
| 集合包含 | GROUP BY + COUNT |
| 数值聚合 | GROUP BY + HAVING(AVG/SUM/MAX/MIN) |
| 同一实体对比 | score 自连接 |
MySQL索引原理及优化
索引到底是什么?
对于存储引擎来说,每建一个索引,就等于建了一棵新的 B+ 树。表数据实际上是放在主键索引那棵树的叶子页里;用户执行查某行数据,实际上是从根节点一路向下走到叶子节点,最后把叶子页从 Buffer Pool 里读出来(读不到则去访问磁盘)。这种结构的关键点在于:B+ 树把磁盘访问次数压到很低,把随机 IO 尽量变成少量的页读取。
索引类型
不同的索引在约束性、数据组织方式、以及对写入的影响上差别很大。常见分类包括:主键索引、唯一索引、普通索引、组合索引、全文索引。
主键索引既是约束(非空唯一),也是"数据本体"。在 InnoDB 中,主键索引的 B+ 树叶子节点直接包含整行数据,所以它也常被称为"聚集索引"。换句话说:选择的主键,本质上决定了整张表的物理排列方式。
唯一索引提供唯一性,但允许 NULL。它更像一种将业务规则写进存储层的方式:把重复的可能性直接扼杀在写入阶段。
普通索引不保证唯一,强调的是"减少扫描范围"。它是最常见也最容易被滥用的:建太多会让写入成本飙升(每次 insert/update 可能要改多棵树),建太少会让查询变成全表扫描。
组合索引则是把多个列拼进同一棵树里,真正的作用来自它对查询的"路径约束":你能不能走索引、能走多少层,很大程度取决于是否满足最左匹配。
全文索引 则是另一套逻辑:它不是为了等值/范围快速定位,而是为了在"大文本内容"里做检索(match/against),短字符串时采用 LIKE %...% 的方式。
如何进行主键选择
InnoDB 里每张表必须有且仅有一个主键 ,若不指定,它会在非空唯一索引 里挑一个;再没有,就自动生成隐藏rowid。表面上看似是"规则",但背后是物理现实:主键决定聚集索引的叶子页顺序,也就决定了插入是追加还是随机插入引发频繁页分裂。因此工程上主键常用自增/趋势递增 ,是因为它把写入更接近顺序 IO,尽可能降低页分裂概率。
由此我们也可以看出:数据库表的五大约束是逻辑概念,而具体发挥作用的是与物理数据直接相关的索引,创建索引就同时映射了对应的约束。
索引的原理以及实现
讲了这么多前提,那么索引到底是什么、它又是如何实现的?
B+树

在InnoDB中,索引是实实在在存储在磁盘页中的 B+ 树结构。每建一个索引,就等于让InnoDB多维护一棵 B+ 树,而这棵树的每一个节点,最终都落在 16KB 的数据页里,被Buffer Pool缓存,被redo log保护,被LRU机制管理。
InnoDB 采用B+树而不是二叉树或B树,其核心目的只有一个:最小化磁盘随机 IO 次数 。B+ 树的每个节点可以容纳大量 key,这让树的高度通常只有 3~4 层。一次查询,从根到叶子,只需要读极少数几个页,同时也说明了查询时通过索引索引 和全表扫描会是数量级的差距。
物理组织方式
Innodb将表数据分级管理:存储所有表数据的表空间;表空间中用于存储数据的数据段、存放索引的索引段等;每个段又不同的区组成,每个区由固定大小的页构成(默认16KB),上一节提到的B+树的每一个节点就对应一个页。

InnoDB的最小读写单位就是页。对于某种索引的B+树来说,它只能看见大量的页。说明了:哪怕只select一行,或是update一个字段,对于InnoDB来说最终都是整页被载入、修改、刷回。
聚集索引与辅助索引
既然约束、索引有分类,那索引的物理结构------B+树有没有分类呢?
具体的表数据与索引在 InnoDB 中往往以键值对的方式对应,对于聚集索引数据与行数据以:map<int, course> 一一对应;而聚集索引则是以mulitmap<int, paris<int, int>>方式组织。
InnoDB 中最重要的一棵 B+ 树叫 聚集索引(clustered index) ,它就是主键索引。与其他数据库不同,InnoDB 的主键索引叶子节点存的是整行数据本身而不是指针。换句话说,表数据就是聚集索引B+树的叶子节点。因此在建表时哪怕没有显示声明主键约束、非空唯一索引,InnoDB也会默认生成一个自增索引,为的就是确保数据能以B+树的方式进行承载。同时如果主键是自增的,那么新数据几乎总是追加到最右侧叶子页,页分裂少,Buffer Pool 命中好,写入顺序接近顺序 IO;如果主键是以随机值生成,每一次 insert 都可能插进树的中间,引发页分裂、页移动、父节点更新,性能会明显变差。
除了聚集索引,所有所有普通索引、唯一索引、组合索引,都是 辅助索引(secondary index) 。它们也是 B+ 树,但叶子节点不存整行,而只存两样东西:索引列的值、对应行的主键值。当使用辅助索引查询数据,InnoDB实际上需要查询两棵树:先在辅助索引 B+ 树里找到主键;再拿这个主键去聚集索引那棵树里,查到整行数据。这种操作就叫做回表查询 。就能说明在查询时select * 经常比 select id, name 慢,select *必须执行回表查询带来了额外的开销。
执行一次下述语句:
sql
select name from user where age = 30;
如果 age 上有辅助索引,那么 InnoDB 会:
-
从 age 索引的根页开始,沿着 B+ 树找到 age=30 的叶子页。
-
在叶子页中拿到对应记录的主键 id。
-
用 id 去主键索引的 B+ 树中,再走一遍路径。
-
从聚集索引叶子页取出整行,再返回 name。
若这次查询的目标是age,id,这项数据刚好在辅助索引B+树叶子节点上刚好存在(索引覆盖),则无需回表查询直接返回即可。
索引的特殊机制
前文介绍的索引确保我们了解底层的索引和物理页是如何组织的,但使用索引还需要满足某些要求,才能确保索引高效执行。
最左匹配原则
组合索引本质上是一棵按 (A, B, C...) 拼接排序的 B+ 树。
tex
(A1,B1,C1)
(A1,B1,C2)
(A1,B2,C1)
(A2,B1,C1)
...
因此基于组合索引查找,InnoDB只能从左到右连续地使用列来缩小搜索范围 。一旦针对某列使用了范围查询(> < between like),树在这一层之后就不再有全序性了,后面的列就无法继续用于定位。
sql
-- B+ 树可以精确定位到一个很小的叶子区间
where A=1 and B=2 and C=3
-- 到了 B>2 这一层就变成范围扫描,C 已经失去了在树中"继续二分"的意义。
where A=1 and B>2 and C=3
覆盖索引
在 InnoDB 中,辅助索引的叶子节点仅存储了辅助索引以及对应的聚集索引。如果某次查询的列,刚好都在这个辅助索引里,那么InnoDB根本不需要再去聚集索引那棵树查整行,这就是覆盖索引。
sql
-- 创建辅助索引
INDEX idx_name_age(name, age)
select name, age from user where name='Tom';
这条 SQL 完全可以在 idx_name_age 这棵 B+ 树的叶子页中直接完成,不需要回表。对于InnoDB来说,它就减少了一次B+树(聚集索引)的遍历
索引下推
前文提到,MySQL分为两层:Server层负责对接SQL接口、解析SQL语句、生成执行计划;存储引擎层负责页与索引。
在MySQL5.6之前,一次SQL执行语句:1)存储引擎按索引条件取一批候选行(主键)。2)Server 层再对每一行判断剩余条件。3)不符合的再丢掉。这种执行逻辑下,很多应该被过滤掉的数据被冗余地从InnoDB中查询了。
因此后续版本考虑将部分涉及索引列的条件,下推到存储引擎层,让InnoDB在扫描B+树叶子节点时就做过滤。此时一次SQL就变成了:1)存储引擎在索引页中直接判断条件。2)只有通过的记录才回表。3)Server 层只做最终汇总。大幅减少了冗余的回表操作,其本质是将筛选从CPU移动到了B+树扫描阶段。
索引失效
下述几种情况会导致索引失去其有序性无法用于进一步搜索。
在索引列上使用函数:
sql
-- 等价于 f(idx) = const,树中存的是 idx,不是 f(idx)无法定位查找。
from_unixtime(idx) = '2021-04-30'
隐式类型转换:
sql
-- 如果 idx 是 int,会发生idx转成字符串再比较,等价于在索引列上套函数。
where idx = '123'
LIKE '%xxx':这等价于对 key 做后缀匹配,B+ 树只对前缀有序,这直接让树失去可用路径。
<> !=:不等于意味着"几乎所有值",树无法形成连续区间,只能全表扫描。
组合索引没用第一列:不符合最左匹配,例如:(A,B,C) 的树,没给 A,就没法确定从哪一段开始走,只能全表扫描。
索引设计原则
- 选区分度高的列
区分度 = count(distinct col) / count(*)
区分度越高,B+ 树能越快缩小搜索区间。
-
索引尽量短
short key = 每页能放更多 key → 树更矮 → IO 更少。
-
长字符串用前缀索引
用
left(col, N)折中存储与区分度。但代价是:前缀索引不能做 order by / group by,因为它不是完整排序。
-
扩展索引,不要重复建树
(A)已有,再建(A,B)是扩展;再建
(A)是浪费一棵树。 -
不要 select *
否则会造成大量回表和全表扫描,毁掉覆盖索引。
InnoDB体系结构
仅仅有索引InnoDB还无法做到真正的高效,让存储引擎能高速运转的,是一整套围绕 页缓存、日志系统与事务可见性 构建的引擎体系。InnoDB 并不是一个简单的"把数据写到磁盘"的组件,而是一台精密的 事务型存储机器。
从架构上看,InnoDB 可以被理解为三大子系统的协同工作: Buffer Pool(内存层)、Log System(持久性与恢复)、Background Threads(异步维护)。

Buffer Pool
InnoDB 并不是直接对磁盘上的 B+ 树页进行操作,它几乎所有的读写都发生在 Buffer Pool 中。Buffer Pool 是一块由 InnoDB 自己管理的大内存区域,用来缓存:聚集索引页、辅助索引页、undo页、changebuffer页。
当执行一条查询时,InnoDB 首先会检查目标页是否已经在 Buffer Pool 中,如果在,则完全避免磁盘 IO;如果不在,才会从磁盘把整个 16KB 页加载进来。
如果所有的数据读写都通过内核,当用户使用fflush写入文件数据,此时数据并没有落盘,而是会缓存在操作系统的page cache中。用户无法管理操作系统的page cache。因此InnoDB自己维护一个数据缓存池,对于某些热点数据可以留存久一些、某些长时间不访问的数据直接淘汰,所有的数据读写都直接写入磁盘。

在Buffer Pool中,索引数据采用LRU算法,只缓存访问次数多的数据,长时间未访问的数据直接淘汰:

LRU相关算法实现:LRU算法实现
Change Buffer
InnoDB同样考虑到辅助索引页的查询效率。如果修改辅助索引,如果该页当前不在 Buffer Pool 中,InnoDB 并不会为了这次写入去做一次磁盘读取,而是先把变更记录在 Change Buffer 中。等后续该页被真正读入内存,或者后台线程触发合并时,再把这些变更应用到真实索引页上。

因此Buffer Pool同时管理三个链表:free list 组织buffer pool中未使用的缓存页;flush list 组织 buffer pool中待刷盘的页;lru list 组织 buffer pool 中冷热数据,当buffer pool没有空闲页,将从lru list中最久未使用的数据进行淘汰;
Log Buffer
在 InnoDB 中,所有对数据页的修改,都会先在内存中完成,并且同时生成一条 redo log ,用于记录这次物理修改。但 redo log 并不会立刻同步写入磁盘,而是先进入一块位于内存中的缓冲区------Log Buffer。当事务对 Buffer Pool 中的页进行修改时,引擎会同步把对应的 redo 记录写入 Log Buffer。这一步只涉及内存拷贝,非常快。
与Buffer Pool不同,Log Buffer因为只记录相较小的redo记录,直接使用操作系统的文件管理接口。
优化SQL
在InnoDB里,一条 SQL 的快慢,本质上是在多少棵 B+ 树上走了多少层,读了多少 16KB 的页所决定的。所 SQL优化的本质不是语法层面,而是让 MySQL 走更短的树、读更少的页、少回表、少排序。
通过EXPLAIN获取到MySQL 计划如何走索引树 。key 表示选了哪一棵 B+ 树,type 表示在这棵树上是精确命中、范围扫描还是整棵树扫描,rows 则是优化器估算需要扫多少个叶子节点。const、eq_ref 意味着只会命中极少的叶子页,range 表示在树上扫一段连续区间,而 index 或 ALL 则意味着整棵索引树甚至整棵聚集索引树都要被从头到尾扫一遍。优化SQL的第一步就是通过这个操作得到InnoDB是如何执行的。
通过条件语句判断这条SQL未来会走什么路径,where 决定存储引擎能否在 B+ 树中定位一个连续区间,group by 和 order by 决定能否沿着索引的有序性自然输出结果,还是必须在 Server 层额外做一次排序和聚合。而 in 和 not in在逻辑上很简洁,但在执行器眼里往往意味着"对子查询结果集做嵌套判断",这会产生中间表和大量随机访问。尽量改写成等价的 JOIN,往往能让 MySQL 用上连接字段的索引,把"逐行判断"变成"在两棵 B+ 树之间做嵌套查找",这在大数据量下会产生数量级的差异。与之类似,过多的多表关联本质上就是多棵索引树的嵌套遍历,表越多,路径越长,因此在设计查询时,能减少一次 JOIN,往往就等于少爬一整棵树。
在 InnoDB 中,SQL 的快慢从来不是由语法决定的,而是由 数据在 B+ 树与数据页之间如何流动 决定的。每一个索引都是一棵真实存在的 B+ 树,主键索引决定了表的物理排列,辅助索引通过索引值 → 主键 → 聚集索引完成回表,覆盖索引和索引下推的意义在于减少树的遍历次数与页的访问次数;最左匹配、索引失效、区分度、短索引和前缀索引,本质上都是在维护 B+ 树的有序性和高度。Buffer Pool、LRU、Change Buffer、Redo Log 与 Log Buffer 则构成了围绕这些页运转的内存与日志体系,使随机 IO 被吸收、写入被顺序化、事务得以恢复与持久。最终,SQL 优化就是让 MySQL 走更短的树、读更少的页、少回表、少排序。
MySQL 事务原理
事务不仅是SQL语法层的 begin / commit,还是 对 B+ 树、undo 链、redo 日志和锁系统的一次协同调度 。它能确保多个线程在同时修改同一棵索引树时,仍然能维持一致性、可恢复性与可预测性 。事务的本质是:并发控制的最小单位。一组 SQL 要么整体生效,要么整体失效,确保数据库从一个一致状态跳到下一个一致状态,保证系统始终处于一个完整且正确的状态。
ACID
原子性(A):InnoDB 在每一次修改行数据之前,都会先把"旧版本"写入 undo log。发生回滚时,存储引擎会按undo log里的记录,把数据恢复到未执行前
**持久性(D):**事务提交的那一刻,InnoDB 不会强制把脏页刷盘,而是把 redo log 刷到磁盘。redo 记录的是:哪个页、哪个偏移、改成了什么。这是物理日志。redo确保了哪怕 Buffer Pool 数据全部损失,InnoDB 也能把 B+ 树重放出来。
**隔离性(I):**多个事务同时操作同一块数据,要确保每个读写事务对其他事务相互分离不会互相影响。
一致性(C):事务的前后,所有的数据都保持一个一致的状态。数据库完整约束一致性是数据必须遵守的刚性规则 ,这些规则在数据库设计阶段就被明确定义,并被数据库管理系统(DBMS)严格强制执行,确保数据在结构层面不会出现"不可能"的状态;而逻辑一致性 是数据在业务逻辑层面 的正确性,确保数据库从一个有意义的业务状态 转换到另一个同样有意义的业务状态。确保数据不仅在结构上正确,在业务意义上也合理。
四种隔离级别
InnoDB支持四种事务隔离级别,其本质上是规定了事务在并发访问数据时能看到同一份数据的什么版本。
READ UNCOMMITTED(读未提交)
直接读Buffer Pool里最新的行版本,即使这个版本是另一个事务刚改但还没 commit 的;写时仍然会加行锁,仍然会写 undo / redo,但其他事务可以看到当前事务还未没提交的数据。
这种隔离级别会发生脏读异常:
sql
-- 事务A
update account set balance = 0 where id = 1; -- 未提交
-- 事务B
select balance from account where id = 1; -- 看到 0
-- 事务A
rollback;
此时事务B看到的数值0从来没有存在过,凭空产生了一个幽灵值,破坏了一致性。因此这种隔离级别基本上不会使用。
READ COMMITTED(读已提交)
读数据时直接使用MVCC,从Read View中读最新的数据;写数据时加行锁,但不加gap锁,只对已存在的记录加锁。
不可重复读异常:
sql
-- 事务A
select balance from account where id=1; -- 100
-- 事务B
update account set balance=50 where id=1;
commit;
-- 事务A
select balance from account where id=1; -- 50
此时事务A查询account,从Read View中读最新的版本,看到了B已经提交的版本。
幻读异常:
sql
-- 事务A
select * from orders where amount > 100; -- 返回 5 条
-- 事务B
insert into orders values(..., amount=200);
commit;
-- 事务A
select * from orders where amount > 100; -- 返回 6 条
因为没有gap锁,新的数据随时可以提交进来。造成事务A两次范围查找的数据不一致。
REPEATABLE READ(可重复读,InnoDB 默认)
整个事务都只创建一个Read View,后续所有查询都只用这一个视图。换句话说后续所有的查询都只看到事务开始那一刻的快照。相对应的写操作时,不仅对已经存在的记录加锁,还通过Gap锁对未来可能插入的位置加锁。不仅锁住当前数据,还锁住未来可能出现数据的位置。
在这种模式下不可重复读和幻读都会被解决:
sql
-- 事务A
select balance from account where id=1; -- 100
-- 事务B
update account set balance=50 where id=1;
commit;
-- 事务A
select balance from account where id=1; -- 仍然 100
因为A读写的Read View是事务开始时创建的。它看到的Read View中,B的版本对他不可见,因此不可重复读无从发生。
sql
-- 事务A
select * from orders where amount > 100;
-- 事务B
insert into orders values(..., amount=200);
在可重复读隔离模式下,因为Gap锁的存在,所有未来可能插入数据的列(amount > 100)都被锁住。此时事务B的插入操作会被阻塞直到事务A提交。
SERIALIZABLE(可串行化)
这种模式就是简单地读写均加锁,所有命令串行执行互不影响,保证了强一致性,但降低了性能。
简单总结四种隔离模式:
| 隔离级别 | 读 | 写 | 是否锁 gap | 是否用 MVCC |
|---|---|---|---|---|
| RU | 直接读最新 | 行锁 | ❌ | ❌ |
| RC | 每次新快照 | 行锁 | ❌ | ✅ |
| RR | 事务级快照 | 行锁 + gap | ✅ | ✅ |
| SERIALIZABLE | 读也加锁 | 全部锁 | ✅ | ❌ |
MVCC原理
MVCC(Multi-Version Concurrency Control,多版本并发控制)的出现是为了确保数据在不加锁的情况各事务仍然正确隔离。MVCC的出现避免了MySQL用最低效的串行化方式执行命令,让读操作不阻塞写,同时在一个事务中什么时候访问都是一致的历史数据。
InnoDB中的MVCC基于undo log和Read View实现。undo log中记录了行历史版本;而Read View则是用于控制事务见的可见性。
Read View
当一个事务执行 select(快照读)时,InnoDB 会创建一个 Read View。Read View 本质是一个"当前世界状态"的快照,包含四个关键字段:
| 字段 | 含义 |
|---|---|
m_ids |
创建 Read View 时,所有已开始但未提交的事务ID列表 |
min_trx_id |
m_ids 中最小的事务 ID |
max_trx_id |
创建 Read View 时分配的下一个事务 ID |
creator_trx_id |
创建这个 Read View 的事务 ID |
trx_id是InnoDB为每个事务 分配的唯一标识符 。它是一个6字节(48位) 的递增整数值,用于在MVCC(多版本并发控制)中标识哪个事务创建了哪个数据版本。当发生DML,InnoDB就将trx_id写入被修改行中。
roll_pointer是一个7字节 的指针,指向当前记录版本的前一个版本在Undo Log中的位置 。它构建了数据的版本链,就像链表中的next指针,连接着数据的各个历史版本。
结合trx_id,MVCC实现了事务间的可见性隔离:
-
trx_id < min_trx_id:记录在创建 read_view 之前已经提交,所以对当前事务可见; -
trx_id >= max_trx_id:记录是在创建 read_view 之后启动事务生成的,所以对当前事务 不可见; -
min_trx_id <= trx_id < max_trx_id:此时需要判断当前事务是否在m_ids列表中:在 m_ids 里,未提交不可见;不在 m_ids 里 ,已提交可见。
一次完整的MVCC读
如果有事务T1:
sql
SELECT * FROM account WHERE id = 1;
InnoDB会:读当前行(最新版本)
-
取出该行的
trx_id。 -
用 Read View 判断它是否可见。
-
如果不可见则通过
roll_pointer走 undo log。 -
一直回溯,直到找到一个"可见版本"。
因此RC与RR的本质区别就是生成的Read View策略不同。
RC每次读都会生成新的Read View。所以才会出现同一事务,两次 select 结果不一样(不可重复读);
RR则只在事务开始时生成一个Read View直到事务提交,因此整个事务中他只对一个数据快照读取,后续的其他事务提交的数据都不可见。
当然即使InnoDB默认使用RR(快照读),也可以强制要求读操作读当前数据:
| 语句 | 读的是什么 |
|---|---|
select ... |
快照读(走 MVCC) |
select ... lock in share mode |
当前读 |
select ... for update |
当前读 |
update / delete / insert |
当前读 |
锁与RC/RR的加锁对象
MySQL中涉及到以下锁:
| 分类 | 锁名 | 层级 | 锁定对象 | 触发方式 | 作用 |
|---|---|---|---|---|---|
| 全局锁 | Global Read Lock | Server | 整个 MySQL 实例 | flush tables with read lock |
让所有库只读,用于一致性全库备份 |
| Unlock | Server | 整个实例 | unlock tables |
释放全局锁 | |
| 表级锁 | Table Lock | Server | 整张表 | lock tables t read/write |
显式锁表 |
| MDL(元数据锁) | Server | 表结构 | 所有 SQL | 防止 select 与 alter 冲突 |
|
| 意向锁(IS/IX) | InnoDB | 表 | 行锁前自动加 | 表示表内存在行锁 | |
| Auto-Inc Lock | InnoDB | 表 | insert 自增列 |
保证自增 ID 不冲突 | |
| 行级锁 | Record Lock | InnoDB | 一条索引记录 | 等值查询 / update | 锁住某一行 |
| Gap Lock | InnoDB | 索引区间 | RR 范围查询 | 防止区间插入 | |
| Next-Key Lock | InnoDB | 记录 + 前间隙 | RR 普通查询 | 防止幻读 | |
| Insert Intention | InnoDB | gap | insert | 表示要插入该区间 | |
| 查询锁 | S 锁 | InnoDB | 行 | lock in share mode |
共享读 |
| X 锁 | InnoDB | 行 | for update / update |
排他写 | |
| MVCC | InnoDB | 行版本 | 普通 select | 快照读,不加锁 | |
| 无锁 | InnoDB | 行 | read uncommitted |
直接读最新数据 |
需要注意的是,表级锁中的**意向锁(IS/IX)和行级锁中的插入意向锁(tention)**是两种不同的锁。
前者作为表级锁的辅助,用于声明当前事务准备对这个表中的某行加行级锁,其他事务B来发现这个意向锁就明白某行可能会被加锁。如果没有这个意向锁,事务B就需要扫描所有行才能判断能否对当前表加锁。
后者则是Gap锁的一种,用于INSERT操作的并发优化。当插入位置被Gap锁锁定时,将INSERT阻塞并允许多个INSERT在同一个Gap锁并发等待。
查询语义与锁的对应关系:
| SQL | InnoDB 行为 |
|---|---|
select ... |
MVCC 快照读,不加行锁 |
select ... lock in share mode |
加 S 锁 |
select ... for update |
加 X 锁 |
update / delete |
自动加 X 锁 |
insert |
insert intention lock + X 锁 |
read uncommitted |
不用 MVCC,直接读最新版本 |
基于上述的锁放入RC和RR,分析为什么RC会发生不可重复度和幻读的问题。
RC的锁原则很简单:只锁已经存在的记录,不锁gap ;而RR的锁不仅锁住当前操作的数据,还对未来可能操作的位置加锁。
| 查询场景 | RC(Read Committed) | RR(Repeatable Read) |
|---|---|---|
主键 / 唯一索引命中 id = 15 |
对该记录加 X Record Lock | 对该记录加 X Record Lock |
主键 / 唯一索引未命中 id = 16 |
不加任何锁(可插 16) | 在 key 所在区间加 GAP Lock(阻止插 16) |
主键 / 唯一索引范围 id <= 20 |
只对命中行加 X Lock(15,18,20),不锁 (20,30) | 对命中行 + (20,30) 加 Next-Key Lock(锁住未来) |
二级唯一索引命中 no='S0003' |
锁二级索引记录 + 回表锁聚集记录 | 锁二级索引记录 + 回表锁聚集记录 |
二级唯一索引未命中 no='S0008' |
不加 gap,可插 S0008 | 对该索引区间加 GAP Lock |
二级非唯一索引命中 name='Tom' |
锁所有 Tom 对应的聚集记录 | 锁命中记录 + name 索引两侧 gap |
二级非唯一索引未命中 name='John' |
不加 gap | 在 name='John' 应落入的区间加 GAP Lock |
二级索引范围 age <= 23 |
只锁命中的行,不锁 age gap | 在 age 索引上加 Next-Key Lock,锁住 ≤23 的区间 |
修改索引值 update ... set name='John' where id=15 |
锁旧索引项 + 锁新索引项 | 同 RC + 新索引位置可能触发 gap / next-key 锁 |
| 无索引 | 全表扫描,对每一行加 X Lock | 对全表范围加 Next-Key(≈ 表级 gap) |
死锁以及死锁避免
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。InnoDB 内部有一张 Lock Wait Graph(锁等待图) :节点对应事务,边则对应"T1 等待 T2 持有的某个锁"。只要出现环则说明出现了死锁。InnoDB 的后台线程会周期性检测这张图,一旦发现环,就 选择一个"代价最小"的事务回滚。
经典死锁场景:
sql
T1:
UPDATE dl_account_t SET money = money - 100 WHERE id = 1;
UPDATE dl_account_t SET money = money + 100 WHERE id = 2;
T2:
UPDATE dl_account_t SET money = money - 100 WHERE id = 2;
UPDATE dl_account_t SET money = money - 100 WHERE id = 1;
T1对id=1加锁、T2对id=2加锁,而后又分别去等待id=2、id=1,形成了环。这就是典型的对相同表不同行以相反顺序加锁导致死锁。
sql
dl_t(a, b) -- a PK b UNIQUE b: 1, 4, 12, 20
T2: insert (26,10)
T1: insert (30,10)
T2: insert (40,9)
此时b=10不存在,InnoDB对b=10区间加锁,T2会锁住(4,12)这个区间(Next-Key Lock )。T1后续也要操作b=10同样对(4,12)加锁(Next-Key Lock ),但与T2冲突等待T2释放;而T2又操作一次这个区间,等待T1释放。此时就造成了:T1 等 T2 的 Next-Key 且T2 等 T1 的 Next-Key 发生唯一索引 + next-key + 插入意向锁形成环。
sql
dl_t(a, b) -- a PK b UNIQUE b: 1, 4, 12, 20
T1: insert (27,29)
T2: insert (28,29)
T3: insert (29,29)
T1: rollback
同样的情况,T1执行b=29获得写锁+插入意向锁。T2、T3发现唯一键冲突,对b=29加Next-Key锁。T1发生回滚,T2、T3拿到了Next-Key锁,此时T2插入数据,被T3的Next-Key锁阻挡。发生了:T2 等 T3的Next-Key锁,T3 等 T2的Next-Key锁。
因此在工程中常有以下标准避免死锁:
-
统一加锁顺序,所有事务按相同 key 顺序访问。
-
用唯一索引精确命中,避免扫描 gap:
sql
update t set ... where name='Tom' -- ❌
update t set ... where id=? -- ✅
- 尽量用 RC 隔离级别,一些高并发系统会采用RC一致性。
- 把一条大事务拆成小事务,尽量降低一次事务的持锁时间。
MySQL缓存策略
MySQL中的Buffer Pool只是存储引擎内部的缓存,只负责缓存索引页、数据页,用户无法控制缓存哪些数据,且MySQL的数据读写离不开磁盘IO,天生执行效率较内存就比较低。考虑到具体的业务场景,引入Redis内存数据库作为业务可控缓存,用户通过Redis精准控制缓存哪些Key、缓存哪些对象、多久过期,同时通过内存访问替代一部分MySQL的磁盘IO提升读写效率。
如何提升MySQL性能
- 读写分离
读写分离针对的是MySQL集群,集群中主节点(Master)只负责写、从节点(Slave)只负责读。读写分离之所以能成立,是因为背后有一套非常严谨的主从复制(Replication) 机制在支撑。所有从库之所以能够对外提供查询能力,本质上是在重放主库产生的写日志。
在单机MySQL中所有读写都由一个InnoDB统一处理,在底层所有的读写都由Buffer Pool、Redo Log等机制管理。当业务中有巨量读数据操作,哪怕不写数据事务也会被严重拖慢。因此MySQL引入了分布式的读写分离机制。
首先MySQL 的复制不是复制数据页,也不是复制表文件,而是复制 binlog(Binary Log)。binlog存储了主节点做了哪些写操作的逻辑日志。如果有:
sql
UPDATE user SET money = 100 WHERE id = 1;
binlog针对这行写操作会记录:对哪个表、哪一行、被修改为什么,而不是记录具体的物理页,使得binlog可以被任意支持主从复制的节点处理重放数据。

一次完整的主从复制:1)当一个事务在主节点提交后,InnoDB将数据写入Buffer Pool、redolog刷入磁盘,MySQL Server才将这次事务逻辑操作 写入binlog。2)从节点会异步开启一个请求线程,不断向主节点请求最新的数据,主节点将binlog发送给从节点,从节点将内容写入Relay log 中。3)从节点的SQL Thread 会读取Relay log将其中记录的SQL在本地重新执行一次。
因此通过分布式读写分离,能够解决大量读压垮主库的情况,将只读压力均衡到从节点,主节点只处理写事务。但是,从上述主从复制过程中我们可以看到,整个MySQL主从复制是异步执行 的。主节点处理完写操作直接将结果返回,binlog和从库重放都是和主节点的操作异步执行,在这个事件窗口内就会发送主从数据不一致 的情况,这就是所谓的复制延迟。
因此某些因业务场景对数据一致性要求很高,则主要去主节点获取数据:
| 场景 | 应该走 |
|---|---|
| 下单后查订单 | 主节点 |
| 支付后查余额 | 主节点 |
| 列表页、搜索页 | 从节点 |
| 历史数据查询 | 从节点 |
- MySQL Server连接池
连接池是在 MySQL服务器 中维护的一组 MySQL 连接,在服务启动时创建N个连接,以复用为目的处理完用户请求又重新回到池中。
MySQL基于TCP连接,每次客户端建立连接就需要:TCP三次握手、MySQL握手、用户名密码验证、权限加载。在高并发场景下建立连接+连接关闭的开销比SQL语句的处理还要大。引入连接池将建立建立连接的开销尽可能地降低,同时固定的连接池连接数量还能限制最大并发数量一定程度上保证了MySQL的背压能力。
需要注意的是,同一个事务的多条SQL必须在同一个连接中执行。
MySQL+Redis缓存方案详解
MySQL 是磁盘数据库,Redis 是内存数据库,两者承担的职责完全不同。MySQL的设计目标是强一致性,有完整的事务以及完备的崩毁恢复和数据安全保障,同时带来了磁盘访问的代价;而Redis本身就是一个内存数据库,它能为用户提供尽可能高的QPS。因此使用Redis承载高并发读流量提高用户访问速率,MySQL只处理写入作为数据权威,确保数据安全,是缓存方案设计的目的。
缓存一致性问题
引入了Redis作为内存缓存,在业务场景中会发生以下几种情况:
| MySQL | Redis | 状态 |
|---|---|---|
| 有 | 无 | 冷缓存,正常 |
| 无 | 有 | 脏缓存,危险 |
| 有 | 有 | 正常 |
| 有 | 有(旧) | 读到脏数据 |
| 无 | 无 | 读穿透 |
MySQL作为系统数据的正确凭证,上述情况中MySQL无、Redis有,MySQL有,Redis有但不一致。是需要着力避免的情况。
读写策略
缓存层出现的目的就是缓解高并发读给MySQL带来的压力,因此用户读取数据时统一先访问Redis 。命中则直接返回;如果 Redis 中不存在,再去查询 MySQL,并将结果回写到 Redis 中。以缓存优先的模式本质是让 Redis 成为 MySQL 的读扩展层。
写路径的设计则要谨慎得多。若以完全的数据一致性为目标 ,用户写数据时先删除掉Redis中的缓存,再往MySQL中写入数据。在读写访问量相当的场景下缓存带来的性能提升微乎其微;因此考虑牺牲一定的一致性 ,用户写数据时,先往Redis中写一个自动过期的数据,再往MySQL中写入数据,原因在于,MySQL 中的数据是结构化、有事务、有回滚、有 binlog 的,而 Redis 只是一个 Key-Value 存储,最后Redis等待来自MySQL的同步数据。这种情况下用户最多会在过期事件内读到脏数据,不管是同步成功还是失败最后缓存数据都会更新为正确的内容。
缓存同步
前文我们提到,MySQL的主从复制是围绕binlog实现的。因此最高效的方案就是将Redis伪装成从节点,从MySQL中拉取binlog做数据同步。通过解析 binlog,完全可以在不侵入 MySQL 事务的前提下,同步Redis 的状态。
本文介绍一种基于go-mysql-transfer的同步应用。MySQL 首先开启 ROW 格式的 binlog,并配置 server-id,使其可以作为复制源。go-mysql-transfer 作为一个 binlog 消费者,模拟一个 slave 连接到 MySQL,实时拉取数据变更事件。它并不关心 SQL,而是直接接收"哪一行、哪一个字段、从什么值变成什么值"的记录。
这些 binlog 事件并不会直接写入 Redis,而是经过一层规则系统和 Lua 脚本处理。规则定义了哪张表映射到 Redis 的哪个 Key 结构,而 Lua 决定如何从一行 MySQL 数据构建 Redis 的 Key 和 Value。这一步极其重要,因为它让 Redis 的数据结构不再受 MySQL 表结构限制,而可以根据业务需求自由设计。
首先设置MySQL支持主从同步,且设置binlog记录的格式为ROW。

启动MySQL后,插入数据:
sql
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` BIGINT,
`nick` VARCHAR (100),
`height` INT8,
`sex` VARCHAR (1),
`age` INT8,
PRIMARY KEY (`id`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;
同时启动redis,在lua中配置同步处理:
lua
local ops = require("redisOps") --加载Redis模块
local row = ops.rawRow()
local action = ops.rawAction()
if action == "insert" or action == "update" then
local id = row['id']
local key = "user:" .. id
local name = row["nick"]
local sex = row["sex"]
local height = row["height"]
local age = row["age"]
ops.HSET(key, "id", id)
ops.HSET(key, "nick", name)
ops.HSET(key, "sex", sex)
ops.HSET(key, "height", height)
ops.HSET(key, "age", age)
elseif action == "delete" then
local id = row['id']
local key = "user:" .. id
ops.DEL(key)
end
将mysql的同步数据组装成Redis命令发送。

首先使用命令show master STATUS;查看当前MySQL同步状态。使用go-mysql-transfer,进行全量同步:

使用go-mysql-transfer.exe -stock 全量同步后,执行.exe文件开始增量同步:

常见故障与处理方案
在 MySQL + Redis 架构中,引入缓存的核心目标是把 大量读请求挡在数据库之外,但缓存并不是"天然安全层",一旦使用不当,反而可能把数据库拖垮。实际系统中,最常见的三类缓存故障是:缓存穿透、缓存击穿和缓存雪崩。
- 缓存穿透
指的是某个数据在 Redis 中不存在,在 MySQL 中也不存在,但客户端却不停地请求这个 key。因为缓存里查不到,所有请求都会直接落到 MySQL 上,最终把数据库压垮。这种场景在恶意攻击(扫描 id)或者业务 bug 中非常常见。
常见解决方案:1)对这个不存在的数据也进行缓存,比如把 key -> nil 写入 Redis,并设置一个较短的过期时间,这样下一次再请求时就不会再去打 MySQL;代价是 Redis 里会多出很多"空值 key";2)使用 布隆过滤器,把 MySQL 中已经存在的 key 预先写入布隆过滤器,请求进来先过布隆过滤器,如果判定一定不存在,就直接返回,根本不会访问 Redis 和 MySQL 。
2. 缓存击穿
是指某个热点 key 在 Redis 中失效,但在 MySQL 中是存在的。这时如果有大量并发请求同时访问这个 key,就会同时穿透缓存,全部打到 MySQL,瞬间把数据库压满。
常见解决方案:1)分布式锁 :当缓存 miss 时,只有一个请求能拿到锁去查 MySQL 并回写缓存,其他请求睡眠等待,等缓存被填充后再直接读缓存;2)对真正的热点 key 设置永不过期,避免它在高峰期被淘汰掉。
- 缓存雪崩
指的是在某一时间段内,大量缓存 key 集中过期或者 Redis 整体不可用,导致请求全部涌向 MySQL,极易造成数据库崩溃,进而拖垮整个系统。一般是因为缓存集群宕机、或者大量 key 统一过期时间同时失效。
常见解决方案:1)在 Redis 层面使用哨兵、Cluster 等高可用方案,避免缓存整体不可用;2)在业务层面给 key 设置随机过期时间,错开失效时刻;3)如果是系统重启导致缓存丢失,则通过 Redis 持久化或在启动时预热热点数据来恢复缓存
缓存方案的结构性弊端与一致性问题
虽然 Redis 可以极大提升读取性能,但它并不是数据库,它缺少很多关系型数据库才有的能力,因此 缓存方案天生存在一些无法彻底解决的缺陷。
首先,Redis的事务只简单支持乐观锁,且不支持多语句回滚。在 MySQL 中要么全部成功,要么失败回滚;而 Redis 中如果执行多条命令过程中失败,是无法回滚的。这就意味着:不能把跨多条 SQL 的业务事务完全托管给缓存层,缓存只能作为"数据副本",不能作为"最终状态机"。
整个缓存方案会有五种主句不一致状态:1)MySQL 有,Redis 无。2)MySQL 无,Redis 有(脏数据)。3)两边都有但不一致。4)两边都有且一致。5)两边都无。
所有确保一致性的措施都是在让所有异常状态回到1或4,并以MySQL作为唯一数据标准。
总结
MySQL 在工程里的本质,是一台把 SQL 翻译成"对磁盘页的读写" 的机器:客户端发来一条语句,Server 层负责解析、生成语法树、做代价估算并选择执行计划,最后由执行器把"条件、索引、扫描方式"这些抽象信息交给存储引擎。真正和数据打交道的是 InnoDB:它沿着 B+ 树路径定位叶子页,优先去 Buffer Pool 命中页缓存,命中就是纯内存读写,不命中才触发磁盘 IO 把 16KB 的页读入内存。因此SQL 的快慢取决于引擎走了多少层树、读了多少页、是否发生回表、是否额外排序聚合。
为了让并发可恢复、可隔离,事务把一次业务修改变成一套完整的"版本与日志推进":每次写入先记录 undo,把旧版本串进版本链以支持回滚与 MVCC;对数据页的修改发生在 Buffer Pool,同时把物理变更写入 redo,并先进入 Log Buffer,再在合适时机刷盘以保证崩溃后可重放;事务提交时还需要把逻辑层的变更写入 binlog,以支持复制与恢复的一致性。MVCC 通过 Read View 决定"当前事务能看到哪个版本":RC 每次读生成新视图,读到的是最新已提交版本,因此会出现不可重复读;RR 在事务开始时固定 Read View,使整个事务读到同一快照,但为了阻止"未来插入"破坏范围一致性,又必须把可能插入的位置纳入锁域,于是 gap/next-key 成为 RR 的代价来源。
锁系统则把这种隔离策略落到索引层:命中主键/唯一索引时,本质是对那条索引记录加 record lock;当查询是范围、或者涉及二级索引范围扫描时,RR 往往会把"记录 + 间隙"一起锁住,形成 next-key,从而把幻读从根上堵住,但也更容易扩大锁范围。死锁是事务之间形成了等待环:最常见的是两个事务按相反顺序去锁同一组行;更复杂的则来自唯一索引冲突下的 next-key 与插入意向锁相互牵制。工程上规避死锁的核心思路:统一加锁顺序、尽量用唯一索引精确命中减少扫描区间、缩短事务持锁时间,必要时用 RC 换取更小锁域与更高吞吐。
当系统规模上来,性能扩展的主线会从"单机页与索引"延伸到"分布式读写拆分与缓存":读写分离的本质不是复制数据页,而是复制主库的 binlog,从库通过 relay log + SQL thread 重放变更来提供查询能力,因此它天然存在复制延迟,强一致查询要回主库。进一步把 Redis 引入,是为了把大量读请求从磁盘转移到内存,并且让业务能按 key 精准控制缓存对象与过期策略;但一旦引入缓存,系统的难点就从"快不快"转向"准不准",需要围绕 MySQL(权威源)与 Redis(副本)治理各种异常态:脏缓存、不一致、穿透/击穿/雪崩。读路径一般遵循 cache-aside:先查 Redis,miss 再查 MySQL 并回写;写路径之所以更推崇"删缓存"而不是"更缓存",是因为删缓存把风险转化为短期 miss,最终仍能回源读到权威数据,而更新缓存更容易在失败窗口里留下脏值。更稳的体系会让 Redis 跟随 binlog 做异步同步:把缓存更新从应用强耦合变成"日志驱动的最终一致",让所有异常最终回到"以 MySQL 为准"的稳定状态。
把这些串起来,本文介绍了一条清晰的工程链路:**SQL 在 Server 层被解析和规划 → InnoDB 以索引树和页为单位执行读写 → undo/redo/binlog 与 MVCC/锁把并发读写变成可恢复、可隔离、可复制 → 主从复制把读扩展到从库 → Redis 把热点读从磁盘搬到内存,同时用一致性策略与 binlog 同步把副本纳入治理。**理解这条链路,就能把"慢 SQL、幻读、死锁、缓存脏读、主从延迟"这些看似零散的问题,统一还原成同一个答案:系统到底让数据如何在 页、日志、锁、复制与缓存 之间流动。