InnoDB中的锁

1. InnoDB中的锁

行级锁:S和X锁

支持多粒度锁定,允许事务在行级锁和表级锁同时存在

意向锁:将锁定的对象分为多个层次,意味着事务希望在更细粒度上进行加锁。

支持IS、IX意向锁

1.1 一致性非锁定读

一致性非锁定读:行上有X锁时,读取行的历史快照数据。MVCC技术

在READ COMMITTED隔离级别下,总是读取被锁定行的最新的快照数据

在 REPEATABLE READ,总是读取事务开始时的行数据快照版本,可以解决幻读问题。

SELECT @@transaction_isolation;

快照数据的实现是通过undo段来完成的。

1.2 一致性锁定读

显式对数据库读取操作进行加锁(这些是当前读,即不通过 MVCC 机制):

SELECT … FOR UPDATE;对读取的行记录加X锁
SELECT … LOCK IN SHARE MODE;

UPDATE \ DELETE 等都是当前读,否则就会丢失更新操作;

1.3 自增长

(1)InnoDB 对每个含有自增长值的表都有一个自增长计数器

通过innodb_autoinc_lock_mode控制模式:

  1. 传统模式:AUTO-INC锁是表级锁,在完成对自增长插入的SQL语句后就立即释放。
  2. 在插入记录的数量确定时,采用轻量级锁;不确定时(批量插入 INSERT ... SELECT...),采用 AUTO-INC 锁
  3. 使用互斥量对内存中的计数器进行累加操作,只需给自增字段加锁,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁。(在 binlog_format=statement时可能会造成主从不一致的问题,需要搭配 binlog_format=row 来使用)

自增长值的列必须是索引,而且必须是索引的第一个列

(2)新的自增长生成算法:要插入的为 X 值,当前自增值为 Y;从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,作为新的自增值。(这两个都是可配置的参数)

(3)自增值不连续问题

唯一键冲突、事务回滚都会导致自增值不连续

批量申请自增 id 的策略:第 1 次申请会分配 1 个,第 2 次申请分配 2 个,第 3 次申请分配 4 个,依次类推;

(4)对每个有自增列的表,InnoDB 自增长记录在内存中,MySQL 8.0 后通过 redo log 实现持久化;MyISAM 自增值记录在数据文件中;

(5)增大到上限后,会一直保持最大值,如果是主键会报重复主键的错;

1.4 外键

对于外键值的插入和更新,首先需要查询父表中的记录(主动对父表加 S 锁)。

2. 锁的算法

2.1 行锁

(1)Record Lock:单个行记录上的锁; LOCK_MODE 为 X, REC_NOT_GAP

(2)Gap Lock;间隙锁,锁定一个范围,不包括记录本身。注意:间隙锁之间是兼容的; LOCK_MODE 为 X, GAP

对某个记录加 Gap Lock,实际上是不允许其他事务向这条记录前面的间隙插入新记录;间隙锁是前开后开区间

(3)Next-Key Lock;锁定一个范围,包括记录本身;为了解决幻读问题;LOCK_MODE 为 X
next-key lock 是前开后闭区间

(4)Insert Intention Lock(插入意向锁)

事务在等待时也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新纪录,但现在处于等待状态;

不会阻止别的事务继续获取该记录上任何类型的锁(比较鸡肋);

(5)隐式锁

一般情况下执行 INSERT 语句不需要在内存中生成锁结构;

一个事务首先插入一条记录,另一个事务执行 SELECT … LOCK IN SHARE MODE,可能发生脏读情况;

另一个事务 SELECT … FOR UPDATE 或者立即修改该记录,可能发生脏写现象;

如何避免上述两种情况?

  1. 对于聚簇索引,有一个 trx_id 隐藏列,如果其他事务想对新插入记录的添加 S 锁或 X 锁,首先会看一下该记录的 trx_id 隐藏列代表的事务是否是当前的活跃事务;若不是可以正常读取,否则,需要帮助当前事务创建一个 X 锁的锁结构,该锁结构的 is_waiting 属性为 false;然后为自己也创建一个锁结构,该锁结构的 is_waiting 属性为 true,之后自己进入等待状态;

  2. 对二级索引,判断 PAGE_MAX_TRX_ID 小于当前最小的活跃事务 id,若不是,需要进行回表后再进行 1 中的判断;

2.2 InnoDB 锁的内存结构

(1)对不同记录加锁时,可以共有一个锁结构的条件(为每一行都生成一个锁结构浪费存储空间):

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

图22-12
type_mode 区分是 锁模式(IS \ IX \ S \ X)行锁还是表锁,是 Record \ Gap \ Next-Key Lock;

(2)查看当前锁的语句

SELECT * FROM performance_schema.data_locks\G;

2.3 加锁原则

(1)原则:
1. 在能使用记录锁或者间隙锁就能避免幻读现象的场景下, next-key lock 就会退化成退化成记录锁或间隙锁。这里的加锁都是对索引加锁

2. 没有加索引的增删查改语句,会使用全表扫描,对每个记录都加 next-key lock,相当于把表锁住了

查询的列是唯一索引的情况下,会从 Next-Key Lock 降级为 Record Lock

(4)对聚集索引,等值查询仅加 Record Lock;范围查询>=的话,等值记录存在也仅加Record Lock,其他加Next-Key Lock(包括Supremum记录)
(5)对辅助索引,等值查询加上的是 Next-Key Lock,并且InnoDB 还会对辅助索引的下一个键值加上 Gap Lock
记录不存在情况下,对辅助索引加 Gap Lock
记录存在情况下,还需要对聚集索引加 Record Lock
非唯一索引进行范围查询时,对二级索引记录加锁都是加 next-key 锁

可以参考小林的这篇博客: link

(1)Next-Key Lock已经把 b = 3 的记录锁了,为什么需要Gap Lock锁?(书中P268为例)

这里有一点必须得理解,把 b = 3 的记录锁,可以防止该记录被修改,但并不妨碍插入新的行,新行的b也可以是3。

从这点也可以看出,为什么非唯一索引需要范围锁。(唯一索引可以保证不会插入相同的列值,加行锁保证不会被删除即可)

(2)Next-Key Lock已经把 (1,3) 锁了,为什么还需要Gap Lock锁住(3,6)呢?

可以结合间隙锁的实现原理来看,B+树的叶子节点是按排序来存放的,如下所示:

b列136
a主键列357

如果只锁住范围(1,3),即相当于只锁住了左边间隙,那么还可以插入b = 3,a = 6 数据到右边间隙中,仍然会造成幻读问题

因此要将左右两边的间隙都锁住才可以。

(3)书中介绍,是对下一个键值加上Gap Lock。那么如果数据为下述所示,会造成问题吗?

b列1336
主键列3577

这里可能误以为右边间隙为(3,3),但注意实际上查询b = 3的记录时, 会返回两个记录,而非一个。

(4)插入语句在插入一条记录之前,需要先定位到该记录在 B+树 的位置,如果插入的位置的下一条记录的索引上有间隙锁,才会发生阻塞

(5)显式关闭Gap Lock的方式有以下两种

将隔离级别设置为READ COMMITTED
将参数innodb_locks_unsafe_for_binlog设置为1

(6)不同隔离级别的锁算法

在READ COMMITTED下,仅采用Record Lock

在REPEATABLE READ下,采用 Next-Key Lock 的方式来加锁

(7)通过索引以 SLock 的方式查询一个值时,即使查询的值不存在,其锁定的也是一个范围,即Gap Lock。(插入更新也是相同情况)
这样做的目的也是为了避免出现幻读,防止之后对该范围进行插入造成幻读问题。
Gap Lock不会保护边界,因此可以对左右边界进行删除操作,这并不会造成幻读问题。

(8)InnoDB的间隙锁和MVCC机制并不能完全解决幻读问题

普通 select 语句会使用MVCC机制
select … for update语句不是快照读,而是当前读,也就是每次读都是拿到最新版本的数据,会进行加锁。

因此,在可重复读级别下,如果事务T1刚开始使用快照读表b,与此同时,事务T2向表b中插入数据c,并提交。正常情况下,不应该能够看到c,但如果事务T1再使用当前读,就会读到,那么就会触发幻读问题。

2.4 加锁前提判断

(1)精确匹配:使用索引时,形成单点扫描区间

对联合索引 idx_a_b(a, b) 来说, a = 1 或者 a = 1 AND b = 1 都是精确匹配, a = 1 AND b >= 1 不是精确匹配;

(2)唯一性搜索:扫描区间最多包含一条记录

精确匹配;
使用索引为主键或唯一二级索引;
如果是联合索引,每个索引列都要用到;
如果使用唯一二级索引,搜索条件不能为 IS NULL 的形式

(3)半一致性读(只适用于聚簇索引)
隔离级别 <= READ COMMITTED 且 执行 UPDATE 语句

当读取到已经被其他事务加了 X 锁的记录时,InnoDB 会将记录的最新提交版本读出来,判断该版本是否与 UPDATE 中的搜索条件相同,如果不匹配,则不对记录加锁;匹配的话,会再次读取该记录并对其进行加锁;

2.5 加锁流程

默认会加 Gap Lock 锁,即变量 set_also_gap_locks = True

2.5.1 设定一些标志

Con1set_also_gap_locks 为 True && 隔离级别 >= REPEATABLE READ 且 没有开启 innodb_locks_unsafe_for_binlog && 加锁读 && 不是空间索引;

Con2set_also_gap_locks 为 False || 开启了 innodb_locks_unsafe_for_binlog || 隔离级别 <= READ COMMITTED || 空间索引 || 唯一性搜索且该记录的 delete_flag 不为 1;

Con3: 主键索引>=主键的边界条件,并且当前记录是开始边界;

2.5.2 加锁前预处理

(1)锁定读 并且 隔离级别 <= READ COMMITTED ,不会使用 Gap Lock,即 set_also_gap_locks = False

(2)是否首次执行 SELECT

  1. 若不是首次

  2. 若是首次
    普通读,查询前生成 ReadView
    锁定读,需要对表加相应的 IS \ IX 锁;

2.5.3 加锁真实流程

(1)定位第一条记录,针对一个扫描区间只执行一次

(2)针对 ORDER BY … DESC 条件处理;如果是加锁读,隔离级别 >= REPEATABLE READ 且 没有开启 innodb_locks_unsafe_for_binlog

每个扫描区间执行一次,找到从右向左的扫描区间中的最右边的那条记录,对其下一个记录加 Gap 锁,防止幻读。

(3)如果当前记录是 Infimum 记录 或 Supremum 记录 时的处理

  1. 如果是 Infimum,跳过
  2. 如果是 Supremum,满足 Con1,对其添加 Next-Key Lock

(4)当前记录 不是 Infimum 记录 或 Supremum 记录

  1. 精确匹配;
    不在扫描区间内,(如果满足 Con1,对其添加 Gap Lock),直接返回,后续不再进行加锁

  2. 加锁类型判断,根据相应类型加锁;
    加 Record Lock:满足 Con2 或者 Con3
    其余情况加 Next-Key Lock

  3. 判断索引下推条件是否成立(二级索引,非精确匹配,只适用于 SELECT 语句);
    如果不在扫描区间时,会直接跳到下一条记录;如果是范围查询的最后一个记录,直接先 server 层报告查询结束;

  4. 回表对记录加锁(二级索引),对相应聚簇索引记录加 Record Lock;
    即使对覆盖索引场景,如果对索引加的是 X 锁,那么也需要对二级索引执行回表操作,对对应主键加 Record Lock

  5. 判断当前记录是否在扫描区间中
    如果不在,直接结束本次扫描区间,向 server 层返回一个查询完毕的信息;隔离级别 <= READ COMMITTED 时,释放相应的锁(如果是二级索引,也会释放相应聚簇索引的锁)

  6. server 层判断其他搜索条件是否成立
    成立,发送到客户端
    不成立,如果 隔离级别 <= READ COMMITTED 时,释放锁;

  7. 获取下一条记录,返回(3)执行;

2.5.4 加锁情况分析

(1)可以删除主键索引中带有 GAP 锁的记录,会将 GAP 锁加到该被删除记录的下一条记录中;

(2)加锁读时,使用二级索引 s孙权记录存在, name < 's孙权' 这种情况为什么需要加 Next-Key Lock 而非 Gap Lock?

感觉这里的情况和 2.5.3 中 (2) 的情况一致,为什么只有在降序时才这样操作?感觉是可以这样处理的

3. 加锁总结

3.1 普通 SELECT 语句

(1) READ UNCOMMITTED,不加锁

(2) READ COMMITTED,不加锁,在每次执行 SELECT 时生成一个 ReadView,避免了脏读

(3) REPEATABLE READ,不加锁,在第一次执行 SELECT 时生成一个 ReadView,避免了脏读、不可重复读(避免了大部分幻读)

(4)SERIALIZABLE

  1. 禁用自动提交时(autocommit=0),将普通的 SELECT 语句转换为 SELECT … LOCK IN SHARE MODE 形式;
  2. 启用自动提交时,一个语句为一个事务,不会有不可重复读和幻读问题,普通的 SELECT 语句只需创建一个 ReadView 即可;
3.2 锁定读

(1) READ UNCOMMITTED \ READ COMMITTED

不会加 Gap Lock

搜索条件不成立就会释放锁

(2)MySQL 8.0 相比已经做了很多优化工作,没必要持有锁的时候就会释放锁;

3.3 注意事项

(1)加锁读(包含 UPDATE、DELETE 等)一定要注意不要使用全表扫描,这相当于是表锁,性能较差

确保查询计划使用索引(可以考虑force index([index_name])强制使用索引)
使用 LIMIT,找到若干个满足条件的就直接返回

(2)Gap 锁导致的死锁问题

REPEATABLE READ 中,通过加 Gap 锁解决幻读问题,但不同的 Gap 锁都是不冲突的,注意在并行场景下会出现死锁问题;

例如,T1 和 T2 都对 (3,5)加了 Gap 锁,但都要向其中插入记录;

(3)如果真的要去掉 Gap lock,可以考虑改用 RC 隔离级别 + binlog_format = row

(4)注意二级索引的逆序遍历问题;

(5)只有访问到的对象才会加锁,当满足覆盖索引时,并且采用 select ... lock in share mode 时只会锁覆盖索引。(与之相比,即使在这种情况下,for update 也会去锁主键索引)

(6)注意:如果列是 AUTO_INCREMENT 时,向该列插入 0 或 NULL 时,会自动替换为该列最大值 + 1 的值;

除非显示声明 NO_AUTO_VALUE_ON_ZERO

3.4 加锁总结

(1)原则1:加锁的基本单位是 Next-key Lock
原则2:查找过程中访问到的对象才会加锁

优化1:索引上的等值查询,给唯一索引加锁时,Next-key Lock 退化为 Record Lock;
优化2:索引上的等值查询,向右遍历且最后一个值不满足等值条件时,Next-key Lock 退化为 Gap Lock;

一个bug:唯一索引的范围查询会访问到不满足条件的第一个值为止;

(2)有 LIMIT 子句时,当满足要求后就会终止,不再进行加锁;

(3)Next-key Lock 的具体执行步骤:加 Next-key Lock 时,其实分为了两步 Gap Lock + Record Lock,对记录加 Gap Lock 不会阻塞,只有加 Record Lock 才可能阻塞;因此就可能会出现死锁问题。

T1 持有(3,5] 的 Next-key Lock,T2 也想对 5 加 Next-key Lock,那么 Gap Lock 会成功,Record Lock 被阻塞;此时当 T1 想插入 4 时,就会触发死锁问题;

(4)<= 到底是间隙锁还是行锁?其实,要跟“执行过程”配合起来分析。在InnoDB要去找“第一个值”的时候,是按照等值去找的,用的是等值判断的规则;找到第一个值以后,要在索引内找“下一个值”,对应于我们规则中说的范围查找。

3.5 各隔离级别下加锁特点

(1) READ UNCOMMITTED

(2) READ COMMITTED

没有 Gap 锁,除了在外键场景下有 Gap 锁
优化:语句执行过程中加上的行锁,在语句执行完成后,就要把“不满足条件的行”上的行锁直接释放了,不需要等到事务提交。

特点:锁的范围更小,锁的时间更短

(3) REPEATABLE READ

遵守两阶段锁规则,所有锁资源在事务提交或回滚时才释放;

(4)SERIALIZABLE

4. 各层次锁

4.1 全局锁

(1)一般用于数据库全库逻辑备份,整个数据库处于只读状态,会阻塞所有更改操作;

FLUSH TABLE WITH READ LOCK; (FTWRL)

客户端异常断开后,MySQL 会自动释放这个全局锁

(2)官方自带 mysqldump 使用参数 –single-transaction ,在导数据之前会启动一个可重复读事务,获得一致性视图

实际执行时:会使用检查点,SELECT 执行完后,就回滚到检查点的位置释放 MDL 锁;

这种方法的要求是:所有表都使用事务引擎

4.2 表锁

(1)LOCK TABLE ... READ \ WRITE;

(2)元数据锁(MDL);访问表时自动加上,事务提交时才会释放;

增删查改时,加读锁
更改表结构时,加写锁

前面有写锁请求,即使未获得锁,也会阻塞后续的读锁请求(尽管读锁有一定的优先级)

下面为时间线:
T1 执行 SELECT
T2 执行 ALTER(阻塞)
T3 执行 SELECT(阻塞)
T1 提交
T3 接着执行
T4 执行 SELECT(阻塞)
T3 提交
T2 接着执行
T4 接着执行

(3)在给表安全地加字段时,需要考虑 kill 掉长事务,让其释放出 MDL 锁;

或者在 alter 语句中加入超时机制,一定时间内无法获取 MDL 锁,就先放弃;

(4)Online DDL 的过程是这样的:

  1. 拿 MDL 写锁
  2. 降级成 MDL 读锁
  3. 真正做 DDL
  4. 升级成 MDL 写锁
  5. 释放 MDL 锁

5. 阻塞

发生阻塞时,innodb_lock_wait_timeout控制等待时间,当超时时,会抛出错误。

此时需要注意,默认情况下InnoDB不会回滚超时引发的错误异常。需要用户自己决定进行COMMIT还是ROLLBACK;

发生死锁时,InnoDB会马上回滚一个事务

6. 死锁

(1)被动方法:超时机制
建立等待图

在每个事务请求锁发生等待时都会判断是否有回路,若发生死锁,通常会选择回滚undo量最小的事务。

检测回路算法:非递归版本的深度优先

当前事务持有了待插入记录的下一个记录的X锁,但是在该记录的等待队列中还存在一个S锁的请求,可能会发生死锁。

InnoDB存储引擎是根据页进行加锁,而且页内的行锁是采用位图方式实现。

(2)SHOW ENGINE INNODB STATUS

(3)死锁检测也是有代价的:每个新到来的线程被阻塞时,都要进行一次死锁检测 O(n) 的时间复杂度

并不是每次死锁检测都都要扫所有事务(只需于扫描本事务等待的事务,以及等待事务等待的事务。。。);如果有多个线程同时更新一行记录,那么在死锁检测上就会做很多无用功。

怎么解决热点行更新导致的性能问题?

  1. 临时把死锁检测关掉;
  2. 控制并发度;可以考虑在中间件、服务端加这个功能,对相同行更新,进入引擎前进行排队;
  3. 可以考虑把一行拆分为多行,减少冲突,但会增加业务的复杂度;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值