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

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

解决并发问题的两种思路(解决脏读、不可重复读、幻读这些问题)

(1)方案一:读操作采用MVCC,写操作采用加锁的方式
MySQL中普通的SELECT语句在READ COMMITTEDREPEATABLE READ隔离级别下会使用到MVCC读取记录。
MVCC机制可以不通过加锁的机制实现读-写不冲突,性能上肯定优于加锁。

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

(2)方案二:读、写操作均采用加锁的方式
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,且读取到记录后不允许其他记录再访问到该记录直到事务结束对该记录的所有操作。

举例:在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也需要像写-写操作那样排队执行。这种情况下只能通过加锁的方式保证事务串行执行。

按锁的粒度分成两大类:

  • 表锁

    开销小,加锁快;不会出现死锁;锁定力度大,发生锁冲突概率高,并发度最低

获取 InnoDB 表锁争用情况:
SHOW STATUS LIKE ‘Table_locks%’;

Table_locks_waited 的值比较高,则说明存在着较严重的表级锁争用情况。

  • 行锁

    开销大,加锁慢;会出现死锁;锁定粒度小,发生锁冲突的概率低,并发度高

获取 InnoDB 行锁争用情况:
show status like ‘innodb_row_lock%’;

注意:
(1)InnoDB支持行锁和表锁,而 MyISAM 和 MEMORY 仅支持表锁;
(2)InnoDB只有通过索引条件检索数据才使用行级锁,否则 InnoDB将使用表锁(也就是说,InnoDB的行锁是基于索引的!)
(3)InnoDB表级别的操作,例如添加数据表字段、更改数据表字段类型等都会导致表锁,造成阻塞;

image.png

读事务的分类

(1)一致性读(Consistent Reads)
事务利用MVCC进行的读取操作称之为一致性读,或者快照读。所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读,一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。甚至加锁。

比如: SELECT FROM t; SELECT FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2

(2)锁定读(Locking Reads)
读和写操作均采用加锁的方式,此时的读操作就是锁定读,即采用加锁的方式的读操作称为锁定读。

共享锁和独占锁概念(行锁/表锁)

  • 共享锁(S锁)(读锁)(Shared Locks)

    • 事务要读取一条记录时,需要先获取该记录的S锁
    • 读锁是共享的,多个事务可以同时获得同一数据集的读锁。阻止其他事务获得相同数据集的X锁,也就是此时数据集不允许其他事务修改。
  • 独占锁(X锁)(写锁)(Exclusive Locks)(排他锁):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。

    • 事务要改动一条记录时,需要先获取该记录的X锁。
    • 写锁是排他的,允许获得写锁的事务更新数据。阻止其他事务取得相同数据集的X锁和X锁。

在行锁层面,我们仅需要关注下 共享锁排它锁,也分为显式(手动加锁)和隐式(数据库自动加锁)的锁:
(1)显式的读锁如 select … lock in share mode ;
(2)显式的写锁如, select … for update ;
(3)隐式的锁,对于 UPDATE、DELETE、INSERT语句,InnoDB会自动给涉及数据集加排他锁(X)

锁定读操作的加锁语句

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

  1. SELECT ... LOCK IN SHARE MODE;

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

SELECT ... FOR UPDATE;

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

写操作的加锁情况

平常所用到的写操作无非是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操作,加锁操作就需要按照DELETEINSERT的规则进行了。

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

Mysql Locks InnoDB存储引擎中的锁.png

表锁

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

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

    表级别的S锁、X锁

    表级别的S锁和X锁对所有存储引擎来说,二者之间的兼容关系:

  • 给表加S锁:

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

在InnoDB存储引擎下:
在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁。InnoDB存储引擎的表级S锁或者X锁只会在一些特殊情况下,比方说崩溃恢复过程中用到。
手动获取:比如在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t的S锁或者X锁:

  • LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁。
  • LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁。

    尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力。

意向锁(Intention Locks)

为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁:

  • 意向共享锁(IS锁):事务准备在某条记录上加S锁前,需要先在表级别加一个IS锁(必须先取得该表的IS锁)。

    • S锁->IS锁。
  • 意向独占锁(IX锁):事务准备在某条记录上加X锁前,需要先在表级别加一个IX锁(必须先取得该表的IX锁)。

    • X锁->IX锁。

意向锁也是数据库隐式帮我们做了,不需要程序员操心!

需要注意:

  • 如果一个事务想对某张表加表级别的S锁时,会先检查一下该表是否有IX锁,如果有需要等待该表的IX锁对应的事务释放掉IX锁后,该事务才能对表加表级别的S锁;
  • 如果一个事务想对某张表加表级别的X锁时,会先检查一下该表是否有IS锁或者IX锁,如果有需要等待该表的IX锁或者IS锁对应的事务释放掉意向锁后,该事务才能对表加表级别的X锁;

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

兼容性 X IX S IS
X 不兼容 不兼容 不兼容 不兼容
IX 不兼容 兼容 不兼容 兼容
S 不兼容 不兼容 兼容 兼容
IS 不兼容 兼容 兼容 兼容

总结一下:
IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。
当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,就需要先在表级别加一个IX锁。IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。

表级别的AUTO-INC

在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值,多个事务插入记录时,如何保证记录AUTO_INCREMENT属性的列的自增性呢?就是采用的AUTO-INC锁,一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。

行锁

行锁也称为记录锁,InnoDB存储引擎中行锁又分为:

  • Record Locks
  • Gap Locks
  • Next-Key Locks
  • Insert Intention Locks
  • 隐式锁。

注意:**InnoDB**存储引擎中的行锁是通过给索引上的索引项(也就是目录项记录)加锁来实现的。

普通行锁(Record Locks)

官方的类型名称为:LOCK_REC_NOT_GAP。给一条记录加锁,行级别的S锁、X锁就是这个普通行锁的两种。

  • 一个事务获取了一条记录的S记录锁后,他事务也可以继续获取该记录的S记录锁,但不可以继续获取X记录锁;
  • 一个事务获取了一条记录的X记录锁后,其他事务既不可以继续获取该记录的S记录锁,也不可以继续获取X记录锁;

image.png

间隙锁 Gap Locks(gap锁)

官方的类型名称为:LOCK_GAP。也叫gap锁。
强调:gap锁的作用仅仅是为了防止插入幻影记录的而已。解决在REPEATABLE READ隔离级别下是可以解决幻读问题。
image.png
如上图所示,为number值为8的记录加了gap锁,意味着不允许别的事务在number值为8的记录前边的间隙插入新记录,即number列的值(3, 8)这个区间的新记录是不允许立即插入的。
在number值在(20, +∞)这个区间下加gap锁的方式:在数据页中会插入两条伪记录:

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

为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录,可以给索引中的最后一条记录,也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁,如下图所示:
image.png
gap锁的提出仅仅是为了防止插入幻影记录,如果对一条记录加了gap锁,并不会限制其他事务对这条记录加Record Locks或者继续加gap Locks间隙锁也是在具体某条记录上加锁,作用范围是这条记录与上一条记录之间的间隙,不包括这条记录。

增强间隙锁 Next-Key Locks(next-key锁)

官方的类型名称为:LOCK_ORDINARY。也叫next-key锁。
既锁住某条记录,又阻止其他事务在该记录前边的间隙插入新记录,即获取Records Lock + Gap Locks的锁的效果。
image.png
next-key锁的本质就是一个普通行锁一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。

间隙锁的介绍

当我们用范围条件检索数据而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合范围条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”。InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁。

间隙锁只会在 REPEATABLE READ 和 Serializable隔离级别下使用

InnoDB使用间隙锁的目的有两个:

  • 防止幻读,以满足相关隔离级别的要求(REPEATABLE READ隔离级别下再通过GAP锁即可避免了幻读)

  • 满足恢复和复制的需要

    • MySQL的恢复机制要求:在一个事务未提交前,其他并发事务不能插入满足其锁定条件的任何记录,也就是不允许出现幻读

      显、隐式间隙锁

  • 显式:查询范围时显式添加 for update,来实现间隙锁:

    select * from user where product_id > 666 for update;
    
  • 隐式:则是当我们执行 update、delete 等语句的时候,会自动添加间隙锁;

另外间隙锁在不同的索引上,表现形式也是不一样的:

(1)对主键或唯一索引,where 条件全部精准命中( = 或者 in),这种场景本身就不会出现幻读(因为不可能插入相同的数据了),所以只会加读锁;

(2)对于没有索引的列,如果使用间隙锁的使用,会导致全表被锁定

(3)非唯一索引列,如果 where 条件 部分命令(>、<、like)或者全未命中,则会加附近Gap间隙锁;例如某表数据如下,非唯一索引 2,6,9,9,11,15。如果语句要操作非唯一索引列9的数据,gap锁会锁定的列为(6,11],该区间内无法插入数据;

区间为左开右闭,即6可以插入,11不可以插入

插入意向锁 Insert Intention Locks

官方的类型名称为:LOCK_INSERT_INTENTION,也叫插入意向锁。
一个事务在插入一条数据时需要判断插入位置是否被其他事务加了间隙锁,如果有的话,当前插入操作需要等待,直到拥有gap锁的那个事务提交。InnoDB存储引擎规定事务在等待时也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在处于阻塞状态。
image.png
比如事务T1为number值为8的记录加了一个gap锁,然后T2和T3分别想向表中插入number值分别为4、5的两条记录,所以现在为number值为8的记录加的锁的示意图就如下所示:
image.png
从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时获取到number值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。

隐式锁

一般情况下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锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。

四、乐观锁和悲观锁

悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度(悲观),因此,在整个数据处理过程中,将数据处于锁定状态。

相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。

乐观锁的实现

乐观锁的实现,通常是通过版本号的形式来进行实现的,例如如下数据表格

id money version
1 10 1
2 10 1
步骤 命令行环境一 命令行环境二
1 > 开启事务

begin; | > 开启事务

begin; | | 2 | > 根据版本号去更新数据

update test set money = 8 version = version + 1 where id = 2 and version = 1; | | | 3 | | > 根据版本号去更新数据

update test set money = 8 version = version + 1 where id = 2 and version = 1; |

如上表,当步骤2执行完后,此时的version为2,那么步骤三就会执行失败;

悲观锁的实现

悲观锁的实现,可以利用MySQL的for update来实现,第一个请求过来,利用for update加锁之后,其他的请求不能利用for update 再次加锁了,这里需要注意的是,for update 很容易出现全表被锁的情况,建议for update 加在主键或唯一索引上;

除了for update 外,还可以利用外部锁实现悲观锁,如Redis的setnx,Redis的setnx锁,要注意有效期的设定,详情可以看这篇文章《5种分布式锁》

五、死锁

死锁产生的条件,互相持有资源不释放,还有环形等待。这里举一个例子来进行说明:

假设有转账业务,要将id = 2 的数据,money字段增加20元,而 id = 3 的 money 字段减少20元;假设如下的两个运行顺序:

步骤 命令行环境一 命令行环境二
1 > 开启事务

begin; | > 开启事务

begin; | | 2 | > id为2的增加20元

update table set money = money + 20 where id = 2; | > id为3的减少20元

update table set money = money - 20 where id = 3; | | 3 | > id为3的减少20元

update table set money = money - 20 where id = 3; | > id为2的增加20元

update table set money = money + 20 where id = 2; |

可以看到,在执行第3条语句的时候,session1,等待session2释放id = 3的锁,而session2 等待session1释放id = 2的锁,这种相互等待对方释放锁的情况,就形成了死锁;

当然现有的MySQL会组织死锁的发生,当检测可能出现死锁的时候,就不再执行;例如步骤3的session2执行的时候会报错:

image.png
图6:MySQL检测到可能产生死锁,就不再执行

结论:
死锁的发生与否,并不在于事务中有多少条SQL语句,死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。而使用本文上面提到的,分析MySQL每条SQL语句的加锁规则,分析出每条语句的加锁顺序,然后检查多个并发SQL间是否存在以相反的顺序加锁的情况,就可以分析出各种潜在的死锁情况,也可以分析出线上死锁发生的原因。

六、死锁的解决方式

一般来说MySQL通过回滚帮我们解决了不少死锁的问题了,但死锁是无法完全避免的,可以通过以下的经验参考,来尽可能少遇到死锁:

(1)以固定的顺序访问表和行。比如对两个job批量更新的情形,简单方法是对id列表先排序,后执行,这样就避免了交叉等待锁的情形;将两个事务的sql顺序调整为一致,也能避免死锁。

(2)大事务拆小。大事务更倾向于死锁,如果业务允许,将大事务拆小。

(3)在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率。

(4)降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。

(5)为表添加合理的索引。可以看到如果不走索引将会为表的每一行记录添加上锁,死锁的概率大大增大。

参考:
掘金小册:MySQL 是怎样运行的:从根儿上理解 MySQL
后端开发宝典-MySQL锁详解
EnjoyMoving:MySQL锁总结 zhuanlan.zhihu.com
newer大侠:MySQL面试题-锁 zhuanlan.zhihu.com