MySQL 锁机制(下) -- 细说 InnoDB 行锁(记录锁、间隙锁与临键锁)

2019-09-21 11:42:46   最后更新: 2019-09-23 14:48:13   访问数量:37




上一篇文章中,我们介绍了 MySQL 中最基本的锁机制:

  1. 共享锁(S 锁 -- Shared Locks)
  2. 排它锁(X 锁 -- Exclusive Locks)
  3. 意向共享锁(IS 锁 -- Intention Shared Locks)
  4. 意向排它锁(IX 锁 -- Intention Exclusive Locks)

 

以及在此之上实现的全局锁与表级锁

MySQL 锁机制(上) -- 全局锁与表级锁

 

但事实上,Innodb 引擎实现了行级锁,与只支持表级锁的 MyISAM 相比,这显然能够有效减少锁冲突,这也是 Innodb 最终能够战胜 MyISAM 成为 MySQL 默认存储引擎的一个重要原因

因此我们在使用中,最为频繁接触到就是行级锁,用好行级锁,减少锁冲突,将有效提升 MySQL 的执行性能,本文我们就来详细介绍一下 Innodb 中的各种行级锁

 

 

按照锁定的范围不同,行级锁分为:

  1. 记录锁 -- 锁定某行
  2. 间隙锁 -- 锁定某个区间
  3. 临键锁 -- 锁定左开右闭的一段区间

 

上述行级锁的加锁是 Innodb 自动进行的,我们可以通过某些 SQL 语句触发相应的加锁操作,但不能自由的实现加锁和解锁的动作

和上篇文章中我们讲到的 MDL 锁一样,如果在事务中某些行或区间被加锁,那么只有到事务结束时(提交或回滚)才会自动进行解锁

另外,这里提醒一句,innodb 通过 MVCC 实现了在可重复读事务隔离级别下不加锁实现快照读的机制,所以本文提到的所有行级锁,都不会影响到其它事务中的快照读

 

那么,是在什么场景下进行加锁操作的呢?有以下场景:

  1. select ... lock in share mode
  2. select ... for update
  3. insert
  4. update
  5. delete

 

针对上述所有操作,除了 select ... lock in share mode 是加共享锁外,其他操作均为排它锁

 

上面介绍到,记录锁就是对某行进行加锁,防止该行被其他操作修改或删除

对于不存在的记录,Innodb 同样允许对其进行加锁,存储引擎首先创建一个隐藏的聚簇索引,然后将其记录为锁定状态

 

加锁场景

例如下面的语句,当执行:

select * from test where dix_field = 2; select * from test where dix_field in (2, 3, 4);

 

 

如果 idx_field 是主键或惟一键,就会锁定对应行记录的聚簇索引或隐藏的聚簇索引

在读已提交隔离级别下,如果通过非主键或惟一键索引,会锁定查询过程中扫描到的每条记录,但在查询完成后,会自动释放未匹配的记录的锁

 

记录锁锁定的是若干条行记录,间隙锁则锁的是若干个索引间的间隙,每个间隙都是两端开放的区间

在一个数据表中,以主键、惟一键为间隔存在着很多个区间,这些区间如果被加锁,就被称为“间隙锁”

间隙锁存在的目的是为了防止在事务执行过程中,另一个事务对间隙的插入,能够有效避免幻读的发生

正是因为间隙锁的存在目的,所以多个事务可以同时对同一个间隙加锁,即使他们加的都是排它锁(事实上,考虑另一种常见情况,事务 1 持有间隙锁 (1, 3],事务 2 持有间隙锁 (3, 5),此时将记录 3 删除,那么事务 1 与事务 2 持有的间隙锁都将变成 (1, 5),如果强制间隙锁的互斥,那么这种情况下就会产生错误)

 

在读已提交与读未提交隔离级别下,Innodb 会自动禁用间隙锁

 

加锁场景

下列场景下,innodb 会自动加间隙锁:

  1. 通过主键或惟一键查询,但对应的记录不存在时,innodb 会创建隐藏索引,并锁定隐藏索引所在的区间

 

简单的来说,临键锁就是记录锁 + 间隙锁,也可以理解为特殊的间隙锁,他的区间是前开后闭的

 

加锁场景

  1. 通过对主键或惟一键进行范围查询,会加大于查询范围前开后闭最小范围的临键锁
  2. 通过非主键或惟一键查询,会锁定对应索引记录及其之前的间隙
  3. 如果没有建立索引,那么在查询过程中实际上扫描的是全表,所以最终会锁全表,不过对于 select * from test where xxx limit 1 这样的语句来说,实际扫描在首次遇到匹配行即结束,所以会锁此行前所有间隙

 

并发系统中不同线程出现对竞争资源的循环依赖并阻塞相互等待就会发生死锁

例如事务 A 中,执行 update test set k = k + 1 where id = 1; 会锁定 id 为 1 的记录

事务 B 中,执行 update test set k = k + 2 where id = 2; 会锁定 id 为 2 的记录

此时,如果在事务 A 中执行 update test set k = k + 3 where id = 2; 同时在事务 B 中执行 update test set k = k + 4 where id = 1;

两个事务会分别阻塞等待另一个事务占用的排他锁,从而陷入死锁

 

如何避免死锁

设置超时

设置锁等待超时是最为简单粗暴的办法,innodb 提供了加锁阻塞超时时间的设置:innodb_lock_wait_timeout

默认值是 50,即一个加锁请求在等待 50 秒后会自动返回加锁失败

但这样存在几个问题:

该配置项的单位是秒数,因此他的最小粒度是 1 秒,对于有些系统,1 秒的超时显然太长,而另一些系统中,1 秒的超时又显得太短,难以区分是正常的锁等待还是发生了死锁,从而可能造成误伤

 

主动死锁检测

innodb 提供了主动死锁检测机制,innodb 在锁冲突发生时,会扫描持有该锁或在竞争该锁的事务,判断他们之间是否有可能产生死锁,一旦发现当前事务的等待会产生死锁,那么就会立即返回错误

可以通过 innodb_deadlock_detect 设置为 on 或 off 来开启或关闭主动死锁检测机制,默认是开启状态

看上去主动死锁检测 + 业务重试可以解决所有的死锁问题了,但是这同样存在一定的问题

由于整个主动死锁检测过程需要循环遍历所有持有或等待锁的事务两两间的持有锁情况,所以这个过程的时间复杂度是 O(n^2),在高并发的场景下,例如有 1000 个并发的线程同时更新一行,虽然他们之间并不会产生死锁,但主动死锁检测却要进行 100 万次对比,最终造成 CPU 利用率的飙高

 

拆分字段实现单条记录并发度的下降

上述主动死锁检测引起性能问题的原因主要是单条记录加锁的并发度过高,但通常,我们不能靠降低系统的并发度来避免问题的发生,但我们可以通过横向或纵向拆分数据库中的字段来实现对并发加锁的优化

例如,对于单纯用于递增记录的字段,我们可以拆分成多个字段,每次随机选取某个字段进行递增的记录

这样虽然可以有效降低单个字段上的并发度,但依赖于实际的业务,如果业务场景同时存在增减操作,那么拆分成多个字段必须要考虑是否会将某个字段减到负数等问题,在很大程度上提升了业务逻辑的复杂度

 

欢迎关注微信公众号,以技术为主,涉及历史、人文等多领域的学习与感悟,每周三到七篇推文,只有全部原创,只有干货没有鸡汤

 

 

https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html

https://dev.mysql.com/doc/refman/8.0/en/innodb-information-schema-understanding-innodb-locking.html

https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html

 






mysql            死锁      记录锁      数据库      innodb      技术贴      行锁      间隙锁      临键锁     


京ICP备15018585号