1、MySQL中的并发安全问题

1.1 并发事务访问相同记录的场景

MySQL中并发事务访问数据库表中相同记录的情况大致可以分为以下3种:

  • 读-读情况;
  • 写-写情况;
  • 读-写或者写-读情况。

(1)读-读情况
即多个并发事务都读取相同的记录,这种情况不会产生事务并发访问的异常场景,允许读-读情况产生,InnoDB不需要做处理。
(2)写-写情况
即多个并发事务对相同的记录进行写操作。如果不加锁等机制,写-写情况会产生异常场景中最严重的脏写的问题,任何一种事务隔离级别都不会允许脏写的情况产生,InnoDB的解决方案就是对事务加锁,让多个写-写事务串行执行。
(3)读-写和写-读情况
即多个并发事务访问相同的记录,部分事务是读操作,另一部分事务是写操作。之前介绍事务那篇文章中讨论的脏读、不可重复读和幻读就是在读-写/写-读的场景下发生的。SQL标准规定不同事务隔离级别下可能发生的问题不一样:

  • READ UNCOMMITTED隔离级别下,脏读、不可重复读、幻读都可能发生;
  • READ COMMITTED隔离级别下,不可重复读、幻读可能发生,脏读不可以发生;
  • REPEATABLE READ隔离级别下,幻读可能发生,脏读和不可重复读不可以发生;
  • SERIALIZABLE隔离级别下,上述问题都不可以发生。

各个数据库厂商对SQL标准的支持都可能不一样,与SQL标准不同的一点就是,MySQLREPEATABLE READ隔离级别实际上就已经解决了幻读问题。

1.2 解决并发问题的两种思路

1.1小节中的读-读情况不需要处理,写-写情况需要加锁处理,那读-写或者写-读情况下该如何避免脏读、不可重复读和幻读的产生呢?有2种解决方案:
(1)读操作采用MVCC,写操作采用加锁的方式
MVCC机制下,事务在查询时会生成一个ReadView,查询语句只能读取到生成ReadView之间已经提交的事务所做的修改,在生成ReadView之前未提交的事务或者生成ReadView之后才提交的事务所做的更改是读取不到的。MVCC机制可以不通过加锁的机制实现读-写不冲突,性能上肯定优于加锁,MySQL中普通的SELECT语句在READ COMMITTEDREPEATABLE READ隔离级别下会使用到MVCC读取记录。

  • READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadViewReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;
  • REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题,ReadView的存在本身就避免了脏读。

(2)读、写操作均采用加锁的方式
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,且读取到记录后不允许其他记录再访问到该记录直到事务结束对该记录的所有操作。举例:在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也需要像写-写操作那样排队执行。这种情况下只能通过加锁的方式保证事务串行执行。

1.3 读事务的分类

MySQL中的读操作可以分为两类:

  • 一致性读Consistent Reads);
  • 锁定读Locking Reads)。

(1)一致性读
事务利用MVCC进行的读取操作称之为一致性读,或者快照读。所有普通的SELECT语句在READ COMMITTEDREPEATABLE READ事务隔离级别下都是一致性读,一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录进行修改,甚至加锁。
(2)锁定读
上面介绍的解决读-写和写-读场景下事务并发问题有两种思路,其中第二种读和写操作均采用加锁的方式,此时的读操作就是锁定读,即采用加锁的方式的读操作称为锁定读。下文介绍的读操作都是锁定读。锁定度也叫当前读。

2、锁的内存结构

当一个事务想对某条记录做改动时,会先在内存中查看有没有与这条记录相关联的锁结构,如果没有就会在内存中生成一个锁结构与之关联。比如事务T1要对这条记录做改动,就需要生成一个锁结构与之关联,如下图所示:
MySQL锁基础 - 图1
在锁结构中有很多信息,这里做了简化,将两个最重要的属性显示出来,更详细的锁结构介绍可以参考小册。

  • trx_id:生成这个锁结构的事务id
  • is_waiting:代表当前事务是否在等待,false代表当前事务正持有该锁,true代表当前事务处于阻塞状态。

上图中,当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为加锁成功,然后事务T1就可以继续执行操作了。
在事务T1提交之前,另一个事务T2也想对该记录做改动,那么事务T2先去在内存中查看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,事务T2也会生成一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示事务T2需要等待,我们把这个场景就称之为加锁失败,如下图所示:
MySQL锁基础 - 图2
在事务T1提交之后,就会把事务T1生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了,如下图所示:MySQL锁基础 - 图3

3、共享锁和独占锁

3.1 共享锁和独占锁的概念

MySQL中对锁可以有以下分类方式:

  • 共享锁:Shared Locks,也称为**S**锁。在锁定读事务中读取记录时,需要先获取该记录的S锁;
  • 独占锁:也成为排他锁,Exclusive Locks,也称为**X**锁。在写事务中修改记录时,需要先获取该记录的X锁。

假如事务T1首先获取了一条记录的S锁之后,事务T2接着也要访问这条记录:

  • 如果事务T2想要再获取该记录的S锁,那么事务T2也会获得该锁,也就意味着事务T1T2在该记录上同时持有S锁;
  • 如果事务T2想要再获取一个记录的X锁,那么此操作会被阻塞,直到事务T1提交之后将S锁释放掉;
  • 如果事务T1首先获取了一条记录的X锁之后,那么不管事务T2接着想获取该记录的S锁还是X锁都会被阻塞,直到事务T1提交。

    3.2 读操作的加锁语句

    锁定读操作需要加锁,有时候业务需要在读取该记录时获取该记录的S锁,有时需要获取该记录的X锁来禁止别的事务再读写这条记录,对记录加S锁和X锁的语句是不同的:
    (1)对记录加S锁
    sql语句如下:

    1. SELECT ... LOCK IN SHARE MODE;

    如果当前事务执行了该语句,那么它会为读取到的记录加S锁,且允许别的事务继续获取这些记录的S锁,但是不能获取这些记录的X锁(比方说使用下面介绍的SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。
    (2)对记录加X锁
    sql语句如下:

    1. SELECT ... FOR UPDATE;

    如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT … LOCK IN SHARE MODE语句来读取这些记录),也不允许获取这些记录的X锁(比如说使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。

    3.3 写操作中的加锁情况

    平常所用到的写操作无非是DELETEUPDATEINSERT这三种:
    (1)DELETE
    对一条记录做DELETE操作的过程其实是先在B+树索引中定位到这条记录的位置,然后获取一下这条记录的X锁,然后再执行delete mark操作。我们也可以把这个定位标删记录在B+树中位置的过程看成是一个获取X锁的锁定读。
    (2)UPDATE
    在对一条记录做UPDATE操作时分为三种情况:

  • 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读;

  • 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。
  • 如果修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETEINSERT的规则进行了。

头大,没细看…
(3)INSERT
一般情况下,新插入一条记录的操作并不加锁,而是由隐式锁来保证这条新插入的记录在本事务提交前不被别的事务访问到,隐式锁在行锁这一节介绍。

4、表锁

不同存储引擎对锁的支持也是不一样的,对于MyISAMMEMORYMERGE这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,InnoDB存储引擎既支持表锁,也支持行锁,同时也支持四种事务隔离级别。按照锁的粒度的不同划分,InnoDB存储引擎将锁分为表锁和行锁,表锁就是一个事务在表级别上加锁,锁住的是整张表,表锁的粒度比行锁的粒度要粗,表锁具体又划分为以下3种:

  • 表级别的SX锁;
  • 表级别的ISIX锁;
  • 表级别的AUTO-INC锁。

下面分别介绍这3种表锁。

4.1 表级别的SX

首先说一下表级别的S锁和X锁,二者之间的兼容关系:

  • 给表加S锁:
    • 别的事务可以继续获得该表的S锁;
    • 别的事务可以继续获得该表中的某些记录的S锁;
    • 别的事务不可以继续获得该表的X锁;
    • 别的事务不可以继续获得该表中的某些记录的X锁;
  • 给表加X锁:
    • 别的事务不可以继续获得该表的S锁;
    • 别的事务不可以继续获得该表中的某些记录的S锁;
    • 别的事务不可以继续获得该表的X锁;
    • 别的事务不可以继续获得该表中的某些记录的X锁。

在对某个表执行SELECTINSERTDELETEUPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的,我们还是可以手动获取一下的,比方说在系统变量autocommit=0innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表tS锁或者X锁可以这么写:

  1. # 对表t加表级别的S锁
  2. LOCK TABLES t READ
  3. # 对表t加表级别的X锁
  4. LCOK TABLES t WRITE

不过请尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB的厉害之处还是实现了更细粒度的行锁,关于表级别的S锁和X锁大家了解一下就罢了。

4.2 表级别的ISIX

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锁;

SXISIX锁之间的兼容关系如下表所示:

兼容性 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
  • 隐式锁。

准备工作:
建表语句如下:

  1. CREATE TABLE hero (
  2. number INT,
  3. name VARCHAR(100),
  4. country varchar(100),
  5. PRIMARY KEY (number)
  6. ) Engine=InnoDB CHARSET=utf8;

插入以下记录:

  1. INSERT INTO hero VALUES
  2. (1, 'l刘备', '蜀'),
  3. (3, 'z诸葛亮', '蜀'),
  4. (8, 'c曹操', '魏'),
  5. (15, 'x荀彧', '魏'),
  6. (20, 's孙权', '吴');

表中数据如下:

  1. mysql> SELECT * FROM hero;
  2. +--------+------------+---------+
  3. | number | name | country |
  4. +--------+------------+---------+
  5. | 1 | l刘备 | |
  6. | 3 | z诸葛亮 | |
  7. | 8 | c曹操 | |
  8. | 15 | x荀彧 | |
  9. | 20 | s孙权 | |
  10. +--------+------------+---------+
  11. 5 rows in set (0.01 sec)

hero表没有显式声明二级索引,因此仅有一棵聚簇索引的B+树,hero表的聚簇索引示意图如下:
image.png
需要注意的是:**InnoDB**存储引擎中的行锁是通过给索引上的索引项(也就是目录项记录)加锁来实现的。

5.1 Record Locks

MySQL官方的类锁类型名称为:LOCK_REC_NOT_GAP,小册作者给这种锁起名:正经记录锁,比方说我们把number值为8的那条记录加一个正经记录锁的示意图如下:
image.png
正经记录锁是有S锁和X锁之分的,小册作者起名为S型正经记录锁和X型正经记录锁,当一个事务获取了一条记录的S型正经记录锁后,其他事务也可以继续获取该记录的S型正经记录锁,但不可以继续获取X型正经记录锁;当一个事务获取了一条记录的X型正经记录锁后,其他事务既不可以继续获取该记录的S型正经记录锁,也不可以继续获取X型正经记录锁。其实这个Record Locks就是前面介绍的行锁。需要注意的是,**Record Locks**是针对某一条记录去加的行锁。

5.2 Gap Locks

官方的类型名称为:LOCK_GAP,我们也可以简称为gap锁。比方说我们把number值为8的那条记录加一个gap锁的示意图如下:
image.png
如上图所示,如果为number值为8的记录加了间隙锁,意味着不允许其他事务在number值为8的记录前边的间隙插入新纪录,即number列的值为(3, 8)这个区间内不允许立即插入新纪录。
思考这样一个问题:如何加间隙锁,才能阻止其他事务在number值的区间为(20, +∞)插入新的记录呢?之前我们介绍数据页时提到过:在数据页中会插入以下两条伪记录:

  • Infimum记录,表示该页面中最小的记录;
  • Supremum记录,表示该页面中最大的记录。

为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录,我们可以给索引中的最后一条记录,也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁,这样就可以阻止其他事务插入number值在(20, +∞)这个区间的新记录。如下图所示:
MySQL锁基础 - 图7
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锁的示意图如下:
MySQL锁基础 - 图8
next-key锁的本质就是一个Record Locks和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。

5.4 Insert Intention Locks

上面介绍了一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁,如果有的话,插入操作需要阻塞,直到拥有gap锁的那个事务提交。InnoDB存储引擎规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在处于阻塞状态。这种锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们也可以称为插入意向锁
比方说我们把number值为8的那条记录加一个插入意向锁的示意图如下:
MySQL锁基础 - 图9
比方说现在事务T1number值为8的记录加了一个gap锁,然后事务T2和事务T3分别向hero表中插入number值分别为45的两条记录,所以现在为number值为8的记录加的锁的示意图就如下所示:
MySQL锁基础 - 图10
从图中可以看到,由于T1持有gap锁,所以T2T3需要生成一个插入意向锁的锁结构并且处于阻塞状态。当T1提交后会把它获取到的锁都释放掉,这样T2T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2T3之间也并不会相互阻塞,它们可以同时获取到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、死锁

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。
举例:
4d0eeec7b136371b79248a0aed005a52.webp
上图中,事务A在等待事务B释放id=2的行锁,事务B在等待事务A释放id=1的行锁,事务A和事务B都在互相等待对方释放资源,进入了死锁的状态。
出现死锁后,有两种解决策略:

  1. 线程进入等待状态,直到尝试获取行锁超时,这个超时时间可以通过参数innodb_lock_wait_timeout 来设置,默认时间为50s。这个参数设置过大,在线服务往往不能接收(比如50s),如果设置过小,如果只是简单的锁等待又会误伤地释放锁,因此这种方案通常不会采用;
  2. 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务(持有最少行级排他锁的事务),让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑,默认就是打开。

    6.1 死锁检测

    InnoDB存储引擎默认是打开死锁检测机制的,即在发生死锁时,是可以快速发现并进行处理的。但死锁检测也有额外的负担,尤其是同一时刻有多个请求更新同一行时,对应的现象是 CPU 利用率很高,但是每秒却执行不了几个事务。
    每当一个事务被锁时,就会检查它所依赖的线程有没有被别的线程占有的锁锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度为O(n)的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。
    解决思路:

  3. 如果能识别出这个业务一定不会死锁,可以临时把死锁检测关掉;

  4. 在服务端控制并发度,将当前更改同一行数据的线程的数目控制小一些,可以通过数据库中间件或者MySQL源码。

    参考

    掘金小册
    EnjoyMoving:MySQL锁总结 zhuanlan.zhihu.com
    newer大侠:MySQL面试题-锁 zhuanlan.zhihu.com