1、MySQL中的并发安全问题
1.1 并发事务访问相同记录的场景
MySQL中并发事务访问数据库表中相同记录的情况大致可以分为以下3种:
- 读-读情况;
- 写-写情况;
- 读-写或者写-读情况。
(1)读-读情况
即多个并发事务都读取相同的记录,这种情况不会产生事务并发访问的异常场景,允许读-读情况产生,InnoDB
不需要做处理。
(2)写-写情况
即多个并发事务对相同的记录进行写操作。如果不加锁等机制,写-写情况会产生异常场景中最严重的脏写的问题,任何一种事务隔离级别都不会允许脏写的情况产生,InnoDB
的解决方案就是对事务加锁,让多个写-写事务串行执行。
(3)读-写和写-读情况
即多个并发事务访问相同的记录,部分事务是读操作,另一部分事务是写操作。之前介绍事务那篇文章中讨论的脏读、不可重复读和幻读就是在读-写/写-读的场景下发生的。SQL
标准规定不同事务隔离级别下可能发生的问题不一样:
- 在
READ UNCOMMITTED
隔离级别下,脏读、不可重复读、幻读都可能发生; - 在
READ COMMITTED
隔离级别下,不可重复读、幻读可能发生,脏读不可以发生; - 在
REPEATABLE READ
隔离级别下,幻读可能发生,脏读和不可重复读不可以发生; - 在
SERIALIZABLE
隔离级别下,上述问题都不可以发生。
各个数据库厂商对SQL
标准的支持都可能不一样,与SQL
标准不同的一点就是,MySQL
在REPEATABLE READ
隔离级别实际上就已经解决了幻读问题。
1.2 解决并发问题的两种思路
1.1小节中的读-读情况不需要处理,写-写情况需要加锁处理,那读-写或者写-读情况下该如何避免脏读、不可重复读和幻读的产生呢?有2种解决方案:
(1)读操作采用MVCC,写操作采用加锁的方式MVCC
机制下,事务在查询时会生成一个ReadView
,查询语句只能读取到生成ReadView
之间已经提交的事务所做的修改,在生成ReadView
之前未提交的事务或者生成ReadView
之后才提交的事务所做的更改是读取不到的。MVCC
机制可以不通过加锁的机制实现读-写不冲突,性能上肯定优于加锁,MySQL
中普通的SELECT
语句在READ COMMITTED
和REPEATABLE READ
隔离级别下会使用到MVCC
读取记录。
READ COMMITTED
隔离级别下,一个事务在执行过程中每次执行SELECT
操作时都会生成一个ReadView
,ReadView
的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ
隔离级别下,一个事务在执行过程中只有第一次执行SELECT
操作才会生成一个ReadView
,之后的SELECT
操作都复用这个ReadView
,这样也就避免了不可重复读和幻读的问题,ReadView
的存在本身就避免了脏读。
(2)读、写操作均采用加锁的方式
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,且读取到记录后不允许其他记录再访问到该记录直到事务结束对该记录的所有操作。举例:在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也需要像写-写操作那样排队执行。这种情况下只能通过加锁的方式保证事务串行执行。
1.3 读事务的分类
MySQL中的读操作可以分为两类:
- 一致性读(
Consistent Reads
); - 锁定读(
Locking Reads
)。
(1)一致性读
事务利用MVCC
进行的读取操作称之为一致性读,或者快照读。所有普通的SELECT
语句在READ COMMITTED
、REPEATABLE READ
事务隔离级别下都是一致性读,一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录进行修改,甚至加锁。
(2)锁定读
上面介绍的解决读-写和写-读场景下事务并发问题有两种思路,其中第二种读和写操作均采用加锁的方式,此时的读操作就是锁定读,即采用加锁的方式的读操作称为锁定读。下文介绍的读操作都是锁定读。锁定度也叫当前读。
2、锁的内存结构
当一个事务想对某条记录做改动时,会先在内存中查看有没有与这条记录相关联的锁结构,如果没有就会在内存中生成一个锁结构与之关联。比如事务T1
要对这条记录做改动,就需要生成一个锁结构与之关联,如下图所示:
在锁结构中有很多信息,这里做了简化,将两个最重要的属性显示出来,更详细的锁结构介绍可以参考小册。
trx_id
:生成这个锁结构的事务id
;is_waiting
:代表当前事务是否在等待,false
代表当前事务正持有该锁,true
代表当前事务处于阻塞状态。
上图中,当事务T1
改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting
属性就是false
,我们把这个场景就称之为加锁成功,然后事务T1
就可以继续执行操作了。
在事务T1
提交之前,另一个事务T2
也想对该记录做改动,那么事务T2
先去在内存中查看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,事务T2
也会生成一个锁结构与这条记录关联,不过锁结构的is_waiting
属性值为true
,表示事务T2
需要等待,我们把这个场景就称之为加锁失败,如下图所示:
在事务T1
提交之后,就会把事务T1
生成的锁结构
释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2
还在等待获取锁,所以把事务T2
对应的锁结构的is_waiting
属性设置为false
,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2
就算获取到锁了,如下图所示:
3、共享锁和独占锁
3.1 共享锁和独占锁的概念
MySQL中对锁可以有以下分类方式:
- 共享锁:
Shared Locks
,也称为**S**
锁。在锁定读事务中读取记录时,需要先获取该记录的S
锁; - 独占锁:也成为排他锁,
Exclusive Locks
,也称为**X**
锁。在写事务中修改记录时,需要先获取该记录的X
锁。
假如事务T1
首先获取了一条记录的S
锁之后,事务T2
接着也要访问这条记录:
- 如果事务
T2
想要再获取该记录的S
锁,那么事务T2
也会获得该锁,也就意味着事务T1
和T2
在该记录上同时持有S
锁; - 如果事务
T2
想要再获取一个记录的X
锁,那么此操作会被阻塞,直到事务T1
提交之后将S
锁释放掉; 如果事务T1首先获取了一条记录的
X
锁之后,那么不管事务T2
接着想获取该记录的S
锁还是X
锁都会被阻塞,直到事务T1
提交。3.2 读操作的加锁语句
锁定读操作需要加锁,有时候业务需要在读取该记录时获取该记录的
S
锁,有时需要获取该记录的X
锁来禁止别的事务再读写这条记录,对记录加S
锁和X
锁的语句是不同的:
(1)对记录加S锁sql
语句如下:SELECT ... LOCK IN SHARE MODE;
如果当前事务执行了该语句,那么它会为读取到的记录加
S
锁,且允许别的事务继续获取这些记录的S
锁,但是不能获取这些记录的X
锁(比方说使用下面介绍的SELECT ... FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X
锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S
锁释放掉。
(2)对记录加X锁sql
语句如下:SELECT ... FOR UPDATE;
如果当前事务执行了该语句,那么它会为读取到的记录加
X
锁,这样既不允许别的事务获取这些记录的S
锁(比方说别的事务使用SELECT …LOCK IN SHARE MODE
语句来读取这些记录),也不允许获取这些记录的X
锁(比如说使用SELECT ... FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的S
锁或者X
锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X
锁释放掉。3.3 写操作中的加锁情况
平常所用到的写操作无非是
DELETE
、UPDATE
、INSERT
这三种:
(1)DELETE
对一条记录做DELETE
操作的过程其实是先在B+
树索引中定位到这条记录的位置,然后获取一下这条记录的X
锁,然后再执行delete mark
操作。我们也可以把这个定位标删记录在B+树中位置的过程看成是一个获取X
锁的锁定读。
(2)UPDATE
在对一条记录做UPDATE
操作时分为三种情况:如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在
B+
树中定位到这条记录的位置,然后再获取一下记录的X
锁,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在B+
树中位置的过程看成是一个获取X
锁的锁定读;- 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在
B+
树中定位到这条记录的位置,然后获取一下记录的X
锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+
树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT
操作提供的隐式锁进行保护。 - 如果修改了该记录的键值,则相当于在原记录上做
DELETE
操作之后再来一次INSERT
操作,加锁操作就需要按照DELETE
和INSERT
的规则进行了。
头大,没细看…
(3)INSERT
一般情况下,新插入一条记录的操作并不加锁,而是由隐式锁来保证这条新插入的记录在本事务提交前不被别的事务访问到,隐式锁在行锁这一节介绍。
4、表锁
不同存储引擎对锁的支持也是不一样的,对于MyISAM
、MEMORY
、MERGE
这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,InnoDB
存储引擎既支持表锁,也支持行锁,同时也支持四种事务隔离级别。按照锁的粒度的不同划分,InnoDB
存储引擎将锁分为表锁和行锁,表锁就是一个事务在表级别上加锁,锁住的是整张表,表锁的粒度比行锁的粒度要粗,表锁具体又划分为以下3种:
- 表级别的
S
锁X
锁; - 表级别的
IS
锁IX
锁; - 表级别的
AUTO-INC
锁。
4.1 表级别的S
锁X
锁
首先说一下表级别的S锁和X锁,二者之间的兼容关系:
- 给表加
S
锁:- 别的事务可以继续获得该表的
S
锁; - 别的事务可以继续获得该表中的某些记录的
S
锁; - 别的事务不可以继续获得该表的
X
锁; - 别的事务不可以继续获得该表中的某些记录的
X
锁;
- 别的事务可以继续获得该表的
- 给表加X锁:
- 别的事务不可以继续获得该表的
S
锁; - 别的事务不可以继续获得该表中的某些记录的
S
锁; - 别的事务不可以继续获得该表的
X
锁; - 别的事务不可以继续获得该表中的某些记录的
X
锁。
- 别的事务不可以继续获得该表的
在对某个表执行SELECT
、INSERT
、DELETE
、UPDATE
语句时,InnoDB
存储引擎是不会为这个表添加表级别的S
锁或者X
锁的,我们还是可以手动获取一下的,比方说在系统变量autocommit=0
,innodb_table_locks = 1
时,手动获取InnoDB
存储引擎提供的表t
的S
锁或者X
锁可以这么写:
# 对表t加表级别的S锁
LOCK TABLES t READ
# 对表t加表级别的X锁
LCOK TABLES t WRITE
不过请尽量避免在使用InnoDB
存储引擎的表上使用LOCK TABLES
这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB
的厉害之处还是实现了更细粒度的行锁,关于表级别的S
锁和X
锁大家了解一下就罢了。
4.2 表级别的IS
锁IX
锁
IS
锁和IX
锁首先是表级别的锁,还有个名称是意向锁(Intention Locks
),如下:
- 意向共享锁(
Intention Shared Lock
):简称**IS**
锁,当事务准备在某条记录上加S
锁时,需要先在表级别加一个IS
锁; - 意向独占锁(
Intention Exclusive Lock
):简称**IX**
锁,当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。
需要注意:
- 如果一个事务想对某张表加表级别的
S
锁时,会先检查一下该表是否有IX
锁,如果有需要等待该表的IX锁对应的事务释放掉IX
锁后,该事务才能对表加表级别的S
锁; - 如果一个事务想对某张表加表级别的
X
锁时,会先检查一下该表是否有IS
锁或者IX
锁,如果有需要等待该表的IX
锁或者IS
锁对应的事务释放掉意向锁后,该事务才能对表加表级别的X
锁;
S
、X
、IS
、IX
锁之间的兼容关系如下表所示:
兼容性 | X | IX | S | IS |
---|---|---|---|---|
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
IX | 不兼容 | 兼容 | 不兼容 | 兼容 |
S | 不兼容 | 不兼容 | 兼容 | 兼容 |
IS | 不兼容 | 兼容 | 兼容 | 兼容 |
当我们在对使用InnoDB
存储引擎的表的某些记录加S
锁之前,就需要先在表级别加一个IS
锁,当我们在对使用InnoDB
存储引擎的表的某些记录加X
锁之前,就需要先在表级别加一个IX
锁。IS
锁和IX
锁的使命只是为了后续在加表级别的S
锁和X
锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
4.3 表级别的AUTO-INC
锁
在使用MySQL
过程中,我们可以为表的某个列添加AUTO_INCREMENT
属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值,多个事务插入记录时,如何保证记录AUTO_INCREMENT
属性的列的自增性呢?就是采用的AUTO-INC
锁,一个事务在持有AUTO-INC
锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
5、行锁
行锁也称为记录锁,InnoDB
存储引擎中行锁又分为:
Record Locks
;Gap Locks
;Next-Key Locks
;Insert Intention Locks
;- 隐式锁。
准备工作:
建表语句如下:
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
插入以下记录:
INSERT INTO hero VALUES
(1, 'l刘备', '蜀'),
(3, 'z诸葛亮', '蜀'),
(8, 'c曹操', '魏'),
(15, 'x荀彧', '魏'),
(20, 's孙权', '吴');
表中数据如下:
mysql> SELECT * FROM hero;
+--------+------------+---------+
| number | name | country |
+--------+------------+---------+
| 1 | l刘备 | 蜀 |
| 3 | z诸葛亮 | 蜀 |
| 8 | c曹操 | 魏 |
| 15 | x荀彧 | 魏 |
| 20 | s孙权 | 吴 |
+--------+------------+---------+
5 rows in set (0.01 sec)
hero
表没有显式声明二级索引,因此仅有一棵聚簇索引的B+
树,hero
表的聚簇索引示意图如下:
需要注意的是:**InnoDB**
存储引擎中的行锁是通过给索引上的索引项(也就是目录项记录)加锁来实现的。
5.1 Record Locks
MySQL
官方的类锁类型名称为:LOCK_REC_NOT_GAP
,小册作者给这种锁起名:正经记录锁,比方说我们把number
值为8
的那条记录加一个正经记录锁的示意图如下:
正经记录锁是有S
锁和X
锁之分的,小册作者起名为S
型正经记录锁和X
型正经记录锁,当一个事务获取了一条记录的S型正经记录锁后,其他事务也可以继续获取该记录的S型正经记录锁,但不可以继续获取X型正经记录锁;当一个事务获取了一条记录的X型正经记录锁后,其他事务既不可以继续获取该记录的S型正经记录锁,也不可以继续获取X型正经记录锁。其实这个Record Locks
就是前面介绍的行锁。需要注意的是,**Record Locks**
是针对某一条记录去加的行锁。
5.2 Gap Locks
官方的类型名称为:LOCK_GAP
,我们也可以简称为gap
锁。比方说我们把number
值为8
的那条记录加一个gap
锁的示意图如下:
如上图所示,如果为number
值为8
的记录加了间隙锁,意味着不允许其他事务在number
值为8
的记录前边的间隙插入新纪录,即number
列的值为(3, 8)
这个区间内不允许立即插入新纪录。
思考这样一个问题:如何加间隙锁,才能阻止其他事务在number
值的区间为(20, +∞)
插入新的记录呢?之前我们介绍数据页时提到过:在数据页中会插入以下两条伪记录:
Infimum
记录,表示该页面中最小的记录;Supremum
记录,表示该页面中最大的记录。
为了实现阻止其他事务插入number
值在(20, +∞)
这个区间的新记录,我们可以给索引中的最后一条记录,也就是number
值为20
的那条记录所在页面的Supremum
记录加上一个gap锁
,这样就可以阻止其他事务插入number
值在(20, +∞)
这个区间的新记录。如下图所示:gap锁
的提出仅仅是为了防止插入幻影记录而提出的,如果你对一条记录加了gap锁
,并不会限制其他事务对这条记录加Record Locks
或者继续加gap Locks
。间隙锁也是在具体某条记录上加锁,作用范围是这条记录与上一条记录之间的间隙,不包括这条记录。
5.3 Next-Key Locks
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,即获取Records Lock
+ Gap Locks
的锁的效果,InnoDB
提供了一种称之为Next-Key Locks
的锁,官方的类型名称为:LOCK_ORDINARY
,我们也可以简称为next-key锁
。比方说我们把number
值为8
的那条记录加一个next-key锁
的示意图如下:next-key锁
的本质就是一个Record Locks
和一个gap锁
的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。
5.4 Insert Intention Locks
上面介绍了一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁
,如果有的话,插入操作需要阻塞,直到拥有gap
锁的那个事务提交。InnoDB
存储引擎规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在处于阻塞状态。这种锁命名为Insert Intention Locks
,官方的类型名称为:LOCK_INSERT_INTENTION
,我们也可以称为插入意向锁。
比方说我们把number
值为8
的那条记录加一个插入意向锁的示意图如下:
比方说现在事务T1
为number
值为8
的记录加了一个gap
锁,然后事务T2
和事务T3
分别向hero
表中插入number
值分别为4
、5
的两条记录,所以现在为number
值为8
的记录加的锁的示意图就如下所示:
从图中可以看到,由于T1
持有gap锁
,所以T2
和T3
需要生成一个插入意向锁的锁结构并且处于阻塞状态。当T1
提交后会把它获取到的锁都释放掉,这样T2
和T3
就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting
属性改为false
),T2
和T3
之间也并不会相互阻塞,它们可以同时获取到number
值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁(插入意向锁就是这么鸡肋)。
5.5 隐式锁
INSERT
操作是不加锁的,如果一个事务先插入了一条记录并且没有提交,此时另一个事务:
- 立即使用
SELECT ... LOCK IN SHARE MODE
语句读取这条记录,也就是在要获取这条记录的S锁
,或者使用SELECT ... FOR UPDATE
语句读取这条记录,也就是要获取这条记录的X锁
,此时会产生脏读问题; - 立即修改这条记录,也就是要获取这条记录的
X锁
,此时会产生脏写问题。
此时事务id会对插入的记录加一个隐式锁,具体如下:
- 对于聚簇索引记录来说,有一个
trx_id
隐藏列,该隐藏列记录着最后改动该记录的事务id
。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id
隐藏列代表的的就是当前事务的id
,如果其他事务此时想对该记录添加S锁
或者X锁
时,首先会看一下该记录的trx_id
隐藏列代表的事务是否是当前的活跃事务(未提交事务),如果是的话,那么就帮助当前事务创建一个X锁
(也就是为当前事务创建一个锁结构,is_waiting
属性是false
),然后本事务进入等待状态(也就是为自己也创建一个锁结构,is_waiting
属性是true
); - 对于二级索引记录来说,本身并没有
trx_id
隐藏列,但是在二级索引页面的Page Header
部分有一个PAGE_MAX_TRX_ID
属性,该属性代表对该页面做改动的最大的事务id
,如果PAGE_MAX_TRX_ID
属性值小于当前最小的活跃事务id
,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复聚簇索引的做法。
总结一下:一个事务对新插入的记录可以不显式的加锁(但是会生成一个锁结构),但是由于事务id
的存在,相当于加了一个隐式锁。其他事务在对这条记录加S锁
或者X锁
时,由于隐式锁
的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。
6、死锁
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。
举例:
上图中,事务A在等待事务B释放id=2的行锁,事务B在等待事务A释放id=1的行锁,事务A和事务B都在互相等待对方释放资源,进入了死锁的状态。
出现死锁后,有两种解决策略:
- 线程进入等待状态,直到尝试获取行锁超时,这个超时时间可以通过参数innodb_lock_wait_timeout 来设置,默认时间为50s。这个参数设置过大,在线服务往往不能接收(比如50s),如果设置过小,如果只是简单的锁等待又会误伤地释放锁,因此这种方案通常不会采用;
发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务(持有最少行级排他锁的事务),让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑,默认就是打开。
6.1 死锁检测
InnoDB存储引擎默认是打开死锁检测机制的,即在发生死锁时,是可以快速发现并进行处理的。但死锁检测也有额外的负担,尤其是同一时刻有多个请求更新同一行时,对应的现象是 CPU 利用率很高,但是每秒却执行不了几个事务。
每当一个事务被锁时,就会检查它所依赖的线程有没有被别的线程占有的锁锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度为O(n)的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。
解决思路:如果能识别出这个业务一定不会死锁,可以临时把死锁检测关掉;
- 在服务端控制并发度,将当前更改同一行数据的线程的数目控制小一些,可以通过数据库中间件或者MySQL源码。
参考
掘金小册
EnjoyMoving:MySQL锁总结 zhuanlan.zhihu.com
newer大侠:MySQL面试题-锁 zhuanlan.zhihu.com