1. 隔离性实现原理
1.1 事务的隔离性
隔离性:MySQL是一个多线程的服务器程序,可以同时跟多个客户端程序建立连接、处理请求,此时不同客户端执行的DML语句被包含在不同的事务当中,就有可能对同一张表的同一个数据行进行修改,此时为了避免多个事务并发执行相互影响,因此需要隔离不同事务,这种性质就是隔离性。
1.2 事务的隔离级别
隔离级别:我们已经知道事务之间存在隔离性,但是如何实现事务之间的隔离,隔离到什么程度?如何保证数据安全的同时兼顾性能,这都是我们需要考虑的问题
在多线程环境中,也存在的线程安全问题,我们通常是使用"锁机制"来解决;同理,多个事务对同一张表的同一个数据行进行修改操作时,如果要实现事务之间的相互隔离也可以通过锁来完成,在MySQL中常见的锁类型有:独占锁、共享锁、行锁、间隙锁、next-key锁等,不同的锁联合多版本并发控制可以实现 事务间不同程度的隔离,称为事务的 隔离级别 ,不同的隔离级别对安全性和性能之间做了取舍,有的隔离级别注重并发性能,有的隔离级别注重安全,在Innodb存储引擎中,提供如下四种隔离级别:
- 读未提交(Read Uncommited)
- 读已提交(Read Committed)
- 可重复读(Repeatable Read)(默认)
- 串行化(Serializable)
从上到下在性能方面递减,安全性方面递增
1.3 锁
锁:实现事务的隔离级别中我们使用到了锁机制,所谓锁就是在事务A修改某些数据的时候,对这些数据加一把锁,防止其他事务同时对这些数据进行修改操作;当事务A完成修改操作后,释放当前所持有的锁,以便其他事务进行加锁操作,这里重点介绍InnoDB存储引擎中涉及到的锁:
1.3.1 锁信息
相关锁的一些信息,比如锁的请求状态、持有状态、阻塞状态都被保存在performance_schema
库中的data_locks
表中,可以通过以下方式进行查看:
其中我们需要特别注意两个字段:
- LOCK_TYPE:锁类型,依赖于存储引擎,在InnoDB存储引擎中按照锁的粒度分为表级锁TABLE和行级锁RECORD
- 行级锁也叫行锁,是对表中的某些具体数据行进行加锁
- 表级锁也叫表锁,是对整个数据表进行加锁
- LOCK_MODE:锁模式,用来描述如何申请锁,分为共享锁(S)、独占锁(X)、意向共享锁(IS)、意向独占锁(IX)、记录锁、间隙锁、Next-Key锁、AUTO-INC锁
1.3.2 共享锁和独占锁-Shared and Exclusive Locks
InnoDB实现了标准的行级锁,分为两种分别是共享锁(S)以及独占锁(X),独占锁也被称为排它锁。
- 共享锁(S锁):允许持有该锁的事务读取表中的一行记录,同时允许其他事务在锁定行上加另一个共享锁以及读取锁定行,但是不能对其进行修改操作
- 独占锁(X锁):允许持有该锁的事务对数据行进行更新或者删除操作,同时不允许其他事务对锁定行进行读取或修改操作,也不允许加任何锁
# 加共享锁
select * from user where name='张三' for share;
# 加独占锁
select * from user where name='张三' for update;
# 使用以下SQL语句查看锁监视器
show engine innodb status\G;
- 如果事务T1持有行R上的S锁,那么事务T2请求R上的锁时有如下处理:
- 如果T2请求S锁会立即授予,此时T1、T2都有对行R的共享锁
- 如果T2请求X锁不会被立即授予,而是阻塞到T1释放持有的锁
- 如果事务T1持有行R的X锁,那么事务T2无论请求何种类型的锁都不会被立即授予,而是阻塞等待事务T1释放持有的锁
💡 提示:
读锁是共享锁的一种实现方式,写锁是独占锁的一种实现方式!
1.3.3 意向锁-Intension Locks
- InnoDB存储引擎支持多粒度锁共存,比如行级锁和表级锁共存
- InnoDB使用意向锁实现多粒度级别的锁,意向锁是表级别的锁,它并不是真正意义上的加锁,只是在
data_locks
中记录事务以后要对表中的哪一行加哪种类型的锁,意向锁分为两种:- 意向共享锁(IS):表示事务打算对表中的单个行设置共享锁
- 意向排他锁(IX):表示事务打算对表中的单个行设置排他锁
- 在获取意向锁时有如下协议:
- 在事务获得表中某一行的共享锁(S锁)之前,它必须首先获取该表上的IS锁或者更强粒度的锁
- 在事务获得表中某一行的排它锁(X锁)之前,它必须首先获取该表上的IX锁
- 意向锁可以提高加锁的性能,在真正加锁之前不需要遍历表中的数据行是否加锁,只需要查看表中的意向锁即可
- 在请求锁的过程中,如果说申请的锁与现有锁兼容则立即授予;如果冲突则不会授予,而是阻塞等待直到冲突的锁被释放,意向锁与行级锁的兼容性如下:
1.3.4 索引记录锁-Record Locks
索引记录锁:又被称为精准行锁,顾名思义就是指索引记录上的锁,如下SQL锁住的是一行
select * from user where id=1 for update; # id为索引列
- 索引记录锁总是锁定索引行,在表没有定义索引的情况下,InnoDB会创建一个隐藏的聚集索引,并使用该索引进行记录锁定,当使用索引进行查找时,锁定的只是满足条件的行
1.3.5 间隙锁-Gap Locks
间隙锁:间隙锁锁定的是索引记录之间的间隙,或者第一个索引记录之前,再或者是最后一个索引记录之后的间隙
- 例如有如下SQL,锁定的是ID(10, 20)之间的间隙,注意不包括10和20的行,目的是防止其他事务将ID为15的列插入到列user表中,因为指定范围值之间的间隙被锁定了
select * from user where id between 10 and 20 for update; # id为索引列
- 间隙锁可以跨越单个或者多个索引记录
- 对于使用唯一索引查询到的唯一行,不使用间隙锁,例如如下语句,id列有唯一的索引值,只会对id为100的行使用索引记录锁
# 只使用Record Locks
select * from user where id=100; # id为索引列
- 如果id没有被索引或者是一个非唯一的索引列,以上SQL语句会锁定记录之前的间隙
- 不同事务之间的间隙锁可以共存,一个事务的间隙锁不会阻止另一个事务在相同的间隙上使用间隙锁,共享间隙锁和独占间隙所没有区别
- 当事务隔离级别设置为
READ COMMITED
时间隙锁会被禁止,扫描索引不会使用间隙锁定
1.3.6 临键锁-Next-Key Locks
临键锁:是索引记录锁和索引记录之前的间隙上间隙锁的组合
- 假设索引记录包含值10、11、13、20,则这些索引可能的Next-Key锁覆盖以下区间:(-oo,10]、(10,11]、(11,13]、(13,20]、(20, +oo)
- 默认情况下,默认级别
REPEATABLE READ
事务隔离级别开启next-key锁进行索引扫描,可以防止部分幻象行,从而解决部分幻读问题
1.3.7 自增锁-AUTO-INC Locks
自增锁:是一个表级锁,服务于配置了AUTO_INCREMENT
自增列的表,在插入数据时会在表上加自增锁并生成自增值,同时阻塞其他事务操作,以保证值的唯一性,注意的是如果一个事务执行新增操作生成自增值,但是事务回滚了,申请到的自增值不会回退,这也意味表中可能会出现自增列不连续的情况
1.3.8 死锁
当每个事务持有其他事务所需要的锁时,并且不会主动释放自己持有的锁,并且互相等待对方先释放锁,此时造成事务僵持无法继续执行下去的情况,就被称为死锁
1.3.8.1 示例
- 首先打开客户端A,并执行如下操作
# 开启全局变量innodb_print_all_deadlocks方便查看死锁信息
mysql> set global innodb_print_all_deadlocks=ON;
Query OK, 0 rows affected (0.00 sec)
# 创建两张表
mysql> CREATE TABLE animals (name VARCHAR(10) PRIMARY KEY, value INT) ENGINE =InnoDB;
Query OK, 0 rows affected (0.03 sec)
mysql> CREATE TABLE birds (name VARCHAR(10) PRIMARY KEY, value INT) ENGINE =InnoDB;
Query OK, 0 rows affected (0.03 sec)
# 插入数据
mysql> INSERT INTO animals (name,value) VALUES ("dog",10);
Query OK, 1 row affected (0.01 sec)
mysql> INSERT INTO birds (name,value) VALUES ("magpie",20);
Query OK, 1 row affected (0.00 sec)
# 开启事务
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
# 给查询行加上共享锁
mysql> SELECT value FROM animals WHERE name='dog' FOR SHARE;
+-------+
| value |
+-------+
| 10 |
+-------+
1 row in set (0.00 sec)
- 打开客户端B,并执行以下操作
# 客户端B开启事务
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
# 给查询行加上共享锁
mysql> SELECT value FROM birds WHERE name='magpie' FOR SHARE;
+-------+
| value |
+-------+
| 20 |
+-------+
1 row in set (0.00 sec)
- 尝试在客户端B中更新animals表中的行
# 客户端B修改animals表中的行(由于客户端A事务已经加了S锁,因此客户端B阻塞等待)
mysql> update animals set value=30 where name='dog';
- 此时客户端A更新birds表中的数据行就会造成死锁
# 客户端A修改birds表中的行(由于客户端B事务已经加了S锁,因此客户端A阻塞等待形成死锁)
mysql> update birds set value=40 where name='magpie';
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
- 当死锁发生时,事务A自动回滚(释放锁),此时事务B执行成功!
mysql> update animals set value=30 where name='dog';
Query OK, 1 row affected (26.54 sec)
Rows matched: 1 Changed: 1 Warnings: 0
- 可以通过以下方式查看服务器发生死锁的次数:
mysql> SELECT `count` FROM INFORMATION_SCHEMA.INNODB_METRICS
-> WHERE NAME="lock_deadlocks";
+-------+
| count |
+-------+
| 1 |
+-------+
1 row in set (0.01 sec)
- 我们还可以查看InnoDB监视器查看锁信息:
mysql> show engine innodb status\G;
1.3.8.2 死锁产生的条件
- 互斥访问:如果线程1获取到锁A,此时线程2就无法再次获取锁A
- 不可抢占:如果线程1获取到锁A,那么除非线程1自己释放,否则其余线程无法从线程1中抢夺锁
- 请求并保持:当线程已经获取锁A的时候,还要在此基础上获取锁B,并且不会释放锁A
- 循环等待:线程1等待线程2释放锁,线程2也在等待线程1释放锁,死锁发生时系统中一定存在两个或两个以上的线程构成环路,环路上的线程都在等待别的线程释放锁
以上是死锁产生的四个必要条件,比如同时满足才会发生死锁,并且只要破坏任意一个条件就可以打破死锁,通常最简单的就是破坏循环等待条件!
1.4 MVCC
MVCC:上一小节介绍了如何使用锁机制来实现不同的隔离级别,但是频繁加锁解锁对性能会产生比较大的影响,因此InnoDB为了提高性能,同时采用了另一种实现不同隔离级别的机制:MVCC
,即"Multi-Versioned Concurrency Control"多版本并发控制,用来解决脏读、不可重复读等问题,MVCC在某些场景中代替了低效的锁,在保证隔离性的同时提升并发性能。
1.4.1 实现原理
1.4.1.1 Undo Log 链
MVCC的实现原理是基于Undo Log
版本链和ReadView
来实现的,Undo Log我们知道作为事务回滚的基础,会在每一次执行update或者delete操作的时候,将操作的上一版本记录在Undo Log中,每一条Undo Log都记录一个roll_pointer
的引用信息,通过该引用信息就可以将某条数据对应的Undo Log组织成Undo链,同时在数据行的头部通过roll_pointer
就可以与Undo Log中的第一条日志进行关联,构成完整的数据版本链
1.4.1.2 ReadView
每条数据的版本链都构造好后,在查询的时候具体选择哪个版本呢?这里就需要借助ReadView
数据结构实现了,所谓的ReadView
本质上就是一个内存数据结构,在事务中每次使用 select 查询都会构造出一个ReadView
记录该版本链中的一些统计信息,后续就无需再次遍历所有版本链,这些统计值包括:
m_ids
:当前活跃事务id列表m_low_limit_id
:当前活跃事务id列表中最小事务idm_up_limit_id
:下一个将被分配的事务id,也就是版本链头事务id+1m_creator_tx_id
:创建当前readview的事务id
当构造好readview的时候就需要按照一定的查询规则来找到唯一可用版本,比如说在m_creator_tx_id=201
的事务中执行select操作就会构造一个readview数据结构
m_ids
:假设当前活跃事务id列表为[90, 100, 200]m_low_limit_id
:当前活跃事务最小id为90m_up_limit_id
:下一个将要分配的事务id就是201+1=202m_creator_tx_id
:创建readview的事务id为201
接下来找到版本链头,从头开始遍历所有版本,根据四步查找原则:
- 第一步:判断该版本是否由当前事务创建,即若 m_creator_tx_id等于该版本事务id,意味着读取自己修改的数据,可以直接访问,否则下一步
- 第二步:若该版本事务id < m_low_limit_id则意味该版本在构建readview之前已经提交,可以直接访问,否则下一步
- 第三步:若该版本事务id >= m_up_limit_id,则意味着该版本在readview创建完毕之后才创建,所以无需第四步判断,直接遍历下一个版本
- 第四步:若在事务在 m_low_limit_id 和 m_up_limit_id 之间并且没有在活跃事务id列表中则意味着已经提交,可以直接访问,否则遍历下一个版本
这样从版本链头遍历判断到版本链尾,找到⾸个符合要求的版本即可,就可以实现查询到的结果都 是已经提交事务的数据,解决了脏读问题
1.4.1.3 其他问题
关于MVCC是否能解决不可重复读、幻读问题?
- 首先MVCC无法解决幻读问题
- MVCC可以在
REPEATABLE_READ
隔离级别下可以解决不可重复读问题,但是在READ_COMMITED
隔离级别下无法解决- 在
REPEATABLE_READ
隔离级别下,我们只会在第一次使用select查询时生成readview,后续查询都使用该readview保证数据一致性 - 在
READ_COMMITED
隔离级别下,对于每一次select操作都会生成一个readview,就会出现不可重复读问题
- 在
2. 查看并设置隔离级别
-
事务的隔离级别分为全局作用域和会话作用域,查看不同的隔离级别可以使用如下方式:
# 查看全局作用域 select @@global.transaction_isolation; # 查看会话作用域 select @@session.transaction_isolation;
可以看到默认的隔离级别都是REPEATABLE_READ
可重复读
- 设置不同的隔离级别可以使用如下几种方式:
-
通过set命令设置
# 设置全局作用域 set global transaction isolation level serializable; # 设置会话作用域 set session transaction isolation level serializable;
-
通过选项文件进行配置
[mysqld] transaction-isolation = REPEATABLE-READ # 隔离级别为可重复读 transaction-read-only = OFF # 关闭只读意味着访问模式为读写
-
通过set设置系统变量设置
# 方式一 # 设置全局作用域 set global transaction_isolation='REPEATABLE-READ'; # 设置会话作用域 set session transaction_isolation='REPEATABLE-READ'; # 方式二 # 设置全局作用域 set @@global.transaction_isolation='REPEATABLE-READ'; # 设置会话作用域 set @@session.transaction_isolation='REPEATABLE-READ';
-