MySQL 行级锁

上一篇文章中,我们讲了 MySQL 的全局锁和表级锁,今天我们再来聊聊行级锁。

我们知道 MySQL 有很多存储引擎,其实不同的存储引擎对于锁的实现也是不一样的。比如 InnoDB 支持行级锁,而 MyISAM 就不支持。所以,这篇文章我们就基于 InnoDB 来讲一些锁的知识。

行级锁,顾名思义,就是用于锁定表中的特定行,两个不同的事务无法同时对同一条记录进行更新。MySQL 中的行级锁可以分为这几种:Record Lock(记录锁)、Gap Lock(间隙锁)、Next-Key Lock(临键锁)。

Record Lock

Record Lock 仅针对某一条记录加锁,其他记录不受影响,而且它分为共享锁和排他锁。

共享锁也被称为读锁, 一个事务获取共享锁后,可以读取被锁定的行,但不能对该行进行修改。

比如这个语句,就会对 id = 1 的行记录加一个共享锁。

SELECT * FROM table_name WHERE id = 1 LOCK IN SHARE MODE;
Java

排他锁也被称为写锁, 一个事务获取排他锁后,可以读取和修改被锁定的行,同时阻止其他事务对该行加任何锁也就是共享锁或排他锁。

这个语句就会对 id = 1 的行记录加一个排他锁。

SELECT * FROM table_name WHERE id = 1 FOR UPDATE;
Java

共享锁和排他锁会发生冲突,如果一个事务对一条记录加了共享锁,那么另一个事务再对这条记录加排他锁时就会被阻塞。

Gap Lock

Gap Lock 是间隙锁,顾名思义,它锁住的是两条记录之间的间隙,而不是某个具体的行。 它的作用是在一定范围内阻止其他事务在该间隙中插入新记录,从而避免幻读的产生。

当一个事务进行范围查询,并通过 FOR UPDATE 或 LOCK IN SHARE MODE 加锁时,MySQL 会对查询涉及的范围加 Gap Lock。

比如现在有一个表:

id name
1 Alice
5 Bob
10 Charlie

现在开启一个事务:

START TRANSACTION;
SELECT * FROM employees WHERE id > 5 FOR UPDATE;
Java

此时,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

如果我现在开启一个事务:

START TRANSACTION;
SELECT * FROM employees WHERE id >= 5 FOR UPDATE;
Java

InnoDB 就会对 id =5 ,id =10 ,id = 20 这三条记录加 Record Lock,同时用 Gap Lock 锁住这三个范围:(1, 5]、(5, 10]、(10, 20],防止其他事务在此期间插入新的记录。

两阶段锁

基于上面讲的三个概念,我们再来学习一下两阶段锁这个协议。

两阶段锁协议主要用于并发事务中,它分为加锁和解锁这两个阶段。

加锁阶段是指,在事务的执行过程中,需要时加锁,可以加各种类型的锁,比如行锁、表锁等。这个阶段只允许加锁,不允许解锁。

解锁阶段是指,当事务提交或者回滚时释放所有持有的锁。这个阶段只允许解锁,不允许再加锁。

现在有一个场景: 我们需要从 id = 1 的账户转出 50 元,并将其转入 id = 2 的账户。

START TRANSACTION;

-- 1. 查询账户1的余额
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;

-- 2. 扣除账户1的余额
UPDATE accounts SET balance = balance - 50 WHERE id = 1;

-- 3. 查询账户2的余额
SELECT balance FROM accounts WHERE id = 2 FOR UPDATE;

-- 4. 增加账户2的余额
UPDATE accounts SET balance = balance + 50 WHERE id = 2;

-- 5. 提交事务
COMMIT;

Java

当事务在执行第一步和第三步时,InnoDB 会分别对 id = 1 和 id = 2 的这两条记录加排他锁,并且这些锁会被保留,直到当前事务提交或者回滚。当事务执行到 COMMIT 语句时,这些锁就会被释放。

如果这个场景中,没有使用两阶段锁协议,会发生什么?

假设现在有两个事务,事务 A 和事务 B 。事务 A 正在执行转账,当它执行完第二步时,就释放了 id = 1 这条记录的锁。这时,事务 B 来读取账户 1 的余额, 但账户 2 的余额尚未增加。那么事务 B 读取到的数据就是错误的。

也就是说,如果不使用两阶段锁协议,就会导致其他事务看到中间状态,导致读取数据的错误。

死锁

相信很多人都听说过死锁这个东西,我们就来仔细聊一聊,看看它究竟是个啥。

话不多说,先上定义。

死锁是指两个或多个事务在持有资源锁时,发生了互相等待对方资源的情况,导致这些事务都无法继续执行。

死锁的发生需要同时满足这四个条件:

  1. 互斥:一个资源一次只能被一个事务占用。
  2. 请求与保持:一个事务在等待资源的同时,已经占有了其他资源。
  3. 不可剥夺:已被事务占用的资源,在事务完成前不能被强行剥夺。
  4. 循环等待:存在事务之间的循环等待链,例如:事务 A 等待事务 B 的资源,事务 B 又在等待事务 A 的资源。

我们从一个具体的场景入手。

现在有一个事务 A :

START TRANSACTION;
-- 锁住 id = 1
UPDATE accounts SET balance = balance - 50 WHERE id = 1;

-- 等待事务 B 释放 id = 2 的锁
UPDATE accounts SET balance = balance + 50 WHERE id = 2;

Java

还有一个事务 B :

START TRANSACTION;
-- 锁住 id = 2
UPDATE accounts SET balance = balance - 30 WHERE id = 2;

-- 等待事务 A 释放 id = 1 的锁
UPDATE accounts SET balance = balance + 30 WHERE id = 1;

Java

按照时间顺序来看,就是这样的:

时间 事务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 相互等待,形成循环依赖,导致死锁。

对于死锁的解决办法,常见的一般有两种:

  1. 第一种是设置合理的等待时间, 通过设置 innodb_lock_wait_timeout 参数,限制事务等待锁的时间。
  2. 第二种是死锁检测,通过设置 innodb_deadlock_detect 参数来发起死锁检测,只要检测到了死锁,就主动回滚死锁链中的某个事务。

但是这两种解决方案都是有缺点的。

第一种方案中,如果设置的等待时间太长, 等待的事务会阻塞其他事务;如果设置的等待时间太短,就无法判断是简单的锁等待还是出现了死锁。所以这种方案难以确定合理的超时时间。

第二种方案中,死锁检测的时间复杂度为 O(n²),其中 n 是当前等待锁的事务数量。当事务数量增加时,死锁检测的开销会显著增大。另外在高并发场景下,锁等待的事务数量可能非常多,每次死锁检测都会占用大量的 CPU 和内存资源。即使没有实际发生死锁,频繁的死锁检测也会增加数据库的负载,导致性能下降。

那我们应该如何优化死锁检测的逻辑呢?

第一种就是关闭死锁检测,如果我们能确保某个业务场景不会发生死锁,那么就可以把死锁检测关闭。

第二种就是限制并发量,既然死锁检测的时间复杂度是 O(n²),那就让这个 n 尽可能地小。

总结

今天这篇文章,讲解的主要是关于行级锁的有关知识,同时,我们也介绍了两阶段锁和死锁的概念,以及死锁的解决思路。

发表评论

后才能评论