MySQL 行级锁
上一篇文章中,我们讲了 MySQL 的全局锁和表级锁,今天我们再来聊聊行级锁。
我们知道 MySQL 有很多存储引擎,其实不同的存储引擎对于锁的实现也是不一样的。比如 InnoDB 支持行级锁,而 MyISAM 就不支持。所以,这篇文章我们就基于 InnoDB 来讲一些锁的知识。
行级锁,顾名思义,就是用于锁定表中的特定行,两个不同的事务无法同时对同一条记录进行更新。MySQL 中的行级锁可以分为这几种:Record Lock(记录锁)、Gap Lock(间隙锁)、Next-Key Lock(临键锁)。
Record Lock
Record Lock 仅针对某一条记录加锁,其他记录不受影响,而且它分为共享锁和排他锁。
共享锁也被称为读锁, 一个事务获取共享锁后,可以读取被锁定的行,但不能对该行进行修改。
比如这个语句,就会对 id = 1 的行记录加一个共享锁。
排他锁也被称为写锁, 一个事务获取排他锁后,可以读取和修改被锁定的行,同时阻止其他事务对该行加任何锁也就是共享锁或排他锁。
这个语句就会对 id = 1 的行记录加一个排他锁。
共享锁和排他锁会发生冲突,如果一个事务对一条记录加了共享锁,那么另一个事务再对这条记录加排他锁时就会被阻塞。
Gap Lock
Gap Lock 是间隙锁,顾名思义,它锁住的是两条记录之间的间隙,而不是某个具体的行。 它的作用是在一定范围内阻止其他事务在该间隙中插入新记录,从而避免幻读的产生。
当一个事务进行范围查询,并通过 FOR UPDATE 或 LOCK IN SHARE MODE 加锁时,MySQL 会对查询涉及的范围加 Gap Lock。
比如现在有一个表:
id | name |
---|---|
1 | Alice |
5 | Bob |
10 | Charlie |
现在开启一个事务:
此时,InnoDB 会加 Record Lock 锁住 id = 10 的这条记录,同时加 Gap Lock 锁住 (5, 10) 之间的间隙,防止其他事务在这个区间插入新记录。
Next-Key Lock
Next-Key Lock 是 Record Lock 和 Gap Lock 的组合锁。Next-Key Lock 不仅锁住了目标记录本身,还锁住了该记录前后的间隙。
id | name |
---|---|
1 | Alice |
5 | Bob |
10 | Charlie |
15 | David |
如果我现在开启一个事务:
InnoDB 就会对 id =5 ,id =10 ,id = 20 这三条记录加 Record Lock,同时用 Gap Lock 锁住这三个范围:(1, 5]、(5, 10]、(10, 20],防止其他事务在此期间插入新的记录。
两阶段锁
基于上面讲的三个概念,我们再来学习一下两阶段锁这个协议。
两阶段锁协议主要用于并发事务中,它分为加锁和解锁这两个阶段。
加锁阶段是指,在事务的执行过程中,需要时加锁,可以加各种类型的锁,比如行锁、表锁等。这个阶段只允许加锁,不允许解锁。
解锁阶段是指,当事务提交或者回滚时释放所有持有的锁。这个阶段只允许解锁,不允许再加锁。
现在有一个场景: 我们需要从 id = 1 的账户转出 50 元,并将其转入 id = 2 的账户。
当事务在执行第一步和第三步时,InnoDB 会分别对 id = 1 和 id = 2 的这两条记录加排他锁,并且这些锁会被保留,直到当前事务提交或者回滚。当事务执行到 COMMIT 语句时,这些锁就会被释放。
如果这个场景中,没有使用两阶段锁协议,会发生什么?
假设现在有两个事务,事务 A 和事务 B 。事务 A 正在执行转账,当它执行完第二步时,就释放了 id = 1 这条记录的锁。这时,事务 B 来读取账户 1 的余额, 但账户 2 的余额尚未增加。那么事务 B 读取到的数据就是错误的。
也就是说,如果不使用两阶段锁协议,就会导致其他事务看到中间状态,导致读取数据的错误。
死锁
相信很多人都听说过死锁这个东西,我们就来仔细聊一聊,看看它究竟是个啥。
话不多说,先上定义。
死锁是指两个或多个事务在持有资源锁时,发生了互相等待对方资源的情况,导致这些事务都无法继续执行。
死锁的发生需要同时满足这四个条件:
- 互斥:一个资源一次只能被一个事务占用。
- 请求与保持:一个事务在等待资源的同时,已经占有了其他资源。
- 不可剥夺:已被事务占用的资源,在事务完成前不能被强行剥夺。
- 循环等待:存在事务之间的循环等待链,例如:事务 A 等待事务 B 的资源,事务 B 又在等待事务 A 的资源。
我们从一个具体的场景入手。
现在有一个事务 A :
还有一个事务 B :
按照时间顺序来看,就是这样的:
时间 | 事务A | 事务B |
---|---|---|
T1 | begin | begin |
T2 | 更新 id = 1,并上锁 | |
T3 | 更新 id = 2,并上锁 | |
T4 | 尝试更新 id = 2,等待事务 B 释放锁 | |
T5 | 尝试更新 id = 1,等待事务 A 释放锁 |
我们来分析一下这个过程:
事务 A 首先锁住了 id = 1 的记录。然后试图获取 id = 2 的锁,但 id = 2 已经被事务 B 锁住,因此 A 进入等待状态。
事务 B 首先锁住了 id = 2 的记录。然后试图获取 id = 1 的锁,但 id = 1 已经被事务 A 锁住,因此 B 进入等待状态。
那么,最后的结果就是,事务 A 和事务 B 相互等待,形成循环依赖,导致死锁。
对于死锁的解决办法,常见的一般有两种:
- 第一种是设置合理的等待时间, 通过设置 innodb_lock_wait_timeout 参数,限制事务等待锁的时间。
- 第二种是死锁检测,通过设置 innodb_deadlock_detect 参数来发起死锁检测,只要检测到了死锁,就主动回滚死锁链中的某个事务。
但是这两种解决方案都是有缺点的。
第一种方案中,如果设置的等待时间太长, 等待的事务会阻塞其他事务;如果设置的等待时间太短,就无法判断是简单的锁等待还是出现了死锁。所以这种方案难以确定合理的超时时间。
第二种方案中,死锁检测的时间复杂度为 O(n²),其中 n 是当前等待锁的事务数量。当事务数量增加时,死锁检测的开销会显著增大。另外在高并发场景下,锁等待的事务数量可能非常多,每次死锁检测都会占用大量的 CPU 和内存资源。即使没有实际发生死锁,频繁的死锁检测也会增加数据库的负载,导致性能下降。
那我们应该如何优化死锁检测的逻辑呢?
第一种就是关闭死锁检测,如果我们能确保某个业务场景不会发生死锁,那么就可以把死锁检测关闭。
第二种就是限制并发量,既然死锁检测的时间复杂度是 O(n²),那就让这个 n 尽可能地小。
总结
今天这篇文章,讲解的主要是关于行级锁的有关知识,同时,我们也介绍了两阶段锁和死锁的概念,以及死锁的解决思路。