10.1 解决并发事务问题的两种基本方式
并发事务访问相同记录的情况大致可以划分为3种:
- 读-读 情况:即并发事务相继读取相同的记录。
读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。
- 写-写 情况:即并发事务相继对相同的记录做出改动。
在这种情况下会发生 脏写 的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过 锁 来实现的。这个所谓的 锁 其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有 锁结构 和记录进行关联的。
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的 锁结构 ,当没有的时候就会在内存中生成一个 锁结构 与之关联。比方说事务 T1 要对这条记录做改动,就需要生成一个 锁结构 与之关联:
- trx信息 :代表这个锁结构是哪个事务生成的。
- is_waiting :代表当前事务是否在等待。

如图所示,当事务 T1 改动了这条记录后,就生成了一个 锁结构 与该记录关联,因为之前没有别的事务为这条记录加锁,所以 is_waiting 属性就是 false ,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
在事务 T1 提交之前,另一个事务 T2 也想对该记录做改动,那么先去看看有没有 锁结构 与这条记录关联,发现有一个 锁结构 与之关联后,然后也生成了一个 锁结构 与这条记录关联,不过 锁结构 的 is_waiting 属性值为 true ,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者没有成功获取到锁。
在事务 T1 提交之后,就会把该事务生成的 锁结构 释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务 T2 还在等待获取锁,所以把事务 T2 对应的锁结构的 is_waiting 属性设置为 false ,然后把该事务对应的线程唤醒,让它继续执行,此时事务 T2 就算获取到锁了。
- 读-写 或 写-读 情况:也就是一个事务进行读取操作,另一个进行改动操作
这种情况下可能发生 脏读 、 不可重复读 、 幻读 的问题。
1. **方案一:读操作利用多版本并发控制( MVCC ),写操作进行 加锁 。**
所谓的 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. **方案二:读、写操作都采用 加锁 的方式**
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行 加锁 操作,这样也就意味着 读 操作和 写 操作也像 写-写 操作那样排队执行。
采用 MVCC 方式的话, 读-写 操作彼此并不冲突,性能更高,采用 加锁 方式的话, 读-写 操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用 MVCC 来解决 读-写 操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用 加锁 的方式执行,那也是没有办法的。
10.2 一致性读、当前读
10.2.1 一致性读(Consistent Reads)
事务利用 MVCC 进行的读取操作称之为 一致性读 ,或者 一致性无锁读 ,有的地方也称之为 快照读 。InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。所有普通的 SELECT 语句( plain SELECT )在 READ COMMITTED 、 REPEATABLE READ 隔离级别下都算是 一致性读 :
SELECT FROM t;
SELECT FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2
一致性读 并不会对表中的任何记录做 加锁 操作,其他事务可以自由的对表中的记录做改动。
- 从图中可以看到,第一个有效更新是事务C,把数据从(1,1)改成了(1,2)。这时候,这个数据的最新版本的row trx_id是102,而90这个版本已经成为了历史版本。
- 第二个有效更新是事务B,把数据从(1,2)改成了(1,3)。这时候,这个数据的最新版本(即row trx_id)是101,而102又成为了历史版本。
- 你可能注意到了,在事务A查询的时候,其实事务B还没有提交,但是它生成的(1,3)这个版本已经变成当前版本了。但这个版本对事务A必须是不可见的,否则就变成脏读了。
好,现在事务A要来读数据了,它的视图数组是[99,100]。当然了,读数据都是从当前版本读起的。所以,事务A查询语句的读数据流程是这样的:
- 找到(1,3)的时候,判断出row trx_id=101,比高水位大,处于红色区域,不可见;
- 接着,找到上一个历史版本,一看row trx_id=102,比高水位大,处于红色区域,不可见;
- 再往前找,终于找到了(1,1),它的row trx_id=90,比低水位小,处于绿色区域,可见。
这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。
10.2.2 当前读(current read)
如果事务B在更新之前查询一次数据,这个查询返回的k的值确实是1。但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务C的更新就丢失了。因此,事务B此时的set k=k+1是在(1,2)的基础上进行的操作。所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
因此,在更新的时候,当前读拿到的数据是(1,2),更新后生成了新版本的数据(1,3),这个新版本的row trx_id是101。所以,在执行事务B查询语句的时候,一看自己的版本号是101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以查询得到的k的值是3。其实,除了update语句外,select语句如果加锁,也是当前读。如果把查询语句select * from t where id=1修改一下,加上lock in share mode 或 for update,也都可以读到版本号是101的数据,返回的k的值是3。
10.2.3 读提交隔离级别下
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

这时,事务A的查询语句的视图数组是在执行这个语句的时候创建的,时序上(1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:
- (1,3)还没提交,属于情况1,不可见;
- (1,2)提交了,属于情况3,可见。
所以,这时候事务A查询语句返回的是k=2。
显然地,事务B查询结果k=3。
- 对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
- 对于读提交,查询只承认在语句启动前就已经提交完成的数据;
10.3 锁定读(Locking Reads)
10.3.1 共享锁和独占锁(互斥)
共享锁 ,英文名: Shared Locks ,简称 S锁 。在事务要读取一条记录时,需要先获取该记录的 S锁 。
SELECT ... LOCK IN SHARE MODE
独占锁 ,也常称 排他锁 ,英文名: Exclusive Locks ,简称 X锁 。在事务要改动一条记录时,需要先获取该记录的 X锁 。
SELECT ... FOR UPDATE
10.3.2 写操作
平常所用到的 写操作 无非是 DELETE 、 UPDATE 、 INSERT 这三种:
DELETE :
对一条记录做 DELETE 操作的过程其实是先在 B+ 树中定位到这条记录的位置,然后获取一下这条记录的 X 锁 ,然后再执行 delete mark 操作。我们也可以把这个定位待删除记录在 B+ 树中位置的过程看成是一个获取 X锁 的 锁定读 。
- UPDATE :
在对一条记录做 UPDATE 操作时分为三种情况:
- 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在 B+ 树中定位到这条记录的位置,然后再获取一下记录的 X锁 ,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在 B+ 树中位置的过程看成是一个获取 X锁 的 锁定读 。
- 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在 B+ 树中定位到这条记录的位置,然后获取一下记录的 X锁 ,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在 B+ 树中位置的过程看成是一个获取 X锁 的 锁定读 ,新插入的记录由 INSERT 操作提供的 隐式锁 进行保护。
- 如果修改了该记录的键值,则相当于在原记录上做 DELETE 操作之后再来一次 INSERT 操作,加锁操作就需要按照 DELETE 和 INSERT 的规则进行了。
- INSERT :
一般情况下,新插入一条记录的操作并不加锁,通过一种称之为 隐式锁 的东东来保护这条新插入的记录在本事务提交前不被别的事务访问。
10.4 多粒度锁
前边提到的 锁 都是针对记录的,也可以被称之为 行级锁 或者 行锁 ,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在 表 级别进行加锁,自然就被称之为 表级锁 或者 表锁 ,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为 共享锁( S锁 )和 独占锁 ( X锁 )。
假如对表进行上 S 锁,需要确保没有行级的 X 锁。对表上 X 锁,需要确保没有 行级的 S 锁或 X 锁。
我们在上表锁时,怎么知道有没有 行锁 呢?遍历是不可能遍历的,这辈子也不可能遍历的,于是乎设计InnoDB 提出了一种称之为 意向锁 (英文名: Intention Locks )。
- 意向共享锁,英文名: Intention Shared Lock ,简称 IS锁 。当事务准备在某条记录上加 S锁 时,需要先在表级别加一个 IS锁 。
- 意向独占锁,英文名: Intention Exclusive Lock ,简称 IX锁 。当事务准备在某条记录上加 X锁 时,需要先在表级别加一个 IX锁 。
IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。
10.5 InnoDB 存储引擎中的锁
10.5.1 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锁 。
- 表级别的 IS锁 、 IX锁
当我们在对使用 InnoDB 存储引擎的表的某些记录加 S锁 之前,那就需要先在表级别加一个 IS锁 ,当我们在对使用 InnoDB 存储引擎的表的某些记录加 X锁 之前,那就需要先在表级别加一个 IX锁 。 IS锁 和 IX锁的使命只是为了后续在加表级别的 S锁 和 X锁 时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
- 表级别的 AUTO-INC锁
使用 MySQL 过程中,我们可以为表的某个列添加 AUTO_INCREMENT 属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值。
系统实现这种自动给 AUTO_INCREMENT 修饰的列递增赋值的原理主要是两个:
- 采用 AUTO-INC 锁,也就是在执行插入语句时就在表级别加一个 AUTO-INC 锁,然后为每条待插入记录的 AUTO_INCREMENT 修饰的列分配递增的值,在该语句执行结束后,再把 AUTO-INC 锁释放掉。这样一个事务在持有 AUTO-INC 锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
如果我们的插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用 INSERT … SELECT 、 REPLACE … SELECT 或者 LOAD DATA 这种插入语句,一般是使用 AUTO-INC 锁为 AUTO_INCREMENT 修饰的列生成对应的值。
小贴士:
需要注意一下的是,这个AUTO-INC锁的作用范围只是单个插入语句,插入语句执行完成后,这个锁就被释放了,跟我们之前介绍的锁在事务结束时释放是不一样的。
- 采用一个轻量级的锁,在为插入语句生成 AUTO_INCREMENT 修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的 AUTO_INCREMENT 列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。
如果我们的插入语句在执行前就可以确定具体要插入多少条记录,比方说我们上边举的关于表 t 的例子中,在语句执行前就可以确定要插入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修饰的列生成的值是交叉的,在有主从复制的场景中是不安全的。
10.5.2 InnoDB 中的行级锁
行锁 ,也称为 记录锁 ,顾名思义就是在记录上加的锁。不过设计 InnoDB 的很有才,一个 行锁 玩出了各种花样,也就是把 行锁 分成了各种类型。换句话说即使对同一条记录加 行锁 ,如果类型不同,起到的功效也是不同的。
CREATE TABLE hero (number INT,name VARCHAR(100),country varchar(100),PRIMARY KEY (number),KEY idx_name (name)) Engine=InnoDB CHARSET=utf8;INSERT INTO hero VALUES(1, 'l刘备', '蜀'),(3, 'z诸葛亮', '蜀'),(8, 'c曹操', '魏'),(15, 'x荀彧', '魏'),(20, 's孙权', '吴');
10.5.2.1 记录锁 Record Locks
我们前边提到的记录锁就是这种类型,也就是仅仅把一条记录锁上,有 S锁 和 X锁 之分的。
10.5.2.2 间隙锁 Gap Locks
MySQL 在 REPEATABLE READ 隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用 MVCC 方案解决,也可以采用 加锁 方案解决。但是在使用 加锁 方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上 记录锁 。提出了一种称之为 Gap Locks 的锁,官方的类型名称为: LOCK_GAP ,我们也可以简称为 gap 锁 。比方说我们把 number 值为 8 的那条记录加一个 gap锁 的示意图如下:
图中为 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 记录,表示该页面中最大的记录。
10.5.2.3 临键锁 Next-Key Locks
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的 间隙 插入新记录,所以设计 InnoDB 的就提出了一种称之为 Next-Key Locks 的锁,官方的类型名称为: LOCK_ORDINARY ,我们也可以简称为 next-key锁 。
next-key锁 的本质就是一个 正经记录锁 和一个 gap锁 的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的 间隙 。next-key lock实际上是间隙锁和行锁加起来的结果,先加间隙锁,再加行锁。
10.5.2.4 插入意向锁 Insert Intention Locks
我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的 gap锁 ( next-key 锁 也包含 gap锁 ,后边就不强调了),如果有的话,插入操作需要等待,直到拥有 gap锁 的那个事务提交。但是设计 InnoDB 的规定事务在等待的时候也需要在内存中生成一个 锁结构 ,表明有事务想在某个 间隙 中插入新记录,但是现在在等待。设计 InnoDB 的就把这种类型的锁命名为 Insert Intention Locks ,官方的类型名称为: LOCK_INSERT_INTENTION ,我们也可以称为 插入意向锁 。
比方说现在 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的 插入意向锁 ,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁( 插入意向锁 就是这么鸡肋)
10.5.2.5 隐式锁
我们前边说一个事务在执行 INSERT 操作时,如果即将插入的 间隙 已经被其他事务加了 gap锁 ,那么本次INSERT 操作会阻塞,并且当前事务会在该间隙上加一个 插入意向锁 ,否则一般情况下 INSERT 操作是不加锁的。
那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务立即使用 SELECT … LOCK IN SHARE MODE 语句读取这条事务,也就是在要获取这条记录的 S锁 ,如果允许这种情况的发生,那么可能产生 脏读 问题。或者使用 SELECT … FOR UPDATE 语句读取这条事务或者直接修改这条记录,也就是要获取这条记录的 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锁时,由于 隐式锁 的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。
10.6 InnoDB 锁的内存结构
我们前边说对一条记录加锁的本质就是在内存中创建一个 锁结构 与之关联,那么是不是一个事务对多条记录加锁,就要创建多个 锁结构 呢?比方说事务 T1 要执行下边这个语句:SELECT * FROM hero LOCK IN SHARE MODE
很显然这条语句需要为 hero表 中的所有记录进行加锁,那是不是需要为每条记录都生成一个 锁结构 呢?其实理论上创建多个 锁结构 没问题,反而更容易理解,但是谁知道你在一个事务里想对多少记录加锁呢,如果一个事务要获取10000条记录的锁,要生成10000个这样的结构也太亏了吧!所以设计 InnoDB 的本着勤俭节约的传统美德,决定在对不同记录加锁时,如果符合下边这些条件:
- 在同一个事务中进行加锁操作
- 被加锁的记录在同一个页面中
- 加锁的类型是一样的
- 等待状态是一样
那么这些记录的锁就可以被放到一个 锁结构 中。
- 锁所在的事务信息 :
不论是 表锁 还是 行锁 ,都是在事务执行过程中生成的,哪个事务生成了这个 锁结构 ,这里就记载着这个事务的信息。实际上这个所谓的**锁所在的事务信息在内存结构中只是一个指针而已,所以不会占用多大内存空间,通过指针可以找到内存中关于该事务的更多信息,比方说事务id是什么。下边介绍的所谓的索引信息其实也是一个指针。**
- 索引信息 :
对于 行锁 来说,需要记录一下加锁的记录是属于哪个索引
- 表锁/行锁信息 :
表锁结构 和 行锁结构 在这个位置的内容是不同的
- 表锁:
记载着这是对哪个表加的锁,还有其他的一些信息
- 行锁:
记载了三个重要的信息:
- Space ID :记录所在表空间。
- Page Number :记录所在页号。
- n_bits :对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个 n_bits 属性代表使用了多少比特位。(并不是该页面中有多少记录,n_bits属性的值就是多少。为了让之后在页面中插入了新记录后也不至于重新分配锁结构,所以n_bits的值一般都比页面中记录条数多)
- type_mode:
这是一个32位的数,被分成了 lock_mode 、 lock_type 和 rec_lock_type 三个部分,如图所示:
- 锁的模式( 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时,表示插入意向锁。
- LOCK_WAIT (十进制的 256 ) :也就是当第9个比特位置为 1 时,表示 is_waiting 为 true ,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为 0 时,表示 is_waiting 为 false ,也就是当前事务获取锁成功
- 其他信息 :
为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。
- 一堆比特位:
如果是 行锁结构 的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的 n_bits 属性表示的。我们前边唠叨InnoDB记录结构的时候说过,页面中的每条记录在 记录头信息 中都包含一个 heap_no 属性,伪记录 Infimum 的 heap_no 值为 0 , Supremum 的 heap_no 值为 1 ,之后每插入一条记录, heap_no值就增1。 锁结构 最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个 heap_no ,不过为了编码方便,映射方式有点怪:
10.7 加锁
10.7.1 全局锁
顾名思义,全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都select出来存成文本。
官方自带的逻辑备份工具是mysqldump。当mysqldump使用参数–single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。一致性读是好,但前提是引擎要支持这个隔离级别。比如,对于MyISAM这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用FTWRL命令了。所以,single-transaction方法只适用于所有的表使用事务引擎的库。如果有的表使用了不支持事务的引擎,那么备份就只能通过FTWRL方法。
既然要全库只读,为什么不使用set global readonly=true的方式呢?确实readonly方式也可以让全库进入只读状态,但还是会建议你用FTWRL方式,主要有两个原因:
- 一是,在有些系统中,readonly的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改global变量的方式影响面更大,我不建议你使用。
- 二是,在异常处理机制上有差异。如果执行FTWRL命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为readonly之后,如果客户端发生异常,则数据库就会一直保持readonly状态,这样会导致整个库长时间处于不可写状态,风险较高。
业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论是哪种方法,一个库被全局锁上以后,你要对里面任何一个表做加字段操作,都是会被锁住的。
10.7.2 表锁
- 隐式上锁:
在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行更新操作(UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预,因此用户一般不需要直接用 LOCK TABLE;
-- 上读锁 --SELECT-- 上写锁 --INSERT, UPDATE, DELETE
显示上锁(手动)
LOCK TABLES teachers READ; // 对表上读锁LOCK TABLES courses WRITE; // 对表上写锁UNLOCK TABLES; // 解锁所有锁表
10.7.3 加行锁
隐式上锁:
InnoDB 行锁的加锁的方式是自动加锁:
- 对于 UPDATE、DELETE、INSERT 操作,InnoDB 会自动给涉及数据集添加排他锁
- 对于 SELECT 操作,InnoDB 不会添加任何锁
-- 不会上锁 --SELECT-- 上写锁 --INSERT, UPDATE, DELETE
- 显示上锁(手动)
LOCK IN SHARE MODE 与 FOR UPDATE 只能在事务内其作用,以保证当前会话事务锁定的行不会被其他会话修改。
-- 读锁 --SELECT *FROM table_nameLOCK IN SHARE MODE;-- 写锁 --SELECT *FROM table_nameFOR UPDATE;
解锁(手动):
- 提交事务(commit)
- 回滚事务(rollback)
- 阻塞进程(kill)
10.7.4 排查锁
10.7.4.1 排查表锁
查看表锁情况SHOW OPEN TABLES;
表锁分析SHOW STATUS LIKE 'table%';
- Table_locks_waited:
出现表级锁定争用而发生等待的次数(不能立即获取锁的次数,每等待一次值加 1),此值高说明存在着较严重的表级锁争用情况
- Table_locks_immediate:
产生表级锁定次数,不是可以立即获取锁的查询次数,每立即获取锁加 1
10.7.4.2 排查行锁
行锁分析SHOW STATUS LIKE 'innodb_row_lock%';
- Innodb_row_lock_current_waits:当前正在等待锁定的数量
- Innodb_row_lock_time:从系统启动到现在锁定总时间长度
- Innodb_row_lock_time_avg:每次等待所花平均时间
- Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花时间
- Innodb_row_lock_waits:系统启动后到现在总共等待的次数
优化建议
- 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
- 合理设计索引,尽量缩小锁的范围
- 尽可能较少检索条件,避免间隙锁
- 尽量控制事务大小,减少锁定资源量和时间长度
- 尽可能低级别事务隔离
10.8 示例
10.8.1 结论
- 当使用唯一索引来等值查询的语句时, 如果这行数据存在,不产生间隙锁,而是记录锁。
- 当使用唯一索引来等值查询的语句时, 如果这行数据不存在,会产生间隙锁。
- 当使用唯一索引来范围查询的语句时,对于满足查询条件但不存在的数据产生间隙(gap)锁,如果查询存在的记录就会产生记录锁,加在一起就是临键锁(next-key)锁。
- 当使用普通索引不管是锁住单条,还是多条记录,都会产生间隙锁;
- 在没有索引上不管是锁住单条,还是多条记录,都会产生表锁;
- 间隙锁会封锁该条记录相邻两个键之间的空白区域,防止其它事务在这个区域内插入、修改、删除数据,这是为了防止出现 幻读 现象;
- 间隙的范围根据检索条件向下寻找最靠近检索条件的记录值A作为左区间,向上寻找最靠近检索条件的记录值B作为右区间,即锁定的间隙为(A,B] 左开右闭。
- 原则1:加锁的基本单位是next-key lock。希望你还记得,next-key lock是前开后闭区间。
- 原则2:查找过程中访问到的对象才会加锁。
- 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁(记录锁)。
- 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。
- 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
``sql CREATE TABLEt(idint(11) NOT NULL,cint(11) DEFAULT NULL,dint(11) DEFAULT NULL, PRIMARY KEY (id), KEYc(c`) ) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25); ```
10.8.2 唯一索引等值查询示例

由于表t中没有id=7的记录,所以用我们上面提到的加锁规则判断一下的话:
- 根据原则1,加锁单位是next-key lock,session A加锁范围就是(5,10];
- 同时根据优化2,这是一个等值查询(id=7),而id=10不满足查询条件,next-key lock退化成间隙锁,因此最终加锁的范围是(5,10)。
所以,session B要往这个间隙里面插入id=8的记录会被锁住,但是session C修改id=10这行是可以的。
10.8.3 非唯一索引等值查询示例

这里session A要给索引c上c=5的这一行加上读锁。
- 根据原则1,加锁单位是next-key lock,因此会给(0,5]加上next-key lock。
- 要注意c是普通索引,因此仅访问c=5这一条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10]加next-key lock。
- 但是同时这个符合优化2:等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10)。
- 根据原则2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么session B的update语句可以执行完成。
但session C要插入一个(7,7,7)的记录,就会被session A的间隙锁(5,10)锁住。
需要注意,在这个例子中,lock in share mode只锁覆盖索引,但是如果是for update就不一样了。 执行 for update时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。
10.8.4 唯一索引范围查询示例

现在我们就用前面提到的加锁规则,来分析一下session A 会加什么锁呢?
- 开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。 根据优化1, 主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁。
- 范围查找就往后继续找,找到id=15这一行停下来,因此需要加next-key lock(10,15]。
所以,session A这时候锁的范围就是主键索引上,行锁id=10和next-key lock(10,15]。这样,session B和session C的结果你就能理解了。
这里你需要注意一点,首次session A定位查找id=10的行的时候,是当做等值查询来判断的,而向右扫描到id=15的时候,用的是范围查询判断。
10.8.5 普通索引范围查询示例

这次session A用字段c来判断,加锁规则跟案例三唯一的不同是:在第一次用c=10定位记录的时候,索引c上加了(5,10]这个next-key lock后,由于索引c是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终sesion A加的锁是,索引c上的(5,10] 和(10,15] 这两个next-key lock。
所以从结果上来看,sesson B要插入(8,8,8)的这个insert语句时就被堵住了。
这里需要扫描到c=15才停止扫描,是合理的,因为InnoDB要扫到c=15,才知道不需要继续往后找了。
10.8.6 非唯一索引上存在等值示例
可以看到,虽然有两个c=10,但是它们的主键值id是不同的(分别是10和30),因此这两个c=10的记录之间,也是有间隙的。
这次我们用delete语句来验证。注意,delete语句加锁的逻辑,其实跟select … for update 是类似的。
这时,session A在遍历的时候,先访问第一个c=10的记录。同样地,根据原则1,这里加的是(c=5,id=5)到(c=10,id=10)这个next-key lock。
然后,session A向右查找,直到碰到(c=15,id=15)这一行,循环才结束。根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(c=10,id=10) 到 (c=15,id=15)的间隙锁。左右两边都是虚线,表示开区间,即(c=5,id=5)和(c=15,id=15)这两行上都没有锁。
10.8.7 limit 示例

这个例子里,session A的delete语句加了 limit 2。你知道表t里c=10的记录其实只有两条,因此加不加limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B的insert语句执行通过了,跟案例六的结果不同。
这是因为,案例七里的delete语句明确加了limit 2的限制,因此在遍历到(c=10, id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。
因此,索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间,如下图所示:
可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此insert语句插入c=12是可以执行成功的。在删除数据的时候尽量加limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
10.8.8 无索引示例


事务A 等值查询 mobile [=|>] 8888884,因为mobile是无索引的,所以这个for update,变成表级排他(X)锁。
事务B 因为事务A已经加了表级的排他锁,所以其它事务无法进行任何的增删改操作。
10.8.9 小结
我们上面的所有案例都是在可重复读隔离级别(repeatable-read)下验证的。同时,可重复读隔离级别遵守两阶段锁协议,所有加锁的资源,都是在事务提交或者回滚的时候才释放的。
在最后的案例中,你可以清楚地知道next-key lock实际上是由间隙锁加行锁实现的。如果切换到读提交隔离级别(read-committed)的话,就好理解了,过程中去掉间隙锁的部分,也就是只剩下行锁的部分。其实读提交隔离级别在外键场景下还是有间隙锁,相对比较复杂,我们今天先不展开。
另外,在读提交隔离级别下还有一个优化,即:语句执行过程中加上的行锁,在语句执行完成后,就要把“不满足条件的行”上的行锁直接释放了,不需要等到事务提交。
也就是说,读提交隔离级别下,锁的范围更小,锁的时间更短,这也是不少业务都默认使用读提交隔离级别的原因。
10.9 元数据锁 MDL
InnoDB层已经有了IS、IX这样的意向锁,有同学觉得可以用来实现上述例子的并发控制。但由于MySQL是 Server-Engine 架构,所以MDL锁是在Server中实现。MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。另外,MDL锁还能实现其他粒度级别的锁,比如全局锁、库级别的锁、表空间级别的锁,这是InnoDB存储引擎层不能直接实现的锁。
修改表结构时,Server 会加元数据写锁,其他事务会堵塞,但是是轻量级的,执行完语句后会转为元数据读锁,然后释放。进行增删改查时,会加元数据读锁,然后进入存储引擎层,再进行存储引擎层的锁划分。
