22.1 解决并发事务带来问题的两种基本方式
当一个事务想对一条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。
- trx 信息:代表这个锁结构是哪个事务生成的。
- is_waiting:代表当前事务是否在等待。
- 当事务 T1 改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有有别的事务为这条记录加锁,所以 is_waiting = false,这个场景称为获取锁成功,或者加锁成功,可以继续执行操作。
- 此时事务 T2 也想改动这条记录,它首先看有没有锁结构与该记录关联,发现有,于是就生成一个 is_waiting = true 的锁结构与这条记录关联,这个场景称为获取锁失败,或者加锁失败,需要等待执行。
- 事务 T1 提交之后,会把它生成的锁结构释放掉,并且看有没有其他事务在等等获取锁,如果有,就把该事务的锁结构 is_waiting 属性设置为 false,并唤醒该事务。
与 SQL 标准不同的是,MySQL 在 REPEATABLE READ 隔离级别不但解决了脏读和不可重复读,还解决了幻读问题。
关于如何解决脏读、不可重复读和幻读,有两种方案:
方案一:读操作利用 MVCC,写操作进行加锁。
通过生成一个 ReadView,然后通过 ReadView 找到符合条件的记录版本,其实就像是在生成 ReadView 的那个时刻做了一次时间静止,查询语句只能读到在生成 ReadView 之前已提交事务所做的更改,在生成 ReadView 之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突。
方案二:读、写操作都进行加锁。
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。
22.1.1 一致性读(Consistent Reads)
事务利用 MVCC 进行的读取操作称为一致性读,或者一致性无锁读,或者快照读。所有普通的 SELECT 语句,在 READ COMMITED、REPEATABLE READ 隔离级别下都算是一致性读。
一致性读不会对表的中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。
22.1.2 锁定读(Locking Reads)
22.1.2.1 共享锁和独占锁
- 共享锁:Shared Locks,即 S 锁。事务要读取一条记录时,需要先获取该记录的 S 锁。
- 独占锁:Exclusive Locks,即 X 锁。事务要改动一条记录时,需要先获取该记录的 X 锁。
兼容性 | X | S |
---|---|---|
X | 不兼容 | 不兼容 |
S | 不兼容 | 兼容 |
22.1.2.2 锁定读的语句
- 对读取的记录加 S 锁:
SELECT ... LOCK IN SHARE MODE;
如果当前事务执行了该语句,那么它会为读取到的记录加 S 锁,这样允许别的事务继续获取这些记录的 S 锁,但是不能获取这些记录的 X 锁。
- 对读取的记录加 X 锁:
SELECT ... FOR UPDATE;
如果当前事务执行了该语句,那么它会为读取到的记录加 X 锁,这样即不允许别的事务获取这些记录的 S 锁,也不允许获取这些记录的 X 锁。
22.1.3 写操作
- DELETE
先在 B+ 树中定位到这条记录的位置,然后获取一下这条记录的 X 锁,然后再执行 delete mark 操作。我们也可以把这个定位待删除记录在 B+ 树中位置的过程看成是一个获取 X 锁的锁定读。
-
UPDATE
- 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在 B+ 树中定位到这条记录的位置,然后再获取一下记录的 X 锁,最后在原记录的位置进行修改操作。我们也可以把这个定位待修改记录在 B+ 树中位置的过程看成是一个获取 X 锁的锁定读。
- 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在 B+ 树中定位到这条记录的位置,然后获取一下记录的 X 锁,将该记录彻底删除掉,最后再插入一条新记录。这个定位待修改记录在 B+ 树中位置的过程看成是一个获取 X 锁的锁定读,新插入的记录由 INSERT 操作提供的隐式锁进行保护。
- 如果修改了该记录的键值,则相当于在原记录上做 DELETE 操作之后再来一次 INSERT 操作。
-
INSERT
一般情况下,新插入一条记录的操作并不加锁,InnoDB 通过隐式锁来保护新插入的记录在本事务提交前不被别的事务访问。
22.2 多粒度锁
意向共享锁:Intention Shared Lock,简称 IS 锁。当事务准备在某条记录上加 S 锁时,需要先在表级别加一个 IS 锁。
意向独占锁:Intention Exclusive Lock,简称 IX 锁。当事务准备在某条记录上加 X 锁时,需要先在表级别加一个 IX 锁。
IS、IX 是表级锁,它们的提出仅仅为了在之后加表级别的 S 锁和 X 锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录。也就是说其实 IS 锁和 IX 锁是兼容的,IX 锁和 IX 锁是兼容的。
兼容性 | X | IX | S | IS |
---|---|---|---|---|
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
IX | 不兼容 | 兼容 | 不兼容 | 兼容 |
S | 不兼容 | 不兼容 | 兼容 | 兼容 |
IS | 不兼容 | 兼容 | 兼容 | 兼容 |
22.3 MySQL 中的行锁和表锁
22.3.1 其他存储引擎中的锁
MyISAM、MEMORY、MEGE 这些存储引擎只支持表级锁,而且并不支持事务。
22.3.2 InnoDB 存储引擎中的锁
InnoDB 存储引擎既支持表锁,也支持行锁。
22.3.2.1 InnoDB 中的表级锁
- 表级别的 S 锁、X 锁
在对某个表执行 SELECT、INSERT、DELETE、UPDATE 语句时,InnoDB 是不会为这个表添加表级别的 S 锁或者 X 锁的。
另外,在对某个表执行一些诸如 ALTER TABLE、DROP TABLE 这类的 DDL 语句时,其他事务对这个表并发执行 DQL、DML 语句会发生阻塞,这个过程其实是通过在 server 层使用一种称为**元数据锁(Metadata Locks,MDL)**来实现的,一般情况下也不会使用 InnoDB 提供的表级别的 S、X 锁。
InnoDB 提供的表级 S 锁和 X 锁相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。
- 表级别的 IS 锁、IX 锁
IS 锁和 IX 锁的使命只是为了后续在加表级别的 S 锁 和 X 锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
- 表级别的 AUTO-INC 锁
AUTO_INCREMENT
属性原理:
- 采用 AUTO-INC 锁:在执行插入语句时就在表级别加一个 AUTO-INC 锁,然后为每条待插入记录分配递增的值,在该语句执行结束后,再把 AUTO-INC 锁释放掉。
- 采用一个轻量级的锁:在为插入语句生成 AUTO_INCREMENT 值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的值后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。
TIPS:InnoDB 提供 innodb_autoinc_lock_mode
系统变量来控制使用上述哪种方式生成 AUTO_INCREMENT
列,0:AUTO-INC;2:轻量级锁;1:混用(主从复制时不安全)。
22.3.2.2 InnoDB 中的行级锁
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number),
KEY idx_name (name)
) Engine=InnoDB CHARSET=utf8;
INSERT INTO hero VALUES
(1, 'l刘备', '蜀'),
(3, 'z诸葛亮', '蜀'),
(8, 'c曹操', '魏'),
(15, 'x荀彧', '魏'),
(20, 's孙权', '吴');
常用的行锁类型:
-
Record Locks:LOCK_REC_NOT_GAP,正经记录锁
-
Gap Locks:LOCK_GAP,gap 锁
通过加锁的方式解决幻读问题时,由于幻影记录尚不存在无法加锁,于是就在可能插入幻影记录的地方加上 gap 锁。
此时就保证了 number 列在(3,8)区间不会有新记录插入。
使用 Supremum[1](####5.3.1 记录头信息的秘密) 记录来保证(20,+∞)区间不会有新记录插入。
gap 锁的提出仅仅是为了防止插入幻影记录
-
Next-Key Locks:LOCK_ORDINARY,next-key 锁
既可以锁住某条记录,又可以阻止其他事务在该记录前面的间隙插入新记录。
-
Insert Intention Locks:LOCK_INSERT_INTENTION,插入意向锁
一个事务在插入一条记录是需要判断插入位置有没有 gap 锁(包括 next-key 锁),如果有的话就需要等待那个持有 gap 锁的事务提交,并生成一个
插入意向锁
的锁结构。多个插入意向锁之间不会相互阻塞,事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁(就是这么鸡肋)。
-
隐式锁
一个事务对新插入的记录可以不显式地加锁(生成一个锁结构),但是由于
事务 id
的存在,相当于加了一个隐式锁。别的事务在对这条记录加 S 锁 或者 X 锁时,由于隐式锁的存在,会称帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。
22.3.3 InnoDB锁的内存结构
对一条记录加锁的本质就是在内存中创建一个与之关联的锁结构
对多条记录加锁时可以使用同一个锁结构,但要符合以下条件:
- 在同一个事务中
- 被加锁的记录在同一个页面
- 加锁的类型相同
- 等待状态相同
-
锁所在的事务信息:指向内存中事务的指针
-
索引信息:对于行锁来说,记录加锁的记录属于哪个索引
-
表锁/行锁信息
- 表锁:记录被加锁的表
- 行锁:Space ID(所在表空间),Page Number(页号),n_bits(使用了多少用来区分是记录是否加锁的比特位)
-
type_mode:32位,分为3个部分
-
lock_mode:锁的模式,占用低4位
名称 十进制表示 含义 LOCK_IS 0 共享意向锁,IS 锁 LOCK_IX 1 独占意向锁,IX 锁 LOCK_S 2 共享锁,S 锁 LOCK_X 3 独占锁,X 锁 LOCK_AUTO_INC 4 AUTO-INC 锁 -
lock_type:锁的类型,占用低5~8位
名称 十进制表示 含义 LOCK_TABLE 16(第5个bit为1) 表级锁 LOCK_REC 32(第6个bit为1) 行级锁 -
rec_lock_type:行锁的具体类型,占用其余位
名称 十进制表示 含义 LOCK_ORDINARY 0 next-key 锁 LOCK_GAP 512(第10个bit为1) gap 锁 LOCK_REC_NOT_GAP 1024(第11个bit为1) 记录锁 LOCK_INSERT_INTENTION 2048(第12个bit为1) 插入意向锁 LOCK_WAIT 256(第9个bit为1) 第9个比特位为1时,表示 is_waiting = true,否则 is_waiting = false
-
-
其他信息:为了更好地管理各种锁结构而设计的各种哈希表和链表
-
一堆比特位:
对应着一个页面中的记录,一个比特位映射一个 heap_no
22.4 语句加锁分析
22.4.1 普通的SELECT语句
在不同隔离级别下,有不同表现:
- READ UNCOMMITTED:不加锁;直接读取记录的最新版本;可能出现脏读、不可重复读和幻读
- READ COMMITED:不加锁;每次查询时会生成一个 ReadView,避免了脏读,可能会出现不可重复读和幻读
- REPAEATABLE READ:不加锁;只在第一次查询时生成一个 ReadView,避免了脏读、不可重复读和幻读。
- SERIALIZABLE:
- autocommit = 0,禁用自动提交时,普通 SELECT 会被转换为 SELECT……LOCK IN SHARE MODE 获取 S 锁,具体加锁情况与 REPAEATABLE READ 一致。
- autocommit = 1,启用自动提交时,不加锁,会生成 ReadView 来读取记录,这时一个事务中只包含一条语句,也就不会出现不可重复记、幻读的现象了。
TIPS:MVCC 并不能完全避免幻读现象
在 REPAEATABLE 隔离级别下,T1 第一次执行普通的 SELECT 语句时生成了一个 ReadView,之后 T2 向 hero 表中新插入一条记录 R1 并提交。由于 ReadView 并不能阻止 T1 执行 UPDATE 或 DELETE 语句来改动 R1,导致 R1 的 trx_id 变成了 T1,之后 T1 再次执行普通 SELECT 时就可以看到这条记录了,也即出现了幻读现象。
22.4.2. 锁定读的语句
# MySQL 规定的两种锁定读的语法格式
SELECT……LOCK IN SHARE MODE;
SELECT……FOR UPDATE;
# 由于在执行过程中需要首先定位到被改动的记录并加锁,因为也可以被认为是一种锁定读
UPDATE……
DELETE……
-
匹配模式(match mode)
在使用索引执行查询时,查询优化器首先会生成若干个扫描区间,针对每个区间使用单向链表进行扫描。如果被扫描的区间是一个单点区间,此时的匹配模式就是精确匹配,否则就不是精确匹配。
-
唯一性搜索(unique search)
如果在扫描某个区间前,就能确定该区间内最多只包含一条记录,那么就把这种情况称为唯一性搜索。当查询符合以下条件时,就可以认为是唯一性搜索了:
- 匹配模式为精确匹配
- 使用的是聚簇索引或唯一二级索引
- 唯一二级索引可为 NULL 时,搜索条则不能为索引列 IS NULL
- 索引中包含多个列时,每一个列都要被用到
分析语句加锁过程
描述得很啰嗦,不妨参考本地事务 | 凤凰架构 (icyfenix.cn)
22.4.3 半一致性读的语句
半一致性读(Semi-Consistent Read)是一种介于一致性读和锁定读之间的读取方式。当隔离级别不大于 READ COMMITED 且执行 UPDATE 语句时将使用半一致性读。
当 UPDATE 语句读取到已经被其他事务加了 X 锁的记录时,InnoDB 会将该记录的最新版本读出来,然后判断该版本是否与 UPDATE 语句中的搜索条件相匹配。如果不匹配,则不对该记录加锁,从而提高不大于 READ COMMITED 隔离级别的并发。
22.4.4 INSERT语句
- INSERT 语句一般情况下不需要在内存中生成锁结构,而是依靠隐式锁保护插入的记录
- 插入前需要定位被插入记录在 B+ 树中的位置,如果该位置已经有
gap 锁
,则需要加上插入单向锁
并进入等待状态
但有两种需要生成锁结构的特殊情况:
-
遇到重复键(duplicate key)
插入时,主键或唯一二级索引重复时,会对已经在 B+ 树中的那条记录加
next-key 锁
。 -
外键检查
22.5 查看事务加锁情况
22.5.1 使用information_schema数据库中的表获取锁信息
- INNODB_TRX:存储了 InnoDB 存储引擎当前正在执行的事务信息
- INNODB_LOCKS:记录了锁信息
- INNODB_LOCK_WAITS:表明每个阻塞的事务是因为获取不到哪个事务持有的锁而阻塞
22.5.2 使用 SHOW ENGINE INNODB STATUS 获取锁信息
SHOW ENGINE INNODB STATUS;
22.6 死锁
-
不同事务由于互相持有对方需要的锁而导致事务都无法继续执行的情况称为死锁
-
InnoDB 有一个死锁检测机制,当它检测到死锁发生时,会选择一个较小的事务进行回滚,并向客户端发送一条消息
ERROR 1212 (40001): Deadlock found when trying to get lock; try restarting transaction
-
使用
SHOW ENGINE INNODB STATUS
语句来查看最近发生的一次死锁信息
22.7 总结
- MVCC 和加锁是解决并发事务带来的一致性问题的两种方式
- 共享锁(S锁)、独占锁(X)锁,共享锁与共享锁兼容,独占锁不与其他锁兼容
- 事务利用 MVCC 进行的读取操作称为一致性读;在读取记录前加锁的读取操作称为锁定读(有特定写法)
- IS、IX是表级锁,以便快速判断表中的记录是否有被上锁
- InnoDB 的行级锁有多种类型
- InnoDB 的锁结构
- infomation_schema 库下有关于事务的锁的表
- 死锁