前言
这次主要来聊聊InnoDB中隔离级别和锁,主要包括三个部分:各种隔离级别存在的问题,InnoDB 中锁的介绍,各种隔离级别是如何加锁的。
不同隔离级别下会出现的一致性问题:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
READ-UNCOMMITTED | ✅ | ✅ | ✅ |
READ-COMMITTED | ❌ | ✅ | ✅ |
REPEATABLE-READ | ❌ | ❌ | ✅ |
SERIALIZABLE | ❌ | ❌ | ❌ |
脏读
:事务A可以读取到事务B修改但未提交
的数据。不可重复读
:同一个事务两次读取同一条记录
的结果不一致,重点在于update
和delete
。幻读
:同一事务两次读取同样范围
的记录,第二次返回了第一次没有的记录,重点在于insert
。
不同隔离级别下存在的问题演示
READ-UNCOMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
脏读 ✅
当前数据库快照
id_ | name_ |
---|---|
1 | 脏读的事务_V1 |
事务A
BEGIN;
UPDATE ql_tx_test.`ql_tx` SET name_ = '脏读的事务_V2' WHERE id_ = 1;
# 未提交
事务B
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ = 1; #脏读的事务_V2
事务A对
id_ = 1
的记录进行修改,但是还没有提交。在事务B中就已经可以读取到了。
不可重复读 ✅
幻读 ✅
该隔离级别既然连脏读都存在(一个事务可以读取另外一个事务未提交的UPDATE数据),那么不可重复读、幻读 必然是存在的。
READ-COMMITTED
**SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;**
脏读 ❌
当前数据库快照
id_ | name_ |
---|---|
1 | 脏读的事务_V9 |
事务A
BEGIN;
UPDATE ql_tx_test.`ql_tx` SET name_ = '脏读的事务_V10' WHERE id_ = 1;
# 未提交
事务B
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ = 1; # 脏读的事务_V9
可以看到在 R-C 隔离级别下不会读取到其他事务未提交的数据,所以不存在脏读。
不可重复读 ✅
当前数据库快照
id_ | name_ |
---|---|
1 | 脏读的事务_V9 |
事务A
BEGIN;
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ = 1; # 脏读的事务_V9;
事务B
BEGIN;
UPDATE ql_tx_test.`ql_tx` SET name_ = '脏读的事务_V10' WHERE id_ = 1;
COMMIT; #提交
事务A
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ = 1; # 脏读的事务_V10;
可以看到,事务A两次读取 id_ = 1 的记录,结果不一样,这就是不可重复读。
PS:跟上面脏读的案例唯一的区别就是,事务B提交了事务。也许这就是为什么该隔离级别叫做读已提交吧。
幻读 ✅
当前数据库快照
id_ | name_ |
---|---|
1 | 脏读的事务_V9 |
事务A
BEGIN;
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ <=10; # 查出一条
事务B
INSERT INTO ql_tx_test.`ql_tx`(`id_`,`name_`) values(4,"幻读事务_V4");
事务A
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ <=10; # 查出两条
事务A两次读取同一范围记录,查询结果不一样。
REPEATABLE READ
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
脏读 ❌
脏读就不演示了。
不可重复读 ❌
当前数据库快照
id_ | name_ |
---|---|
1 | 脏读的事务_V9 |
事务A
BEGIN;
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ = 1; #脏读的事务_V9
事务B
UPDATE ql_tx_test.`ql_tx` SET name_ = '脏读的事务_V11' WHERE id_ = 1;
事务A
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ = 1; #脏读的事务_V9
事务A两次读取同一记录,
不会
因为其他事务对该记录进行了UPDATE而导致两次读取结果不一致。
幻读 ✅
当前数据库快照
id_ | name_ |
---|---|
1 | 脏读的事务_V9 |
2 | 脏读的事务_V2 |
事务A
BEGIN;
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ <=10; # 查出 id_=1、2 两条记录
事务B
INSERT INTO ql_tx_test.`ql_tx`(`id_`,`name_`) values(5,"幻读事务_V5");
事务A
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ <= 10; # 查出 id_=1、2 两条记录
从上面的结果看来,RR隔离级别已经不存在幻读问题。但这是不严谨的,如果幻读定义为同一事务中两次查询同一范围的结果一致,那么确实是解决了。但是现实中,不可能都只是查询,存在的场景时先查询,后插入,那么这时候就出问题了。
我们继续上面的演示
事务A
INSERT INTO ql_tx_test.`ql_tx`(`id_`,`name_`) values(5,"幻读事务_V5");#插入其他事务已提交的id_=5的记录,主键冲突
可以看到,其实还是存在幻读问题,想要解决这个问题,我们可以使用间隙锁解决(后面会详细介绍)。
SERIALIZABLE
SERIALIZABLE 就不一一做演示了,跟RR隔离级流程基本一致。
InnoDB存储引擎中的锁
为了保证数据库事务一致性的性质,一般使用加锁这种方式。所以,想要了解不同隔离级别下的加锁策略,需要先了解InnoDB中都有哪些锁。
共享锁和排它锁
行级锁。
用法
# 共享锁/读锁
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ = 1 LOCK IN SHARE MODE;
# 排它锁/写锁
SELECT * FROM ql_tx_test.`ql_tx` WHERE id_ = 1 FOR UPDATE;
效果:
读读
不冲突,读写
冲突,写写
冲突。
意向锁(Intention Locks)
💡 意向锁主要用于锁表前,避免遍历所有的行是否加锁。相当于加了一个全局的锁标志位。在每一行获取锁之前,先要获取到表的意向锁(因为行锁与意向锁是不会冲突的,所以不同行之间永远不会因为意向锁而冲突)。但是意向锁会与表锁冲突,这样就达到了一个表级的意向锁实现当前是否允许加表锁的需求。
意向锁是表级锁,有两种类型:
IS
:表明一个事务打算对表中的一行记录加共享锁。IX
:表明一个事务打算对表中的一行记录加排它锁。
意向锁遵循如下两个规则:
- 事务给
数据行
加共享锁之前,必须先获得该表的IS
锁。 - 事务给
数据行
加排它锁之前,必须先获得该表的IX
锁。
表级锁的兼容情况
说明:这里抽取一两个样例进行说明
X X 冲突
:两个事务同时对一个表加表级的排它锁,肯定冲突。X IX 冲突
:一个事务想要对一个表加标记的排它锁,但是发现该表的 IX 已经被持有了,冲突。- 两个意向锁之间绝不会冲突。
记录锁(Record Locks)
记录锁是索引记录上的锁(锁住索引)。
例如:
SELECT * FROM ql_tx_test.`ql_tx` WHERE ver_ = 0 FOR UPDATE;
它会对 ver_ = 0
的所有记录加上排他锁(ver_有索引的前提下)。
这里我们根据索引建立的情况,看看三种可能发生的场景:
ver_ 字段是唯一索引
:锁住一条记录。ver_ 字段是普通索引
:锁住N条记录,N取决于有多少条记录 ver_ = 0。ver_ 字段没有索引
:整张表所有的记录都会被锁上。
间隙锁(Gap Locks)
间隙锁锁定一个范围,但不包含记录本身。间隙锁封锁索引记录中的间隔,或者第一条索引记录之前的范围,又或者最后一条索引记录之后的范围。
Gap Locks 的作用是为了阻止多个事务将记录插入到同一范围内,避免幻读问题的产生。
我们同样还是要结合索引建立情况,分三种情况讨论:
我们在事务A执行
SELECT * FROM ql_tx_test.ql_tx WHERE ver_ > 5 AND ver_ < 9 FOR UPDATE;
ver_ 字段是普通索引
:【此时产生间隙锁】,(5,9)这个范围会被锁定,当我们在 事务B 中插入 ver_ = 7 的记录是,执行会被阻塞,事务A再次执行同样查询时返回相同的记录,幻读问题就可以避免。当我们在事务B中插入 ver_ = 11 的记录时,因为11不在锁定的范围,执行新增操作成功。ver_ 字段是唯一索引
:【此时产生间隙锁】,(5,9)这个范围会被锁定。ver_ 字段没有索引
:整张表全部记录都会被加锁。
间隙锁唯一的目的是防止其他事务插入到统一范围。间隙锁可以共存,一个事务获取的间隙锁并不阻止另一个事务获取同一间隙的间隙锁。
临键锁(Next-Key Locks)
Next-Key Locks 是Record Lock 和Record之前的间隙的间隙锁的一种结合。
我们执行一条SQL语句:SELECT * FROM user WHERE id > 7 AND id < 11 FOR UPDATE
,锁住的不是9这单个值,而是对(5,9]、(9,12] 这2个区间加了X锁。因此任何对于这个范围的插入都是不被允许的,从而避免幻读。
所以触发临键锁与触发间隙锁的区别就在于:查询条件范围的端点是否在索引上。不在,用临键锁。在,用间隙锁。
PS:间隙锁、临键锁,在RC隔离级别下是不生效的。
无锁
InnoDB中还有一种非常关键的技术,利用InnoDB的多版本控制,我们可以实现无锁。
InnoDB Multi-Versioning
InnoDB是一个多版本存储引擎。它保存了被修改记录的旧版本信息,用于一致性读
(consistent read)和事务回滚
。这些信息被存储在 Undo logs 中。
Undo logs 分为 insert和update 两种。insert undo logs 仅用作事务的回滚,事务提交之后就可以被移除。但是 update undo logs 还要用与一致性读,当没有事务持有该数据的快照的时候,update undo logs才可以被移除。
在 InnoBD 多版本体系中,当你DELETE一条数据的时候,该数据不会马上被物理移除,只有等待该DELETE语句对应的 update undo logs 被移除之后,对应的记录才会被物理的删除。
一致性读(Consistent Nonlocking Reads)
一致性读利用多版本控制对查询返回
某个时刻
数据的快照。
- 如果是
RR
隔离级别,则同一事务中的所有一致性读都将读取该事务第一次
获取到的数据快照。- 上面这句话什么意思呢?假设您在默认的RR隔离级别上运行,当您执行一致性读(普通的 SELECT 语句)时,InnoDB会根据 timepoint 点查询当前时刻的
数据库快照
。如果另一个事务在该 timepoint 之后删除、插入和更新一行并提交,都不会看到相应的改变。简单来说,根据事务开始的timepoint去undo logs 中查该timepoint对应的数据库快照。
- 上面这句话什么意思呢?假设您在默认的RR隔离级别上运行,当您执行一致性读(普通的 SELECT 语句)时,InnoDB会根据 timepoint 点查询当前时刻的
- 如果是
RC
隔离级别,同一事务中的所有一致性读都是读取数据最新
的快照。
非阻塞
一致性读是 InnoDB 在 RC 和 RR 隔离级别处理 SELECT 语句的默认模式。一致性读不会在它访问的表上设置任何锁,因此其他会话可以在对表执行一致性读的同时自由修改这些表。
Locking Reads
如果你执行先查询,存在插入或者更新一行数据的操作时,简单 SELECT 不能提供足够的保护。因为其他事务可以更新或删除您刚才查询的行。InnoDB 支持两种 locking read 以提供额外的安全保障。
SELECT ... LOCK IN SHARE MODE
:在任何行上设置共享锁。其他事务可以读取这些行,但在事务提交之前不能修改它。SELECT ... FOR UPDATE
:在任何行上设置排它锁。其他事务进不能加读锁(LOCK IN SHARE MODE)也不能加写锁(FOR UPDATE)。
不同SQL的加锁策略
SELECT ... FROM
:一致性读,读取的是数据的快照(RR是读取事务开始的快照,而RC则是读取最新的快照)。SELECT ... LOCK IN SHARE MODE
:在任何行上设置共享锁。其他事务可以读取这些行,但在事务提交之前不能修改它。SELECT ... FOR UPDATE
:在任何行上设置排它锁。其他事务进不能加读锁(LOCK IN SHARE MODE)也不能加写锁(FOR UPDATE)。UPDATE ... WHERE ...
:在每条搜索记录上设置一个独占的 next-key lock。但是,对于使用唯一索引锁定行以搜索唯一行的语句,只需索引记录锁定。DELETE FROM ... WHERE ...
:在每条搜索记录上设置一个独占的 next-key lock。但是,对于使用唯一索引锁定行以搜索唯一行的语句,只需索引记录锁定。INSERT
:在插入的行上设置排他锁。这个锁是索引记录锁,而不是 next-key lock(也就是说,没有间隙锁) ,并且不会阻止其他事务插入到插入的行之前的间隙中。
不同隔离级别的加锁策略
不同的隔离级别采用不同的加锁策略实现数据库事务
一致性与性能的权衡
。
下面的列表描述了MySQL如何支持不同的事务级别,该列表按照使用频率先后展示:
REPEATABLE READ
:普通的 SELECT
:同一事务内读取由第一次读取建立的快照。locking read
:(SELECT … FOR UPDATE ,SELECT … LOCK IN SHARE MODE,UPDATE和DELETE):则取决于查询条件使用的是唯一索引还是范围查询- 如果是唯一索引,仅仅是锁住一条索引记录。
- 如果是非唯一索引,会加间隙锁或者临键锁以保证没有其他的事务往间隙中插入数据。
READ COMMITTED
:对于普通的 SELECT
:总是读取数据最新的快照(fresh snapshot),所以存在不可重复读问题。对于 locking read
:仅加记录锁,不会加间隙锁和临键锁,所以存在幻读问题。
READ UNCOMMITTED
:最自由的隔离级别,可以读其他事务都没提交的数据【脏读】
SERIALIZABLE
:读加共享锁,写加排他锁,读写互斥。
Q&A
为什么RC相对RR可以提高并发度?
很多公司为了提高并发度,没有使用RR,而是使用RC。从上面可以看出,RC隔离级别是不加间隙锁的,可能有人就会说,那我一般也不会使用 locking read啊。兄弟,不要忘记了,update、insert、delete都属于 locking read,一旦是locking read 就有可能加间隙锁。所以RC带来的性能提升就是locking read 不会加间隙锁,并发度自然上来。