playMySQL

lock

解决并发事务带来问题的两种基本方式

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

  1. 读-读情况:即并发事务相继读取相同的记录。

读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。

  1. 写-写情况:即并发事务相继对相同的记录做出改动。

我们前边说过,在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时, 需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的, 也就是说一开始是没有锁结构和记录进行关联的

当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。 比方说事务T1要对这条记录做改动,就需要生成一个锁结构与之关联:

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

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

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

  1. 读-写或写-读情况:也就是一个事务进行读取操作,另一个进行改动操作。

我们前边说过,这种情况下可能发生脏读、不可重复读、幻读的问题。

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

前一章講解了

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

我们说脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁, 那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。不可重复读的产生是因为当前事务先读取一条记录, 另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁, 那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。我们说幻读问题的产生是因为当前事务读取了一个范围的记录, 然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,我们把新插入的那些记录称之为幻影记录。 采用加锁的方式解决幻读问题就有那么一丢丢麻烦了,因为当前事务在第一次读取记录时那些幻影记录并不存在, 所以读取的时候加锁就有点尴尬 —— 因为你并不知道给谁加锁,

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

一致性读(Consistent Reads)

务利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,有的地方也称之为快照读。 所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读

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

锁定读(Locking Reads)

共享锁和独占锁

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

如果事务T1首先获取了一条记录的X锁之后,那么不管事务T2接着想获取该记录的S锁还是X锁都会被阻塞,直到事务T1提交。

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

锁定读的语句

有时候想在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此设计MySQL的大叔提出了两种比较特殊的SELECT语句格式:

    SELECT ... LOCK IN SHARE MODE;
    SELECT ... FOR UPDATE;

寫操作

对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取一下这条记录的X锁,然后再执行delete mark操作。 我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。

INSERT:

一般情况下,新插入一条记录的操作并不加锁,设计InnoDB的大叔通过一种称之为隐式锁的东东来保护这条新插入的记录在本事务提交前不被别的事务访问,

多粒度锁

我们前边提到的锁都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细

一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗 给表加的锁也可以分为共享锁(S锁)和独占锁(X锁):

我们在对教学楼整体上锁(表锁)时,怎么知道教学楼中有没有教室已经被上锁(行锁)了呢?依次检查每一间教室门口有没有上锁?那这效率也太慢了吧! 于是乎设计InnoDB的大叔们提出了一种称之为意向锁(英文名:Intention Locks)

视角回到教学楼和教室上来:

之后:

IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁, 以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。

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

InnoDB存储引擎中的锁

InnoDB存储引擎既支持表锁,也支持行锁

InnoDB中的表级锁

表级别的S锁、X锁

在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。

另外,在对某个表执行一些诸如ALTER TABLE、DROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如 SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞,同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时, 在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁 (英文名:Metadata Locks,简称MDL)东东来实现的,一般情况下也不会使用InnoDB存储引擎自己提供的表级别的S锁和X锁。

小贴士: 在事务简介的章节中我们说过,DDL语句执行时会隐式的提交当前会话中的事务, 这主要是DDL语句的执行一般都会在若干个特殊事务中完成,在开启这些特殊事务前,需要将当前会话中的事务提交掉。

其实这个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这样的手动锁表语句,它们并不会提供什么额外的保护, 只是会降低并发能力而已。InnoDB的厉害之处还是实现了更细粒度的行锁,关于表级别的S锁和X锁大家了解一下就罢了。

表级别的IS锁、IX锁

IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。

表级别的AUTO-INC锁

在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值, 系统会自动为它赋上递增的值,比方说我们有一个表:

CREATE TABLE t (
    id INT NOT NULL AUTO_INCREMENT,
    c VARCHAR(100),
    PRIMARY KEY (id)
) Engine=InnoDB CHARSET=utf8;

系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:

如果我们的插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量), 比方说使用INSERT … SELECT、REPLACE … SELECT或者LOAD DATA这种插入语句, 一般是使用AUTO-INC锁为AUTO_INCREMENT修饰的列生成对应的值。

小贴士: 需要注意一下的是,这个AUTO-INC锁的作用范围只是单个插入语句,插入语句执行完成后,这个锁就被释放了,
跟我们之前介绍的锁在事务结束时释放是不一样的。

如果我们的插入语句在执行前就可以确定具体要插入多少条记录,比方说我们上边举的关于表t的例子中, 在语句执行前就可以确定要插入2条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。 这种方式可以避免锁定表,可以提升插入性能。

mysql> show variables like 'innodb_autoinc_lock_mode';
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_autoinc_lock_mode | 1     |
+--------------------------+-------+
1 row in set (0.01 sec)
小贴士: 设计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修饰的列生成的值是交叉的,
在有主从复制的场景中是不安全的。

InnoDB中的行级锁

```shell script 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孙权’, ‘吴’); ```

看看都有哪些常用的行锁类型。

为了让大家彻底理解这个插入意向锁的功能,我们还是举个例子然后画个图表示一下。比方说现在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的插入意向锁,然后执行插入操作。 事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁(插入意向锁就是这么鸡肋)。

InnoDB锁的内存结构

我们前边说对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?

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

很显然这条语句需要为hero表中的所有记录进行加锁,那是不是需要为每条记录都生成一个锁结构呢?其实理论上创建多个锁结构没问题,反而更容易理解, 但是谁知道你在一个事务里想对多少记录加锁呢,如果一个事务要获取10000条记录的锁,要生成10000个这样的结构也太亏了吧! 所以设计InnoDB的大叔本着勤俭节约的传统美德,决定在对不同记录加锁时,如果符合下边这些条件:

锁结构示意图

我们看看这个结构里边的各种信息都是干嘛的:

为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表,

我们还是举个例子说明一下。

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

T1想对number值为15的这条记录加S型正常记录锁,

在对记录加行锁之前,需要先加表级别的IS锁,也就是会生成一个表级锁的内存结构, 不过我们这里不关心表级锁,所以就忽略掉了哈~ 接下来分析一下生成行锁结构的过程:

T2想对number值为3、8、15的这三条记录加X型的next-key锁

在对记录加行锁之前,需要先加表级别的IX锁,也就是会生成一个表级锁的内存结构,不过我们这里不关心表级锁,所以就忽略掉了哈~

现在T2要为3条记录加锁,number为3、8的两条记录由于没有其他事务加锁,所以可以成功获取这条记录的X型next-key锁, 也就是生成的锁结构的is_waiting属性为false; 但是number为15的记录已经被T1加了S型正经记录锁,T2是不能获取到该记录的X型next-key锁的, 也就是生成的锁结构的is_waiting属性为true。因为等待状态不相同,所以这时候会生成两个锁结构。

这两个锁结构中相同的属性如下:
不同的属性如下:

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