带你轻松学习MySQL的索引、锁、事务以及MVCC
目录
一、索引
(一)什么是索引
(二)索引的类型
1.聚簇索引
2.二级索引
(三)索引失效的场景以及规避方法
1.违反最左前缀原则
2.索引列参与运算或函数
3.使用左模糊查询
4.隐式类型转换
5.有序谓词条件未全覆盖
二、锁
(一)全局锁
(二)表级锁
1.表锁(显式锁)
2.元数据锁(隐式锁)
3.意向锁
(三)行级锁
1.记录锁(所有级别)
2.间隙锁(RR及以上级别生效)
3.临键锁(RR级别默认锁)
(四)锁的选型
三、事务
(一)Undo Log(回滚日志)
(二)Redo Log(重做日志)
(三)Bin Log(二进制日志)
四、MVCC
(一)实现MVCC的核心组件
1.隐藏字段
2.Undo Log版本链
3.读视图(Read View)
(二)不同隔离级别的快照读差异
1. Read Committed (RC)
2. Repeatable Read (RR)
(三)具体案例分析
1.RC级别下
(1)第一阶段
(2)第二阶段
(3)第三阶段
(5)第五阶段
(6)第六阶段
2.RR级别下
(1)第三阶段
(2)第六阶段
一、索引
(一)什么是索引
索引是一种数据结构,能快速定位数据,类似于书籍目录,加速检索效率。
注:不同引擎的索引结构不同,本篇文章将以最常用的InnoDB引擎为例来说明。
(二)索引的类型
InnoDB中索引统一采用B+树结构来构建的,但根据叶子节点存储数据的不同,又分为了聚簇索引和二级索引。
什么是B+树?
B+树是一种多路平衡查找树结构。
其叶子节点存储实际数据,并与相邻叶子节点间构建了双向链表,加快了范围查询的效率。
非叶子节点仅存储索引键,仅作为查找路径使用。
节点插入规则:
如图所示,假设当前节点设置最大度数为3,即每个节点最多存储2个索引键数据,若到达3个即向上分裂。
若目前要插入索引键为9的数据.
1. 9即不小于3,也不位于3和5之间,所以走最右分支(大于5);
2. 同理,在第二层里9依旧大于7,但此时下一层为叶子节点,所以索引键就得存储在此节点中,因为大于7,所以就存储在7的旁边,然后实际数据则存储到对应的叶子节点即可。
3. 可此时该节点存储了3个索引键,到达了最大度数,所以得向上分裂:以中间元素7为分隔,6和9分别分裂为两个节点,7节点向上存储到第一层的最右侧。
4. 可此时根节点也到达了最大度数,所以同理,根节点也需要向上分裂。
最后形成的结构如下:
此处只是简单提了一下B+树的特点,如若想更加深入地学习,可以参考以下文章:
经典树结构——B+树的原理及实现-CSDN博客
https://blog.csdn.net/c630843901/article/details/121423196
为什么InnoDB引擎选择使用B+树索引结构?
- 层级少,搜索效率高:
假设我们一行数据为1K,B+树的一个节点最多存放16K的数据,也就是叶子节点最多存放16行;而非叶子节点仅存放索引键和指针,我们假设索引键为bigint,大小为8byte,而指针假设为6byte,加起来就是14byte,那非叶子节点最多存储(16k*1024)/14=1170个索引键。假设当前树为2层,那可存储的最大数据行数为1170*16=18720行;若3层则是1170*1170*16=21902400行,到达了2000w量级。
- 相比B树,实际数据都存储到叶子节点,使得非叶子节点可存储的索引键数量大大增加。
- 由于本身插入规则,B+树存放数据是有序的,且又因为叶子节点之间构建了双向链表,使得范围查询也更加快捷,即使索引所在的行数据在原表中是乱序的,也可以通过索引快速范围查询。
1.聚簇索引
聚簇索引是唯一的,每张表只存在一个,因此聚簇索引通常是和表主键绑定。
聚簇索引的叶子节点存放的是完整的行数据。
2.二级索引
二级索引不唯一,可以存在多个,与聚簇索引不同的是,二级索引的叶子节点仅存放聚簇索引索引键,即主键值。
因此,如果在查找二级索引以外的字段的时候,就会出现回表查询。
什么是回表查询?
假如说当前表的主键是id,二级索引绑定的是userId,我想根据用户id查找用户性别,那就得写一下sql:
select gender from user where userId = 5;
通过二级索引找到userId 为5的叶子节点,拿到对应的id值,但此时我们只查询到了该条数据对应的主键值,并没有拿到想要查询的gender信息,所以此时我们得拿着id去聚簇索引中查找,因为行数据是存储到聚簇索引中的。
但是大家是否思考过一个问题,如果这条sql语句需要频繁使用,那我们不得每次查询都得回表?又或者当出现如下sql语句的时候:
select phone from user where userId = 5 and gender = 1;
此时就算我们同时给userId和gender设置二级索引,也只有一个二级索引是有效的,因为不管你设置多少个二级索引,最终拿到的都只是id而已,都得回表进行查询,可见效率是很低的。
那为何我们不能在二级索引里多存储几个字段呢?只要把热点字段都存储到二级索引的叶子节点中去,那我们不就不用进行回表查询了?
如果你有这样的想法,恭喜你发明了联合索引!
什么是联合索引?
联合索引就是同时将多个字段都放入一个叶子节点,构成一个二级索引。
打个比方,如果我将userId和gender设置了联合索引,那么叶子节点就会存储userId、gender和Id,然后是依照userId进行排序的,如果恰巧不巧出现了userId一致的情况,此时就再按照gender进行排序即可,再不济也会根据id进行排序,因为id是唯一主键,不会出现重复情况。
由此,在执行select gender from user where userId = 5;这条语句的时候,就只需要先根据userId查找对应的叶子节点,然后获取对应的gender即可。
同理,在执行select phone from user where userId = 5 and gender = 1;时,我们也只需要给三个字段设置一个联合索引即可。
(三)索引失效的场景以及规避方法
1.违反最左前缀原则
当我们设置联合索引时,字段名的顺序其实是有讲究的,正如我们刚才所说,最左边的字段是排序优先级最大的,所以如果要使用联合索引,sql语句中必须要存在联合索引优先级最大的字段,即设置时最左的字段。
打个比方,如果我们给userId和gender设置了联合索引,在sql语句中想使用,但只用了gender字段查找,并没有用userId,那这个时候你让系统如何使用gender字段到联合索引里面查找?
所以如果我们要使用联合索引时,必须要让最左边的字段出现,在where中只需要出现即可,顺序并不讲究,因为MySQL底层会优化where语句,最后的顺序和你写的顺序是不一致的,因此where可以看作是无序的谓词,因此最左边的字段出现在哪个位置都可以;但是如果是有序谓词,比如说order by或者or时,那最左边的字段必须要出现在最左边才行。
2.索引列参与运算或函数
索引存储的只是原始数据,并不会优化计算后的结果。
所以如果你使用where YEAR(create_time)=2025时,即使给create_time设置了二级索引,但实际上并不会使用,只有当索引数据为原始数据时,索引才会有效,即 where create_time BETWEEN \'2025-01-01\' AND \'2025-12-31\'时有效。
3.使用左模糊查询
这个就和违反最左前缀原则差不多,当你使用右模糊查询,如where username = \'张%\',那系统就会根据“张”进行前缀查找,然后返回所有对应的值,但如果你使用where username = \'%三\',这个时候系统就不知道该如何进行查询,因此索引失效。
4.隐式类型转换
如果sql语句中的字段对应的数据类型与实际索引对应的字段类型不一致时,此时索引也会失效。
例如userId在表中类型为bigint,实际输入却为字符串:where userId = \'10000000\',此时存在字符串转换为bigint的隐式类型转换,因此索引失效,改为where userId = 10000000即可。
5.有序谓词条件未全覆盖
当使用想or这样的有序谓词,如果其中一个条件没有索引,那么即使其他条件拥有索引也会失效,若想要继续使用or的话可以加上索引,否则的话可以使用union来替换or谓词,保证其中存在的索引生效。
当然索引也不要设置太多,因为理论上索引是一种空间换时间的思想,设置太多会占用大量空间,而且维护成本也大大增加。
二、锁
(一)全局锁
全局锁是粒度最大的锁,是针对整个数据库而言的,一旦加了全局锁,就会阻塞所有DDL以及DML(不包括查询)操作,所有表只可读不可写。
因此,全局锁通常在数据备份或者迁移场景下有应用空间。
(二)表级锁
从名字就能看出,表级锁的粒度就是表级别,只会锁住某一张表。
1.表锁(显式锁)
这个锁需要手动声明操作,分为两种。
-
- 共享读锁:阻塞所有的写操作(包括本线程)
- 排他写锁:阻塞其他线程的读写操作。
2.元数据锁(隐式锁)
元数据锁不需要手动声明,进行DML和DDL操作时系统会自动加上该锁保证数据安全。
-
- 进行DML操作时:自动加共享元数据锁,允许其他线程DML和查询。
- 进行DDL操作时:自动加排他元数据锁,阻塞所有其他线程的DML和查询。
3.意向锁
这个锁的作用是为了快速判断要加的表锁是否与当前行锁冲突,当加上行锁时,系统就会自动加上对应的意向锁,这样的话当要加表锁的时候就不需要遍历每行数据来查找行锁,可以直接通过意向锁来判断行锁是否存在或者对应的类型。
-
- 当意向锁为共享读锁(IS)时,只可以加表级共享读锁(S)。
- 当意向锁为排他写锁(IX)时,不可以加任何表级锁。
由此可以看出,意向锁实际上只是一个抽象工具,本身并没有锁住任何数据。
(三)行级锁
仅InnoDB引擎支持行级锁。
为什么当索引失效时,行级锁会自动升级为表级锁?
当使用select gender from user where userId = 5;如果userId是存在索引的,那么行级锁会通过索引快速定位该行,然后只锁该行的数据;但如果不存在索引,那么系统就只能从第一条数据开始遍历,直到找到该行数据,但是为了防止在查找过程中数据出现更改的情况,此时系统会给整个表上锁防止修改,确保数据一致。
行级锁依赖于B+树结构,是系统自动添加的,但也可以手动声明,然后根据不同的隔离级别,行级锁的类型也各不相同。
1.记录锁(所有级别)
记录锁就是锁住该行数据,也就是对应b+树的叶子节点中的记录数据,不允许其他事务进行修改或者加锁。
由此记录锁可以防止脏读问题,但无法防止幻读问题。
ps:当锁住的记录不存在时,会优化为间隙锁。
2.间隙锁(RR及以上级别生效)
间隙锁就是锁住当前记录与上一个记录和下一个记录之间的数据,防止插入新记录,但是不锁当前记录的数据。
这样间隙锁就可以防止幻读问题,但无法防止脏读问题。
由此可见,只有同时使用记录所和间隙锁,才能解决脏读和幻读问题,因此临键锁应运而生。
ps:间隙锁是可重入锁,不同的间隙锁可以锁住相同间隙。
3.临键锁(RR级别默认锁)
临键锁就是记录锁和间隙锁的结合,既锁住的间隙,又锁住了当前行数据,完美解决了幻读和脏读问题。
ps:当锁住的记录不存在时,会优化为间隙锁;
(四)锁的选型
特性
全局锁
表级锁
行级锁
粒度
整个实例
整张表
单行记录
并发性能
极差
中等
优秀
死锁风险
无
低
高
适用引擎
所有
MyISAM/InnoDB
InnoDB
典型场景
全库备份
批量导入数据
高并发事务
三、事务
事务是一组不可分割的数据库操作集合,要么全部成功提交,要么全部失败回滚。
什么是ACID?
1. 原子性(Atomicity)
原子性保证了事务内的所有操作要么全部成功,要么全部失败。换句话说,事务是不可分割的单元,它不能被分割成多个部分。在执行事务时,若出现错误,所有的操作都会被撤销,数据库会恢复到事务开始之前的状态。
2. 一致性(Consistency)
一致性保证了事务开始前和结束后的数据库都处于一致的状态。每个事务必须使数据库从一个一致性状态变换到另一个一致性状态。事务会遵循数据库的所有约束条件(如外键、唯一性、检查约束等),确保数据的完整性和准确性。
3. 隔离性(Isolation)
隔离性确保了事务的执行不受其他事务的干扰。每个事务在执行过程中,其他事务不能看到它的中间状态。事务之间的隔离性可以通过不同的隔离级别来控制,不同级别下事务的可见性和执行顺序不同。
4. 持久性(Durability)
持久性保证了事务一旦提交,其所做的修改会永久保存在数据库中。即使发生系统崩溃、断电等故障,已经提交的事务的结果也不会丢失。这通过日志和数据写入磁盘来实现
其中,原子性是由undo log保障,隔离性是由MVCC和锁机制保障,持久性是由redo log保障,而一致性是由原子性、隔离性和持久性共同保障。
(一)Undo Log(回滚日志)
当执行增删改操作的时候,会存储数据的旧版本。
如此,就能够在事务回滚时快速还原数据。
而且undo log在事务提交后并不会删除,而是形成一条undo log版本链供MVCC使用(insert操作会直接删除,因为没有历史记录,仅保留update操作)。
(二)Redo Log(重做日志)
每当执行增删改操作时,会先将数据写入redo log缓冲区里面,然后根据策略定期刷盘,确保即使MySQL宕机后数据也能快速恢复。
Redo Log是循环写的,当对应的脏页刷盘后,原本的重做日志就没用了,该空间就可重用。
什么是脏页?
缓冲区中会分很多页,当执行增删改操作时会先查看缓冲区中有没有对应的页存储了要修改的表,若没有则读取磁盘取出对应的表放入缓冲区中,这样的话如果再次对该表进行操作时就不用读写磁盘,大大提高了读写效率。
缓冲区里的数据会定期刷新到磁盘中,当缓冲区中的数据页与磁盘的不一致时,该页就称作为脏页,因为修改后还没有到刷新时间,出现了短期不一致的情况。
redo log为此存在,当脏页未刷盘时系统宕机就会导致新修改未记录到磁盘上,这个时候就可以通过Redo Log进行重做。
但是Redo Log只能恢复脏页数据,对于数据损坏的物理页数据是无法恢复的。
什么是数据损坏的物理页?
比如说一行数据总共16kb,传输过程中因为某些原因,只传输了4kb,那仅靠这4kb数据是无法恢复16kb的数据的,这种情况就称作数据损坏。
而脏页是数据传输完毕但没有写入磁盘的物理页。
那如何避免数据损坏?
MySQL采用了两次写机制。
一次写操作分为以下流程:
复制到内存缓冲区
- 脏页从Buffer Pool复制到内存中的Double Write Buffer。
顺序写入共享表空间
- Double Write Buffer分2次顺序写入磁盘共享表空间(ibdata)的连续128页区域。
- 此过程为顺序写,性能远高于随机写。
写入实际数据文件
- 再将Double Write Buffer中的脏页离散写入对应的表空间文件。
也就是说一次写操作会先将数据写入磁盘共享表空间,再写入磁盘物理表空间。
这样就可以确保即使写入物理表宕机出现数据损坏,也可以即使通过共享表进行数据重铸。
实际上也就是牺牲部分性能换取了数据完整。
(三)Bin Log(二进制日志)
这个日志需要手动配置开启,记录的是DDL和DML的二进制数据文件,是MySQL的server层日志,所有存储引擎通用,而undo log和redo log是InnoDB引擎独有的。
Bin Log是在一个事务执行的过程中,采用追加写的形式将数据写入对应的内存缓冲区Binlog Cache中,当事务结束后再写入Bin Log文件中,最后根据策略持久化到磁盘。
Bin Log一共有三种格式:
-
ROW 格式(推荐)
- 记录方式:保存每行数据修改前后的完整值。
- 优点:数据一致性高,避免主从复制歧义。
- 缺点:日志量大。
-
STATEMENT 格式
- 记录方式:存储执行的原生 SQL 语句。
- 优点:日志量小。
- 缺点:依赖上下文。
-
MIXED 格式
- 混合策略:默认记录 STATEMENT,对不确定语句自动转 ROW 格式(不确定语句指的是需要依赖上下文的SQL语句)。
- 平衡点:日志量与安全性的折中方案。
因此Bin Log主要用于主从复制或者数据备份。
既然Bin Log也可以备份数据,那为什么InnoDB还要单独设置个Redo Log?
Bin Log是无法记录物理页数据的,也就是说它无法记录未刷盘的脏页数据,因此如果在存在脏页的情况下宕机的话,还需要依赖Redo Log进行恢复。
如何保障Bin Log和Redo Log的数据一致性?
MySQL采用了事务两阶段提交的方法。
MySQL会分配内部XA事务进行协调,先将该事务ID写入Redo Log,然后将事务状态设置为prepare再刷盘,再将ID写入Bin Log同样进行刷盘,最后将事务状态设置为commit表示事务提交。
如果在刷盘期间发生宕机,重启后MySQL就会先扫描磁盘中的Redo Log看看是否有存在事务ID,如果不存在就代表没有进行刷盘行为,就不用下一步操作;
但如果存在则需要进一步扫描Bin Log日志中是否存在相同的ID,如果不存在则代表MySQL的宕机时刻是图中的A,那么将Redo Log进行回滚即可;如果存在则代表是时刻B宕机了,那此时虽然事务状态未提交,但两者均已完成刷盘,所以直接提交事务即可。
四、MVCC
MVCC主要是通过多版本实现读写并发控制,解决了不可重复读的问题。
在了解MVCC的工作机制之前,我们需要先了解两个概念:
类型
说明
快照读
读取数据的历史快照,无需加锁(普通SELECT)
当前读
读取数据最新版本,需加锁(保证写操作安全)
(一)实现MVCC的核心组件
1.隐藏字段
其实每条数据行中,除了我们自己手动添加定义的字段以外,系统还会自动加上三个隐藏字段:
-
DB_TRX_ID(6字节):最近修改事务ID,记录创建/最后一次修改该行的事务ID。
-
DB_ROLL_PTR(7字节):回滚指针,指向该行在undo log中的历史版本。
-
DB_ROW_ID(6字节):隐含主键,无显式主键时自动生成聚簇索引。
前两个字段时形成undo log版本链的重要组件。
2.Undo Log版本链
每当有新的update操作,就会生成对应的undo Log,由于update操作会留下历史版本,于是新版本中的隐藏字段DB_ROLL_PTR就会自动指向上一个旧版本,由此循环边会形成当前目标行的一整条历史版本链。
3.读视图(Read View)
每当事务进行快照读操作的时候,都会生成一个读视图,其包含四个关键属性:
-
m_ids:生成ReadView时活跃事务ID列表(未提交事务)。
-
up_limit_id(min_trx_id):m_ids中的最小事务ID。
-
low_limit_id(max_trx_id):下一个将分配的事务ID(当前最大事务ID+1)。
-
creator_trx_id:创建该ReadView的事务ID。
这几个属性是用来判断Undo Log版本链中哪个版本可以供当前事务快照读而不会出现并发问题。
具体判断规则如下(trx_id是当前事务id):
也就是说,只有当事务已经提交的版本或者由本事务修改的版本才是可见的,在当前事务之后被创造的事务所修改的版本以及当前活跃事务所修改的版本是不可见的。
如果当前版本不可见,则会继续沿着Undo Log版本链向下查找直到找到可见版本。
(二)不同隔离级别的快照读差异
1. Read Committed (RC)
-
ReadView生成:每次查询都生成新的读视图。
-
效果:
-
能读取其他事务已提交的最新数据。
-
可能发生不可重复读。
-
2. Repeatable Read (RR)
-
ReadView生成:仅在第一次查找时生成,后续复用。
-
效果:
-
解决不可重复读问题。
-
(三)具体案例分析
给出场景:
CREATE TABLE account ( id INT PRIMARY KEY, name VARCHAR(20), balance INT) ENGINE=InnoDB;INSERT INTO account VALUES (1, \'Alice\', 1000), (2, \'Bob\', 500);
即:
事务操作时序如下:
1.RC级别下
(1)第一阶段
第一次全表查询,此时生成第一个版本:
Alice:1000
Bob:500
null
null
null
null
1
(2)第二阶段
B事务开启,进行了一次update操作,生成Alice行的第二个版本:
Alice:1000
Bob:500
null
null
null
null
1
Alice:800
B
1
2
(3)第三阶段
A事务开启,进行了一次全表查询,生成了第一个快照:
min_trx_id
max_trx_id
creator_trx_id
B
Alice:1000
Bob:500
快照的数据该如何确定呢?
Bob版本因为只有最老的且修改事务ID为null所以可以直接使用。
Alice版本的话,先查看最新的版本,首先A不是该版本的最后修改事务ID,且B处于活跃ID列表中,因此该版本不可见。
下一个版本,该版本没有事务ID,因此可以使用。
(4)第四阶段
首先B事务提交。
(5)第五阶段
C事务开启,然后插入了一条数据,此时生成Carol的第一个版本:
Alice:1000
Bob:500
Carol:700
null
null
C
null
null
null
1
Alice:800
B
1
2
最后C事务提交。
(6)第六阶段
A事务再次全表扫描,此时生成了第二个快照:
min_trx_id
max_trx_id
creator_trx_id
null
Alice:800
Bob:500
Carol:700
首先Bob版本依旧直接读取版本1即可;
然后Alice版本读取最新的,虽然A不是版本最后修改事务ID,但是B已经不在活跃ID列表中了,因此可以直接读取;
最后Carol版本也是直接读取版本1即可。
我们可以发现这里出现了不可重复读和幻读问题
2.RR级别下
(1)第三阶段
A事务开启,进行了一次全表查询,生成了第一个快照:
min_trx_id
max_trx_id
creator_trx_id
B
Alice:1000
Bob:500
原理与RC级别下的相同。
(2)第六阶段
A事务再次全表扫描,但此时依旧沿用第一个快照,所以返回结果也一致:
min_trx_id
max_trx_id
creator_trx_id
B
Alice:1000
Bob:500
我们可以发现这里解决了不可重复读和幻读问题
ps:此时这里也只解决了快照读的幻读问题
那RR级别可以解决当前读的幻读问题吗?
答案是肯定的。
RR级别的默认锁是临键锁,当第一次当前读后,会给读到的记录以及前后的间隙加上临键锁,直到事务提交,因此在该事务的后续当前读所读到的数据都是一致的。
而RC既以下级别是没有间隙锁和临键锁的,因此它们无法防止幻读问题。
那RR级别是否完美解决了幻读问题?
答案是否定的。
举个例子,当一个事务执行快照读时会创建一个快照,此时就算插入数据,后续的快照读的结果也都一样,但是如果使用了当前读(update语句或select指定当前读)的话,就会读取当前最新数据并更新快照,这样即使后续使用快照读,读取到的也是第二次更新后的快照,和第一次的快照不一样,那自然读取的结果行数也就可能不一样,导致了幻读问题的发生。
那这样不也可能导致不可重复读问题的发生吗?
确实,如果在第一次快照读后更新行数据,再用当前读更新数据后使用的快照读读到的数据就和第一次不一样了,这样一来好像真的发生了不可重复读问题。
但这实际上是幻读的变体,因为这个问题的核心原因是当前读强制更新了快照,这和幻读的“ 结果集新增行” 的本质一致。
所以不可重复读是已经被MVCC给完美解决了,RR级别是不会导致不可重复读问题的。
那该如何完美解决幻读问题?
对于普通幻读,使用RR级别,然后尽量在事务一开始后就调用 “SELECT ... FOR UPDATE” 语句执行当前读上间隙锁防止别的事务更新行数量。
对于上面提到的幻读变体,我们需要尽量避免将快照读和当前读混合使用。应该尽量先执行完所有的当前读再执行所有的快照读,以免破坏快照;或者使用最高级的串行化(SERIALIZABLE),但这样性能太低,所以还是前者的解决方法更为推荐。
串行化隔离级别是如何实现的?
串行化(SERIALIZABLE)隔离级别通过强制加锁机制实现:所有读操作自动加共享锁阻塞写操作,所有写操作加排他锁阻塞读写,范围查询使用间隙锁和临键锁,并禁用MVCC快照读,强制事务按次序一个个进行。
只要我们用好索引、锁和MVCC,就可以完美解决脏读、不可重复读以及幻读问题。
不过也得根据实际业务进行取舍,因为并发问题解决的前提是牺牲一部分性能。
~码文不易,留个赞再走吧~