MySQL 中有 四种事务隔离级别,不同程度的避免了并发事务会出现的一致性问题!!
由于 脏写 (写-写) 会导致数据库的不一致,在四种隔离级别中都不允许脏写现象的发生,通过加锁实现。当修改某记录的事务没有提交,其它事务均无法修改该记录,阻塞等待事务提交 (释放锁)
使用 MVCC 可以避免 脏读 (写-读) 和 不可重复读 (读-写-读) 现象的发生,MySQL 中 READ COMMITTED 和 REPEATABLE READ 的事务隔离级别就是通过读操作使用 MVCC,写操作加锁实现!!
基于这种实现,写操作永远是针对最新版本的记录,而读操作是根据 ReadView 和 trx_id 控制的快照读,也就是会读到 undo log 中的快照记录
但是存在有其它情况需要读到最新的数据,比如银行取钱场景,需要修改读到的数据,然后写回数据库,如果还是快照读就会在旧记录上更新,然后写回数据库,出现一致性问题
所以对于「读取-修改-写回」的业务场景,也需要对读操作加锁,简称锁定读!!本文主要介绍读写操作都需要加锁的情况!!
在正式介绍锁的使用之前,先介绍一下锁结构,它和 Java 中的锁有些区别~
在 Java 中,锁和共享资源绑定,一个共享资源一把锁,线程访问共享资源前必须获取到对应的锁;在 MySQL 中,锁和事务绑定,当一个事务修改记录前,会为该记录生成一把对应的锁,锁属于事务
每个锁结构中有一个字段表示锁属于哪个事务,另外一个字段is_waiting
表示是否获取到锁,如果为 false 表示成功获取锁,如果为 true 表示获取锁失败。锁结构如下图所示:
类似于 Java 中的读写锁,读读共享,读写互斥,写写互斥
共享锁 (Shared Lock):又称读锁,简称 S 锁,事务在读取一条记录时,需要先获取该记录的 S 锁,允许多个事务同时获取 (读读共享)
独占锁 (Exclusive Lock):又称写锁、排它锁,简称 X 锁,事务在修改一条记录时,需要先获取该记录的 X 锁,不允许多个事务同时获取 (写读互斥、写写互斥)
注意:无论是表锁还是行锁,都存在共享锁和独占锁
总结:独占锁和其它任何锁都不兼容,共享锁只和共享锁兼容
上部分说:无论是表锁还是行锁,都存在共享锁和独占锁
如果一个表只被加了表级共享锁或者表中记录只被加了行级共享锁,那么其它事务依旧可以对该表加表级共享锁或行级共享锁
如果一个表被加了表级共享锁或表级独占锁或者表中记录被加了行级共享锁或行级独占锁,那么其它事务不可以对该表加表级独占锁或行级独占锁
当事务想加表级 S 锁时,需要判断表中是否存在表级 X 锁或者表中是否有记录存在行级 X 锁,如果存在就不可以加表级 S 锁
当事务想加表级 X 锁时,需要判断表中是否存在表级 X 锁或表级 S 锁或者表中是否有记录存在行级 X 锁或行级 S 锁,如果存在就不可以加表级 S 锁
判断表级是否存在 X 锁或者 S 锁比较简单,但判断表中是否有记录存在 X 锁或者 S 锁就很麻烦,在没有其它办法的前提下只能遍历表中所有记录,如果记录数量较多,那么开销就会很大
在这种情况下,提出了意向共享锁和意向独占锁:
意向共享锁 (Intention Shared Lock):简称 IS 锁,每当有事务为记录加上 S 锁时,需要先在表级别加一个 IS 锁
意向独占锁 (Intention Exclusive Lock):简称 IX 锁,每当有事务为记录加上一个 X 锁时,需要先在表级别加上一个 IX 锁
如果表级别有 IS 锁,表示表中有记录存在 S 锁,那么就不能为表加 X 锁;如果表级别有 IX 锁,表示表中有记录存在 X 锁,那么就不能为表加 X 锁和 S 锁,直接让判断时间复杂度从 O(n) 降到 O(1)
注意:IS 锁和 IX 锁只是为了快速判断表是否可以加 S 锁和 X 锁,所以 IS 锁和 IX 锁可以共存,也就是一个表可以同时存在 IS 锁和 IX 锁。同时 IS 锁和 IX 锁只能加在表级,不能被加在行级
全局锁用于锁整个数据库,当执行命令flush tables with read lock
后,整个数据库就处于只读状态,当执行命令unlock tables
后,锁被释放。在锁定期间其它线程执行下面操作都会被阻塞:
对数据的增删改操作,如:insert、delete、update 等语句
对表结构的更改操作,如:alter table、drop table 等语句
应用场景:全局锁主要用于全库的备份操作,必须保证备份操作处于一致性快照中
假设数据库中有用户表和商品表,时刻 1 用户表已经完成备份,时刻 2 有用户购买了商品,同时更新了用户表中用户余额以及商品表中库存,时刻 3 商品表完成备份
如果用上面备份的内容恢复数据就会出现用户余额没有被扣除,但已经减少了商品库存,相当于用户没花钱就购买了一件商品,岂不是亏死啦~
缺点:在全局锁定期间,只允许读数据,对于写操作一律禁止,直接导致业务停滞
改进:在支持可重复读的事务隔离级别存储引擎中,可以开启一个备份事务,由于 ReadView 视图可以让备份事务只能看到该事务之前其它事务提交修改的数据,相当于处于一致性快照中
在介绍 InnoDB 表级锁的开头,先介绍一下其它存储引擎 (MyISAM、MEMORY、MERGE),它们只支持表级锁,且不支持事务,都是以当前会话作为一个事务;而 InnoDB 既支持表级锁,也支持行级锁
下面开始正式介绍 InnoDB 中的表级锁!表级锁相比于行级锁来说,锁粒度更粗,支持的并发量更低,但占用的资源更少,所以表级锁和行级锁各有优势,但行级锁用的更多一些~
前文说:无论是表锁还是行锁,都存在共享锁和独占锁
表级 S 锁和 X 锁是 InnoDB 存储引擎自己提供的,比较鸡肋,一般不用,但可以手动获取它们:
xxxxxxxxxx
# 表级 S 锁,也就是读锁
lock tables t read;
# 表级 X 锁,也就是写锁
lock tables t write;
# 释放当前会话所有表锁
unlock tables;
元数据锁 (Metadata Lock,MDL) 是 server 层提供,不需要显示使用 MDL 锁,因为会根据操作自动为表添加 MDL:
当事务执行 select、insert、delete、update 等 DML 语句时,会自动为表加 MDL 读锁,会阻塞 alter table、drop table 等操作
当事务执行 alter table、drop table 等更改表结构的 DDL 语句时,会自动为表加 MDL 写锁,会阻塞 select、insert、delete、update 等操作
MDL 锁会持续整个事务执行期间,直到事务提交才会释放 MDL 锁。很有意思的一个特点:当表存在 MDL 读锁,然后其它事务对表加 MDL 写锁会阻塞,后续也无法对表加 MDL 读锁
因为对表加 MDL 锁的操作会形成一个队列,队列中加 MDL 写锁的优先级更高,所以后续也无法加 MDL 读锁。如果允许后续加 MDL 读锁,可能会导致加 MDL 写锁操作一直被阻塞
详情可见 意向共享锁 & 意向独占锁
当一个表的主键声明了auto_increment
后,在插入记录时就可以不用指定主键值,会自动赋予一个递增的值
InnoDB 在执行插入语句时是多线程,而且自增值由 InnoDB 获取从内存中获取,如果在插入时不加锁,可能会出现多个线程获取到相同自增值的情况,加锁也可以保证一条语句中分配的自增值是连续的
系统自动给auto_increment
修饰的列进行递增赋值的实现方式有两种:
使用 AUTO-INC 锁。在执行插入语句时会加一个表级 AUTO-INC 锁,然后为每条待插入记录的auto_increment
修饰的列分配自增值,语句执行结束后,释放 AUTO-INC 锁。这样可以保证同一时刻只有一个插入事务请求分配自增值,从而保证一个语句中分配的自增值是连续的。(注意: AUTO-INC 锁在语句执行完释放,并非在事务提交后释放)
使用轻量级锁。在执行插入语句时获取这个轻量级锁,然后为每条待插入记录的auto_increment
修饰的列分配自增值后就释放掉轻量级锁,不需要等待语句执行完
可以看到第二种方式的锁粒度更细,只需要等待自增值分配完就可以释放锁,而第一种方式必须等待语句执行完才可以释放锁
但第一种方式适用于执行前不知道具体插入记录数量的情况,如:insert ... select ...
;第二种适用于执行前知道具体插入记录数量的情况,如:insert into t values (x), (x) ...
InnoDB 提供了一个innodb_autoinc_lock_mode
系统变量:
值为 0:一律使用 AUTO-INC 锁
值为 1:混合模式,执行前知道插入记录数量就使用轻量级锁,否则使用 AUTO-INC 锁
值为 2:一律使用轻量级锁
如果innodb_autoinc_lock_mode = 2
,且执行前不知道插入记录数量,那么可能就会出现一条语句中分配的自增值不连续,在主从复制的架构中会出现主从数据不一致的情况
根据上图,给出一种执行顺序:
事务 B 插入(1, 1, 1)
和(2, 2, 2)
事务 A 插入(3, 5, 5)
事务 B 插入(4, 3, 3)
和(5, 4, 4)
假设 binlog 使用 Statement 模式,在主从复制的过程中,从库中的 SQL 线程串行化执行 binlog 中的语句,insert into t2(b, c) select b, c from t
语句被分配的自增值肯定是连续的
建议:在innodb_autoinc_lock_mode = 2
时,将 binlog 格式设置为 row,就不会出现主从数据不一致的情况
注意:InnoDB 支持行级锁,MyISAM 不支持行级锁
update
和delete
语句都会对记录加锁;普通的select
语句不会对记录加锁,属于快照读;锁定读也会对记录加锁,属于当前读。锁定读语句如下:
xxxxxxxxxx
# 对读取的记录加读锁 (S 锁)
select ... lock in share mode;
# 对读取的记录加写锁 (X 锁)
select ... for update;
行级锁主要有三种类型:
Record Lock:记录锁,锁定一条记录
Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
Next-Key Lock:Record Lock 和 Gap Lock 的结合,既锁定记录,也锁定范围
开始介绍这三种类型的锁之前,先给出一个表的简易图,下面都基于该表:
Record Lock 是记录锁,锁定一条记录,而且有 S 锁和 X 锁之分,兼容性同 共享锁 & 独占锁
当执行下面语句时:
x
begin;
select * from t where id = 8 for update; # 为 id = 8 的记录加 X 型记录锁
加锁情况如下图所示:
注意:当事务提交后,锁才会释放
Gap Lock 是间隙锁,锁定一个范围,但不包含记录本身,只存在于可重复读的隔离级别下,为了解决可重复读隔离级别下幻读的现象,但无法完全解决幻读现象
假设为id = 8
的记录加了 Gap Lock,意味着无法在id = 8
前的间隙插入记录,也就是无法插入3 < id < 8
的记录,加锁情况如下图所示:
注意:虽然 Gap Lock 也有 S 锁和 X 锁,但没有什么区别,一个间隙可以包含多个 Gap Lock,它们的作用是相同的,为了防止插入幻影记录
如果不希望在[20, +∞]
间隙插入记录,只需要在Supremum
记录处加 Gap Lock 即可
Next-Key Lock 是 Record Lock 和 Gap Lock 的结合,既锁定记录,也锁定范围。Next-Key Lock 也有 S 锁和 X 锁之分
S 型 Next-Key Lock:可以获取相同范围的 S 型 Next-Key Lock,但无法获取相同范围的 X 型 Next-Key Lock
X 型 Next-Key Lock:无法获取相同范围的 S 型 Next-Key Lock,也无法获取相同范围的 X 型 Next-Key Lock
假设为id = 8
的记录加了 X 型 Next-Key Lock,意味着无法在id = 8
前的间隙插入记录,也就是无法插入3 < id < 8
的记录,也无法访问id = 8
的记录,加锁情况如下图所示:
Insert Intention Lock 是插入意向锁,也属于间隙锁,但却无法同时获取同一间隙的间隙锁和插入间隙锁,因为它俩是互斥的,间隙锁表示禁止在间隙插入记录,插入间隙锁表示有在该间隙插入记录的意图
一个事务在插入一条记录时,需要判断插入位置是否已被其它事务加了间隙锁。如果有的话,插入操作需要阻塞等待,直到拥有间隙锁的事务提交为止。在等待期间,会为事务生成一个插入意向锁,表明有在该间隙插入记录的意图,但此时处于等待状态
假设事务 T1 为id = 8
的记录加了间隙锁,然后事务 T2 和 T3 分别想向表中插入(4, ddd)
和(5, bbb)
,现在id = 8
的记录加锁情况如下图所示:
当事务 T1 提交后,间隙锁被释放,事务 T2 和 T3 可以同时获取id = 8
的插入意向锁,它们的之间并不会相互阻塞