一,并发事务带来的问题

并发事务访问相同记录的情况大致可以划分为3种:

  1. 读&读:并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,不会引起什么问题,所以允许这种情况发生
  2. 写&写:并发事务相继对相同的记录进行改动
  3. 读&写/写&读:一个事务进行读取操作,另一个事务进行改动操作

1.写&写情况

一个事务修改了另一个事务未提交的数据就是脏写。所有的隔离级别都不允许这种情况。具体的处理方式其实就是通过加锁排队实现的。锁本质上是内存中的一个结构,在事务执行之前本身是没有锁的,当一个事务对记录进行改动的时候,首先会判断内存中有没有与这条记录关联的锁结构:没有就生成一个锁结构与记录关联。

假如现在事务T1要对一条记录进行写操作,就需要生成一个锁结构与之关联。

1.png

暂时先看锁结构里面的两个重要信息:

  1. trx信息:表示这个锁结构是与哪个事务关联
  2. is_waiting:表示当前事务是否在等待

当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。

在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先去看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构is_waiting属性值为true,表示当前事务需要等待,这个场景就是获取锁失败,或者加锁失败,或者没有成功的获取到锁:

2.png

事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。

3.png

2.读&写情况

读-写写-读情况:也就是一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读不可重复读幻读的问题。

幻读问题的产生是因为某个事务读了一个范围的记录,之后别的事务在该范围内插入了新记录,该事务再次读取该范围的记录时,可以读到新插入的记录,所以幻读问题准确的说并不是因为读取和写入一条相同记录而产生的。

怎么解决脏读不可重复读幻读这些问题呢?其实有两种可选的解决方案:

  1. 读操作利用多版本并发控制(MVCC),写操作进行加锁
  2. 读、写操作都采用加锁的方式。

2.1 方案一

MVCC就是通过生成一个ReadView,然后通过ReadView找到符合条件的记录版本(历史版本是由undo日志构建的),其实就像是在生成ReadView的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。

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

2.2 方案二

如果一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着操作和操作也像写-写操作那样排队执行。

脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。

不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。

幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,新插入的那些记录就像是幻影记录。采用加锁的方式解决幻读问题比较麻烦,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候加锁就有点尴尬,因为你并不知道给谁加锁。

采用MVCC方式的话,读-写操作彼此并不冲突,性能更高,采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行。

3.一致性读

事务利用MVCC进行的读取操作称之为一致性读,或者一致性非锁读。所有普通的SELECT语句(plain SELECT)在READ COMMITTEDREPEATABLE READ隔离级别下都算是一致性读,比方说:

  1. SELECT * FROM t;
  2. SELECT * FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2

一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。

4.锁定读

4.1 共享锁&独占锁

  • 共享锁,英文名: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提交。

所以说S锁S锁是兼容的,S锁X锁是不兼容的,X锁X锁也是不兼容的:

兼容性 X S
X 不兼容 不兼容
S 不兼容 兼容

4.2 锁定读的语句

在采用加锁方式解决脏读不可重复读幻读这些问题时,读取一条记录时需要获取一下该记录的S锁,其实这是不严谨的,有时候想在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此MySQL提出了两种比较特殊的SELECT语句格式:

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

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

    1. SELECT ... FOR UPDATE;

5.写操作

平常所用到的写操作无非是DELETEUPDATEINSERT这三种:

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

在一个事务中加的锁一般在事务提交或终止时才会释放,当然也有一些特殊情况。


二,多粒度锁

上面介绍的都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁S锁)和独占锁X锁):

  • 给表加S锁
    如果一个事务给表加了S锁,那么:
    • 别的事务可以继续获得该表的S锁
    • 别的事务可以继续获得该表中的某些记录的S锁
    • 别的事务不可以继续获得该表的X锁
    • 别的事务不可以继续获得该表中的某些记录的X锁
  • 给表加X锁
    如果一个事务给表加了X锁(意味着该事务要独占这个表),那么:
    • 别的事务不可以继续获得该表的S锁
    • 别的事务不可以继续获得该表中的某些记录的S锁
    • 别的事务不可以继续获得该表的X锁
    • 别的事务不可以继续获得该表中的某些记录的X锁

思考一下:

  • 如果我们想对表上S锁,首先需要确保表中的没有记录加了锁,如果有加锁记录,需要等到锁释放才可以对表上S锁
  • 如果想对表整体上X锁,首先需要确保表中没有加行锁,如果有加行锁的记录,需要等到全部行锁释放才可以对表上X锁

在上锁(表锁)时,怎么知道表已经被上锁(行锁)了呢?InnoDB提出了意向锁

  • 意向共享锁,英文名: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 不兼容 兼容 兼容 兼容

三,MySQL中的行锁&表锁

MySQL支持多种存储引擎,不同存储引擎对锁的支持也是不一样的。我们重点还是讨论InnoDB存储引擎中的锁。

1.InnoDB存储引擎中的锁

1.1 InnoDB中的表级锁

  • 表级别的S锁X锁
    在对某个表执行SELECTINSERTDELETEUPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。
    另外,在对某个表执行一些诸如ALTER TABLEDROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECTINSERTDELETEUPDATE的语句会发生阻塞,同理,某个事务中对某个表执行SELECTINSERTDELETEUPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用元数据锁(英文名:Metadata Locks,简称MDL)来实现,一般情况下也不会使用InnoDB存储引擎自己提供的表级别的S锁X锁
    其实这个InnoDB存储引擎提供的表级S锁或者X锁是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。不过我们还是可以手动获取一下的,比方说在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表tS锁或者X锁可以这么写:

    DDL语句执行时会隐式的提交当前会话中的事务,这主要是DDL语句的执行一般都会在若干个特殊事务中完成,在开启这些特殊事务前,需要将当前会话中的事务提交掉。

    • LOCK TABLES t READInnoDB存储引擎会对表t加表级别的S锁
    • LOCK TABLES t WRITEInnoDB存储引擎会对表t加表级别的X锁

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

  • 表级别的IS锁IX锁
    当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁IS锁IX锁的使命只是为了后续在加表级别的S锁X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
  • 表级别的AUTO-INC锁
    在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值,比方说我们有一个表:
    由于这个表的id字段声明了AUTO_INCREMENT,也就意味着在书写插入语句时不需要为其赋值,比方说这样:
    上边的插入语句并没有为id列显式赋值,所以系统会自动为它赋上递增的值,效果就是这样:
    系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:
    1. CREATE TABLE t (
    2. id INT NOT NULL AUTO_INCREMENT,
    3. c VARCHAR(100),
    4. PRIMARY KEY (id)
    5. ) Engine=InnoDB CHARSET=utf8;
    6. INSERT INTO t(c) VALUES('aa'), ('bb');

    mysql> SELECT * FROM t; +——+———+ | id | c | +——+———+ | 1 | aa | | 2 | bb | +——+———+ 2 rows in set (0.00 sec)

  1. 采用AUTO-INC锁,也就是在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。这样一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
    如果我们的插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用INSERT ... SELECTREPLACE ... SELECT或者LOAD DATA这种插入语句,一般是使用AUTO-INC锁为AUTO_INCREMENT修饰的列生成对应的值。

    这个AUTO-INC锁的作用范围只是单个插入语句,插入语句执行完成后,这个锁就被释放了,跟之前说的锁在事务结束时释放是不一样的。

  2. 采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。
    如果插入语句在执行前就可以确定具体要插入多少条记录,比方说我们上边的例子,在语句执行前就可以确定要插入2条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。这种方式可以避免锁定表,可以提升插入性能。

    InnoDB提供了一个称之为innodb_autoinc_lock_mode的系统变量来控制到底使用上述两种方式中的哪种来为AUTO_INCREMENT修饰的列进行赋值,当innodb_autoinc_lock_mode值为0时,一律采用AUTO-INC锁;当innodb_autoinc_lock_mode值为2时,一律采用轻量级锁;当innodb_autoinc_lock_mode值为1时,两种方式混着来(也就是在插入记录数量确定时采用轻量级锁,不确定时使用AUTO-INC锁)。不过当innodb_autoinc_lock_mode值为2时,可能会造成不同事务中的插入语句为AUTO_INCREMENT修饰的列生成的值是交叉的,在有主从复制的场景中是不安全的。

1.2 InnoDB中的行级锁

行锁,也称为记录锁,有很多种类型。即使对同一条记录加行锁,如果类型不同,起到的功效也是不同的。

  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)

为啥要在’刘备’、’曹操’、’孙权’前边加上’l’、’c’、’s’这几个字母呀?这个主要是因为我们采用utf8字符集,该字符集并没有对应的按照汉语拼音进行排序的比较规则,也就是说’刘备’、’曹操’、’孙权’这几个字符串的排序并不是按照它们汉语拼音进行排序的,在汉字前边加上了汉字对应的拼音的第一个字母,这样在排序时就是按照汉语拼音进行排序。

hero表中的聚簇索引的示意图画一下:

4.png

现在准备工作做完了,下边看看都有哪些常用的行锁类型

Record Locks(普通锁)

前边提到的记录锁就是这种类型,也就是仅仅把一条记录锁上,正经记录锁。官方的类型名称为:LOCK_REC_NOT_GAP。比方说我们把number值为8的那条记录加一个正经记录锁的示意图如下:

5.png

正经记录锁是有S锁X锁之分的,当一个事务获取了一条记录的S型正经记录锁后,其他事务也可以继续获取该记录的S型正经记录锁,但不可以继续获取X型正经记录锁;当一个事务获取了一条记录的X型正经记录锁后,其他事务既不可以继续获取该记录的S型正经记录锁,也不可以继续获取X型正经记录锁

Gap Locks(间歇锁)

MySQLREPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上正经记录锁。不InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,简称为gap锁

6.png

如图中为number值为8的记录加了gap锁,意味着不允许别的事务在number值为8的记录前边的间隙插入新记录,其实就是number列的值(3, 8)这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条number值为4的新记录,它定位到该条新记录的下一条记录的number值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,number列的值在区间(3, 8)中的新记录才可以被插入。

这个gap锁的提出仅仅是为了防止插入幻影记录而提出的,虽然有共享gap锁独占gap锁这样的说法,但是它们起到的作用都是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加正经记录锁或者继续加gap锁**gap锁**的作用仅仅是为了防止插入幻影记录的而已。

给一条记录加了gap锁只是不允许其他事务往这条记录前边的间隙插入新记录,那对于最后一条记录之后的间隙,也就是hero表中number值为20的记录之后的间隙该咋办呢?也就是说给哪条记录加gap锁才能阻止其他事务插入number值在(20, +∞)这个区间的新记录呢?这时候应该想起我们在前边唠叨数据页时介绍的两条伪记录了:

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

为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录,我们可以给索引中的最后一条记录,也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁,画个图就是这样:

7.png

这样就可以阻止其他事务插入number值在(20, +∞)这个区间的新记录。

Next-Key Locks(普通&间歇)

有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。比方说我们把number值为8的那条记录加一个next-key锁的示意图如下:

8.png

next-key锁的本质就是一个正经记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙

Insert Intention Locks(插入意向锁)

一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁next-key锁也包含gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们也可以称为插入意向锁

比方说我们把number值为8的那条记录加一个插入意向锁的示意图如下:

9png.png

比方说现在T1number值为8的记录加了一个gap锁,然后T2T3分别想向hero表中插入number值分别为45的两条记录,所以现在为number值为8的记录加的锁的示意图就如下所示:

10.png

从图中可以看到,由于T1持有gap锁,所以T2T3需要生成一个插入意向锁锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2T3之间也并不会相互阻塞,它们可以同时获取到number值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。

⑤隐式锁

一个事务在执行INSERT操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务:

  1. 立即使用SELECT ... LOCK IN SHARE MODE语句读取这条记录,也就是在要获取这条记录的S锁,或者使用SELECT ... FOR UPDATE语句读取这条记录,也就是要获取这条记录的X锁,该咋办?
    如果允许这种情况的发生,那么可能产生脏读问题。
  2. 立即修改这条记录,也就是要获取这条记录的X锁,该咋办?
    如果允许这种情况的发生,那么可能产生脏写问题。

这时候事务id`又要起作用了。我们把聚簇索引和二级索引中的记录分开看一下:

  1. 对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。
  2. 对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。

一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。

小贴士: 除了插入意向锁,在一些特殊情况下INSERT还会获取一些锁。

2.InnoDB锁的内存结构

对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比方说事务T1要执行下边这个语句:

  1. # 事务T1
  2. SELECT * FROM hero LOCK IN SHARE MODE;

很显然这条语句需要为hero表中的所有记录进行加锁,那是不是需要为每条记录都生成一个锁结构呢?其实理论上创建多个锁结构没问题,但是InnoDB决定在对不同记录加锁时,如果符合下边这些条件:

  • 在同一个事务中进行加锁操作
  • 被加锁的记录在同一个页面中
  • 加锁的类型是一样的
  • 等待状态是一样的

那么这些记录的锁就可以被放到一个锁结构中。InnoDB存储引擎中的锁结构

11.png

  1. 锁所在的事务信息
    不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记载着这个事务的信息。

    实际上这个所谓的锁所在的事务信息在内存结构中只是一个指针而已,所以不会占用多大内存空间,通过指针可以找到内存中关于该事务的更多信息,比方说事务id是什么。下边介绍的所谓的索引信息其实也是一个指针。

  2. 索引信息
    对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。

  3. 表锁/行锁信息
    表锁结构行锁结构在这个位置的内容是不同的:

    • 表锁:
      记载着这是对哪个表加的锁,还有其他的一些信息。
    • 行锁:
      记载了三个重要的信息:
      • Space ID:记录所在表空间。
      • Page Number:记录所在页号。
      • n_bits:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。

        并不是该页面中有多少记录,n_bits属性的值就是多少。为了让之后在页面中插入了新记录后也不至于重新分配锁结构,所以n_bits的值一般都比页面中记录条数多一些。

  4. type_mode
    这是一个32位的数,被分成了lock_modelock_typerec_lock_type三个部分,如图所示:
    12.png

    • 锁的模式(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锁

        在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。

    • 锁的类型(lock_type),占用第5~8位,不过现阶段只有第5位和第6位被使用:

      • LOCK_TABLE(十进制的16),也就是当第5个比特位置为1时,表示表级锁。
      • LOCK_REC(十进制的32),也就是当第6个比特位置为1时,表示行级锁。
    • 行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在lock_type的值为LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:
      • LOCK_ORDINARY(十进制的0):表示next-key锁
      • LOCK_GAP(十进制的512):也就是当第10个比特位置为1时,表示gap锁
      • LOCK_REC_NOT_GAP(十进制的1024):也就是当第11个比特位置为1时,表示正经记录锁
      • LOCK_INSERT_INTENTION(十进制的2048):也就是当第12个比特位置为1时,表示插入意向锁。
      • 其他的类型:还有一些不常用的类型我们就不多说了。

is_waiting属性也被放到了type_mode这个32位的数字中:

  1. - `LOCK_WAIT`(十进制的`256` :也就是当第9个比特位置为`1`时,表示`is_waiting``true`,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为`0`时,表示`is_waiting``false`,也就是当前事务获取锁成功。
  1. 其他信息
    为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。
  2. 一堆比特位
    如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的n_bits属性表示的。页面中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimumheap_no值为0Supremumheap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,不过为了编码方便,映射方式有点怪:
    13.png

比方说现在有两个事务T1T2想对hero表中的记录进行加锁,hero表中记录比较少,假设这些记录都存储在所在的表空间号为67,页号为3的页面上,那么如果:

  • T1想对number值为15的这条记录加S型正常记录锁,在对记录加行锁之前,需要先加表级别的IS锁,也就是会生成一个表级锁的内存结构,不过我们这里不关心表级锁, 接下来分析一下生成行锁结构的过程:

    • 事务T1要进行加锁,所以锁结构的锁所在事务信息指的就是T1
    • 直接对聚簇索引进行加锁,所以索引信息指的其实就是PRIMARY索引。
    • 由于是行锁,所以接下来需要记录的是三个重要信息:

      • Space ID:表空间号为67
      • Page Number:页号为3
      • n_bits:我们的hero表中现在只插入了5条用户记录,但是在初始分配比特位时会多分配一些,这主要是为了在之后新增记录时不用频繁分配比特位。其实计算n_bits有一个公式:
        其中n_recs指的是当前页面中一共有多少条记录(算上伪记录和在垃圾链表中的记录),比方说现在hero表一共有7条记录(5条用户记录和2条伪记录),所以n_recs的值就是7LOCK_PAGE_BITMAP_MARGIN是一个固定的值64,所以本次加锁的n_bits值就是:

        1. n_bits = (1 + ((n_recs + LOCK_PAGE_BITMAP_MARGIN) / 8)) * 8
        1. n_bits = (1 + ((7 + 64) / 8)) * 8 = 72
      • type_mode是由三部分组成的:

        • lock_mode,这是对记录加S锁,它的值为LOCK_S
        • lock_type,这是对记录进行加锁,也就是行锁,所以它的值为LOCK_REC
        • rec_lock_type,这是对记录加正经记录锁,也就是类型为LOCK_REC_NOT_GAP的锁。另外,由于当前没有其他事务对该记录加锁,所以应当获取到锁,也就是LOCK_WAIT代表的二进制位应该是0。

综上所属,此次加锁的type_mode的值应该是:

  1. type_mode = LOCK_S | LOCK_REC | LOCK_REC_NOT_GAP
  2. 也就是:
  3. type_mode = 2 | 32 | 1024 = 1058
  • 其他信息
  • 一堆比特位
    因为number值为15的记录heap_no值为5,根据上边列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第6个比特位被置为1,就像这样:
    14.png

综上所述,事务T1number值为5的记录加锁生成的锁结构就如下图所示:
15.png

  • T2想对number值为3815的这三条记录加X型的next-key锁,在对记录加行锁之前,需要先加表级别的IX锁,也就是会生成一个表级锁的内存结构,不过我们这里不关心表级锁。
    现在T2要为3条记录加锁,number38的两条记录由于没有其他事务加锁,所以可以成功获取这条记录的X型next-key锁,也就是生成的锁结构的is_waiting属性为false;但是number15的记录已经被T1加了S型正经记录锁T2是不能获取到该记录的X型next-key锁的,也就是生成的锁结构的is_waiting属性为true。因为等待状态不相同,所以这时候会生成两个锁结构。这两个锁结构中相同的属性如下:
    • 事务T2要进行加锁,所以锁结构的锁所在事务信息指的就是T2
    • 直接对聚簇索引进行加锁,所以索引信息指的其实就是PRIMARY索引。
    • 由于是行锁,所以接下来需要记录是三个重要信息:
      • Space ID:表空间号为67
      • Page Number:页号为3
      • n_bits:此属性生成策略同T1中一样,该属性的值为72
      • type_mode是由三部分组成的:
        • lock_mode,这是对记录加X锁,它的值为LOCK_X
        • lock_type,这是对记录进行加锁,也就是行锁,所以它的值为LOCK_REC
        • rec_lock_type,这是对记录加next-key锁,也就是类型为LOCK_ORDINARY的锁。
    • 其他信息

不同的属性如下:

  • number38的记录生成的锁结构

    • type_mode值。
      由于可以获取到锁,所以is_waiting属性为false,也就是LOCK_WAIT代表的二进制位被置0。所以:

      1. type_mode = LOCK_X | LOCK_REC |LOCK_ORDINARY
      2. 也就是
      3. type_mode = 3 | 32 | 0 = 35
    • 一堆比特位
      因为number值为38的记录heap_no值分别为34,根据上边列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第4、5个比特位被置为1,就像这样:

16.png综上所述,事务T2number值为38两条记录加锁生成的锁结构就如下图所示:
17.png

  • number15的记录生成的锁结构

    • type_mode值。
      由于不可以获取到锁,所以is_waiting属性为true,也就是LOCK_WAIT代表的二进制位被置1。所以:

      1. type_mode = LOCK_X | LOCK_REC |LOCK_ORDINARY | LOCK_WAIT
      2. 也就是
      3. type_mode = 3 | 32 | 0 | 256 = 291
    • 一堆比特位
      因为number值为15的记录heap_no值为5,根据上边列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第6个比特位被置为1,就像这样:

18.png综上所述,事务T2number值为15的记录加锁生成的锁结构就如下图所示:
19.png

综上所述,事务T1先获取number值为15S型正经记录锁,然后事务T2获取number值为3815X型正经记录锁共需要生成3个锁结构。

事务T2在对number值分别为3、8、15这三条记录加锁的情景中,是按照先对number值为3的记录加锁、再对number值为8的记录加锁,最后对number值为15的记录加锁的顺序进行的,如果我们一开始就对number值为15的记录加锁,那么该事务在为number值为15的记录生成一个锁结构后,直接就进入等待状态,就不为number值为3、8的两条记录生成锁结构了。在事务T1提交后会把在number值为15的记录上获取的锁释放掉,然后事务T2就可以获取该记录上的锁,这时再对number值为3、8的两条记录加锁时,就可以复用之前为number值为15的记录加锁时生成的锁结构了。


3.总结

未命名文件.jpg