22.1 解决并发事务带来问题的两种基本方式
写 - 写 情况
在这种情况下会发生脏写,任何一种隔离级别都不允许这种现象的发生。所以在多个未提交事务相继对一条记录改动时,需要让它们排队执行·——通过加锁来实现。(锁本质上是一个内存中的结构)
当一个事务想对一条记录进行改动时,首先会看内存中有无与这条记录关联的锁结构。如果没有,就会在内存中生成一个锁结构与之关联:
锁中的两个较为重要的属性为:
- trx 信息:表示这个锁结构是与哪个事务关联的;
- is_waiting:表示当前事务是否在等待。(当 is_waiting 属性为 false,就称为获取锁成功,即加锁成功)
在事务 T1 提交之前,另一个事务 T2 也想对记录进行改动,那么 T2 就需要先查看是否有锁结构与这条记录关联。在发现有一个锁结构与之关联后,T2也生成一个锁结构与这条记录关联,不过锁结构的 is_waiting 属性值设为 true,表示需要等待:
事务 T1 提交后,就会把它生成的锁结构释放掉,然后检测一下是否还有与该记录关联的锁结构。发现事务 T2 还在等待获取锁,所以将事务 T2 对应的锁结构的 is_waiting 属性设置为 false,然后把该事物对应的线程唤醒,让 T2 继续执行:
读 - 写或写 - 读情况
在这两种情况下,会出现脏读、不可重复读、幻读的现象。
避免脏读、不可重复读、幻读,有两种解决方案:
- 方案 1:读操作使用多版本并发控制(MVCC),写操作加锁
MVCC 通过生成一个 ReadView,然后通过 ReadView 找到符合条件的记录版本(历史版本由 undo 日志构建)。类似于在生成 ReadView 的那一刻时间静止了(拍了份快照),查询语句只能读到在生成 ReadView 之前已经提交的事务所做的更改。
写操作一定是针对最新版本的记录,读记录的历史版本和改动记录的最新版本两者并不冲突,即使用 MVCC时,读 - 写操作不冲突。
- 方案 2:读、写操作都采用加锁的方式
如果一些业务场景不允许读取记录的旧版本,而是每次都要求读取记录的最新版本。e.g. 用户在银行存款时,需要将账户余额读出来加上本次存款的数额,最后写入数据库中。在将账户余额读取出来后,就不想让其他事务再访问该余额,直到本次存款事务执行完成后。
这样在读取记录时也需要对其进行加锁操作,读 - 写操作也要像写 - 写操作那样排队执行。
一致性读
事务利用 MVCC 进行的读取操作称为一致性读(一致性无锁读)。
所有普通的 SELECT 语句在 READ COMMITTED 、 REPEATABLE READ 隔离级别下都算是一致性读:
SELECT * FROM t;
SELECT * FROm t1 JOIN t2 ON t1.col1 = t2.col2;
一致性读不会对表中的任何记录加锁,其他事务可自由对表中记录进行改动。
锁定读
- 共享锁和独占锁
在使用加锁的方式解决问题时,既要允许读 - 读不受影响,又要使写 - 写、读 - 写或写 - 读情况中的操作互相阻塞,MySQL将锁分类:
共享锁:简称 S 锁。在事务读取一条记录时,需要先获取该记录的 S 锁;
独占锁:排他锁,简称 X 锁。在事务要改动一条记录时,需要先获取该记录的 X 锁;
如果事务 T1 首先获取一条记录的 X 锁,那么之后无论事务 T2 想获取该记录的 S 锁还是 X 锁,都会被阻塞,直到事务 T1 提交之后将 X 锁释放掉。
- 锁定读的语句
在读取记录时就获取记录的 X 锁,从而禁止别的事务读写该记录。这种在读取记录前就为该记录加锁的读取方式称为锁定读。
对读取的记录加 S 锁
SELECT ... LOCK IN SHARE MODE;
即在普通的 SELECT 语句后加
LOCK IN SHARE MODE
。如果当前事务执行了该语句,就会为读取到的记录加 S 锁,这样可以允许别的事务继续获取这些记录的 S 锁;如果别的记录想获取这些记录的 X 锁,就会被阻塞,直到当前事务提交之后将这些记录上的 S 锁释放掉为止。对读取的记录加 X 锁
SELECT ... FOR UPDATE;
即在普通的 SELECT 语句后加
FOR UPDATE
。如果当前事务执行了该语句,就会为它读取到的记录加 X 锁,这样既不允许别的事务获取这些记录的 S 锁,也不允许获取 X 锁。直到当前事务提交之后将这些记录上的 X 锁释放掉为止。
写操作
平时用到的写操作无外乎 DELETE 、 UPDATE 、 INSERT:
DELETE:对一条记录执行 DELETE 操作就是先在 B+ 树中定位到这条记录,然后获取这条记录的 X 锁,最后再执行 delete mark 操作。前两步可以看作一个获取 X 锁的锁定读。
UPDATE:在对一条记录进行 UPDATE 操作时分为三种情况:
如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在 B+ 树中定位这条记录的位置,然后再获取记录的 X 锁,最后在原记录的位置进行修改操作。
如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在 B+ 树中定位到这条记录的位置,然后获取记录的 X 锁,之后将该记录彻底删除掉,最后再插入一条新记录。(与被彻底删除的记录关联的锁也会被转移到这条新插入的记录上来)
如果修改了该记录的键值,这相当于在原记录上执行了 DELETE 操作之后再来一次 INSERT 操作,加锁操作按照 DELETE 和 INSERT 的规则进行。
INSERT:一般情况下,新插入的一条记录受隐式锁保护,不需要在内存中为其生成对应的锁结构。
22.2 多粒度锁
上述的锁都是针对记录的——行锁。对一条记录加行锁,影响的只有这条记录,行锁粒度比较细。一个事务也可以在表级别进行加锁——表锁,粒度较粗。
那么在上表锁之前,如何确定是否有记录被上行锁, InnoDB提出意向锁:
意向共享锁:IS 锁,当事务准备在某条记录上加 S 锁时,需要先在表级别加 IS 锁。
意向独占锁:IX 锁,当事务准备在某条记录上加 X 锁时,需要先在表级别加 IX 锁。
22.3 MySQL 中的行锁和表锁
- InnoDB 中的表级锁
- 表级别的 S 锁、X 锁
在对某个表执行 SELECT、INSERT、DELETE 和 UPDATE 语句时,InnoDB不会为这个表添加表级别的 S 锁或 X 锁。
在对某个表执行如 ALTER TABLE 、 DROP TABLE 等 DDL语句时,其他事务在对这个表并发执行SELECT、INSERT、DELETE 和 UPDATE 语句,会发生阻塞。反之同理。该过程通过在 server 层使用一种元数据锁实现。
InnoDB 提供的表级 S 锁和 X 锁相当鸡肋,尽量避免使用手动锁表,并不会提供额外的保护,只会降低并发能力。
- 表级别的 IS 锁、IX 锁
当对使用 InnoDB 的表的某些记录加 S 锁、X 锁之前,需要先在表级别加一个 IS 锁、IX 锁,
- 表级别的 AUTO-INC 锁
可以为表的某个列添加 AUTO_INCREMENT 属性,之后在插入记录时可以不指定该列的值,系统会自动为它赋予递增的值。
系统自动给 AUTO_INCREMENT 修饰的列进行递增赋值的实现方式主要有以下两个:
采用 AUTO-INC 锁,即在执行插入语句时就加入一个表级别的 AUTO-INC 锁,然后为每条待插入记录的 AUTO_INCREMENT 修饰的列分配递增的值。在该语句执行结束后,再把 AUTO-INC 锁释放掉。如此,一个事务在持有 AUTO-INC 锁过程中,其他事务的插入语句都要被阻塞,从而保证一个语句中分配的递增值是连续的。
采用一个轻量级,在为插入语句生成 AUTO_INCREMENT 修饰的列的值时获取这个轻量级锁,然后在生成本次插入语句需要用的 AUTO_INCREMENT 修饰的列的值之后,就把该锁释放掉,而不需要等到整个插入语句执行完后才释放锁。
如果在插入语句之前就可以确定具体要插入多少条记录,那么一般采用轻量级锁的方式对 AUTO_INCREMENT 修饰的列进行赋值。可以避免锁定表,提升插入性能。
- InnoDB 中的行级锁
常用的行级锁类型:
- Record Lock
仅仅把一条记录锁上。
- Gap Lock(针对幻读问题)
MySQL 在 REPEATABLE READ 隔离级别下可以很大程度上解决幻读现象。可以使用 MVCC 方案或加锁方案。但是在使用加锁方案时,事务在第一次执行读取操作时,那些幻影记录尚未存在,无法给这些幻影记录上 Record Lock。所以 InnoDB 提出 Gap Lock 。
在一条记录上加 gap 锁,表示不允许别的事务在该记录前面的间隙插入新记录。直到拥有该 gap 锁的事务提交了之后才将该 gap 锁释放掉。
HINT:如何为最后一条记录后的间隙上 gap 锁?对 Supremum 记录上 gap 锁即可。
- Next-Key Lock
既可以锁住某条记录,又可以阻止其他事务在该记录前的间隙插入新记录。相当于 Record Lock 和 Gap Lock 的结合。
- Insert Intention Lock
一个事务在插入一条记录时,需要判断插入位置是否已被别的事务加了 gap 锁。如果有的话,插入操作需要等待,但是 InnoDB规定,事务在等待时也需要在内存中生成一个锁结构,表明事务想在某个间隙中插入新记录,但现在处于等待状态—— Insert Intention Lock。
- 隐式锁
由于在内存中生成并维护锁结构有成本消耗,所以 InnoDB 提出隐式锁。
一个事务对新插入的记录可以不显式加锁,但是由于事务 id 的存在,相当于加了一个隐式锁。别的事务在对这条记录加 S 锁或 X 锁时,由于隐式锁的存在,会为当前事务生成一个锁结构,然后自己再生成一个锁结构,最后进入等待状态。
所以隐式锁起到了延迟生成锁结构的用处。如果别的事务在执行过程中不需要获取与隐式锁相冲突的锁,就可以避免在内存中生成锁结构。
InnoDB 锁的内存结构
如果符合下列条件,这些记录的锁既可以放到同一个锁结构中:
- 在同一个事务中进行加锁操作;
- 被加锁的记录在同一个页面中;
- 加锁的类型是一样的;
- 等待状态是一样的。
锁所在的事务信息:记载该锁对应的事务信息。无论是表级锁还是行级锁,一个锁属于一个事务。
索引信息:对于行级锁来说,需要记录加锁的记录属于哪个索引。
表锁/行锁信息:
- 表级锁记载对哪个表加锁;
- 行级锁记载如下三个信息:
- Space ID:记录所在的表空间;
- Page Number:记录所在的页号;
- n_bits:对于行级锁,一条记录对应一个比特;一个页面包含很多条记录,用不同的比特来区分是对哪一条记录加了锁。
type_mode:32比特,被分为 lock_mode、lock_type 和 rec_lock_type 三个部分:
lock_mode 占用低 4 比特,可选值如下:
lock_type 占用 5~8 位,目前只用 5、6 位:
rec_lock_type 使用其余位来表示,只有在 lock_type 值为 LOCK_REC 时(即该锁时行级锁时)才会细分出更多类型:
其他信息:为更好管理系统运行过程中生成的各种锁结构,而设计了各种哈希表和链表;
一堆比特位:如果是行级锁结构,在该锁结构末尾还放置了一堆比特位,用 n_bits 属性来表示。一个比特位映射一个 heap_no :
(此处有关加锁流程看原书p420!!!)
22.4 语句加锁分析
把语句分为 4 大类:普通的 SELECT 语句、锁定读的语句、半一致性读的语句以及 INSERT 语句。
普通的 SELECT 语句
在不同的隔离级别下,普通的 SELECT 语句具有不同的表现:
READ UNCOMMITTED,不加锁,直接读取记录的最新版本:可能出现脏读、不可重复读、幻读;
READ COMMITTED,不加锁:每次执行普通的 SELECT 语句时都会生成一个 ReadView,这样避免了脏读,无法避免不可重复读和幻读;
REPEATABLE READ,不加锁:只在第一次执行普通的 SELECT 语句时生成一个 ReadView,这样就把脏读、不可重复读、幻读都避免了。
锁定读的语句
- 匹配模式
在使用索引执行查询时,如果被扫描区间是一个单点扫描区间,此时的匹配模式就是精确匹配。
- 唯一性搜索
如果在扫描某区间前,就能实现确定该扫描区间内最多只包含一条记录的话——唯一性搜索。
只要查询复合下面的条件,就可以确定最多只包含一条记录:
- 匹配模式为精确匹配;
- 使用的索引时主键或唯一二级索引(如果是唯一二级索引,搜索条件不能是
**索引列 IS NULL**
) - 如果索引中包含多个列,那么在生成扫描区间时,每一个列都得被用到。
在读取某个扫描区间中记录的过程如下:
首先快速在 B+ 树叶子节点中定位到该扫描区间中的第一条记录,该该记录作为当前记录;
为当前记录加锁:对于锁定读的语句,在隔离级别不大于 READ COMMITTED 时,会为当前记录加正经记录锁;在隔离级别不小于 REPEATABLE READ 时,会加next-key锁;
判断索引条件下推的条件是否成立:索引条件下推用来把查询中与被使用索引有关的搜索条件下推到存储引擎中判断,而不是返回到 server 层再判断(只是为了减少回表次数,减少 I/O 操作)。
如果存在索引条件下推的条件时,如果当前记录符合索引条件下推的条件,则跳到步骤 4 继续执行;如果不符合,则直接获取当前记录所在单向链表的下一条记录,将该记录作为新的当前记录,并跳回步骤 2。
步骤 3 不会释放锁。
执行回表操作:如果读取的是二级索引记录,则需要回表操作,获取到对应的聚簇索引记录并给该聚簇索引记录加正经记录锁;
判断边界条件是否成立:如果该记录符合边界条件,跳到步骤 6 继续执行,否则在隔离级别不大于 READ COMMITTED 时,就要释放掉加在该记录上的锁,并且向 server 层返回一个查询完毕信息。
server 层判断其余搜索条件是否成立:如果成立,将该记录发送到客户端不释放锁,如果不成立那么在隔离级别不大于 READ COMMITTED 时,就要释放掉加在该记录上的锁。
获取当前记录所在单向链表的下一条记录,并将其作为新的当前记录,跳回步骤 2。
可以看出都是先给记录上锁,然后再交给 server 层判断是否要释放锁。
一些特殊的情况:
当隔离级别不大于 READ COMMITTED 时,如果匹配模式为精确匹配,则不会为扫描区间后面的下一条记录加锁。
当隔离级别不小于 REPEATABLE READ 时,如果匹配模式为精确匹配,则会为扫描区间后面的下一条记录加 gap 锁;
当隔离级别不小于 REPEATABLE READ 时,如果匹配模式不是精确匹配,并且没有找到匹配的记录,则会为该扫描区间后面的下一条记录加 next-key 锁;
当隔离级别不小于 REPEATABLE READ 时,如果使用的是聚簇索引,并且扫描的扫描区间是左闭区间,而且定位到的第一条聚簇索引记录的 number 值正好与扫描区间中最小的值相同,那么会为该聚簇索引记录加正经记录锁(该记录后面的记录加 next-key 锁即可);
无论是哪个隔离级别,只要是唯一性索引,并且读取的记录没有被标记为“已删除”(记录头信息中的 delete_flag 为 1),就为读取到的记录加正经记录锁;
在扫描区间时候一般是从左到右的顺序,有时候需要从右到左扫描,当隔离级别不小于 REPEATABLE READ时,并且按照从右到左的顺序扫描扫描区间中的记录时,会给匹配到的第一条记录的下一条记录(还是原来顺序,不是从右到左的顺序)加 gap 锁。(目的是防止其他事务插入值等于第一条记录的新记录)
半一致性读的语句
当隔离级别不大于 READ COMMITTED 且执行 UPDATE 语句时将使用半一致性读。
半一致性读,就是当 UPDATE 语句读取到已经被其他事务加了 X 锁的记录时,InnoDB 会将该记录的最新踢脚板本读出来,然后判断该版本是否与 UPDATE 语句中的搜索条件相匹配。如果不匹配,则不对该记录加锁,从而跳到下一条记录;如果匹配,则再次读取该记录并对其进行加锁。(这个过程其实就是判断是否真的需要加锁)
INSERT 语句
一般情况下 INSERT 语句不需要在内存中生成锁结构。单纯依靠隐式锁保护插入的记录。不过当前事务在插入一条记录前,需要先定位到该记录在 B+树中的位置。如果下一条记录已经被加了 gap 锁(next-key也包含 gap 锁),那么当前事务会为该记录加插入意向锁。
INSERT 时候遇到的两种特殊情况:
- 遇到重复键
在插入一条新记录时,首先要确定这条新记录要插入到 B+ 树的哪个位置。如果发现与现有记录的主键或唯一二级索引列相同,会报错。
在生成报错信息之前,要对聚簇索引中已有的那条记录加 S 锁:
- 当隔离级别不大于 READ UNCOMMITTED 时,加 S 型正经记录锁;
- 当隔离级别不小于 REPEATABLE READ 时,加 S 型 next-key 锁。
如果是唯一二级索引列值重复,无论什么隔离级别,都对已有那条记录加 next-key 锁。
HINT:在使用 INSERT ... ON DUPLICATE KEY...
这种语法插入记录时,遇到重复,会加 X 锁,而不是 S 锁。
- 外键检查
当向子表插入一条记录时,存在外键值在父表中找得到和找不到两种情况:
待插入记录的外键值在父表中能找到:在插入成功前,无论当前事务的隔离级别是什么,只要给父表中外键值为该值的记录加一个 S 型正经记录锁即可。
待插入记录的外键值在父表中找不到:虽然插入失败,但是要根据隔离级别对父表中下一条记录加锁:
- 当隔离级别不大于 READ UNCOMMITTED 时,不对记录加锁;
- 当隔离级别不小于 REPEATABLE READ 时,加 gap 锁。
22.6 死锁
假设开启两个事务,执行流程如下:
T1 阻塞在 number = 3 的记录上;T2 阻塞在 number = 1 的记录上。二者都不能继续执行,此时死锁。
InnoDB 有一个死锁检测机制,当检测到死锁发生时,会选择一个较小的事务进行回滚,并向客户端发送一条消息:
可见,当事务以不同顺序获取某些记录的锁时,可能会发生死锁。当死锁发生时,InnoDB 会回滚一个事务,以释放掉该事务所获取的锁。
(死锁的日志分析看原书p451)