04 锁与并发控制——开发者必须理解的死锁问题

摘要: 死锁不是玄学。两条看似无关的 UPDATE 为什么会互相阻塞?SELECT ... FOR UPDATE 到底锁了什么范围?间隙锁是如何在你不知情的情况下悄悄阻塞 INSERT 的?本文从 InnoDB 锁的三种基本类型(记录锁、间隙锁、临键锁)出发,推导出不同 SQL 语句在不同隔离级别下的加锁规则,然后通过真实的死锁案例演示如何读懂死锁日志、找到死锁根因,最后给出乐观锁 vs 悲观锁的工程权衡,以及六条死锁预防策略。理解了锁的底层逻辑,“死锁”就从一个神秘的黑盒变成了可以预测和防控的工程问题。


第 1 章 为什么需要锁

1.1 并发写入的本质冲突

数据库之所以需要锁,根本原因是并发事务对同一数据的读写可能产生冲突。最典型的场景是”更新丢失(Lost Update)”:

时间线        事务 A                    事务 B
T1           SELECT stock=100
T2                                    SELECT stock=100
T3           UPDATE stock=100-1=99
T4                                    UPDATE stock=100-1=99
T5           COMMIT (stock=99)
T6                                    COMMIT (stock=99)

两个事务都想扣减库存 1,但最终结果是库存只减少了 1 而不是 2——事务 B 的更新覆盖了事务 A 的结果,导致一次扣减”丢失”了。

锁的作用是让并发的冲突操作串行化:当事务 A 修改某行时,加锁阻止事务 B 同时修改,从而避免上述冲突。

1.2 InnoDB 锁的设计目标

InnoDB 锁的设计目标是:在保证数据一致性的前提下,最大化并发度。这个目标决定了 InnoDB 选择行级锁而非表级锁——行级锁的粒度更细,多个事务只要操作不同的行就不会互相阻塞,并发度远高于表级锁。

但行级锁也带来了更高的复杂度,特别是在处理范围查询和防止幻读时,需要引入比简单行锁更复杂的间隙锁机制。


第 2 章 InnoDB 的三种行锁类型

2.1 记录锁(Record Lock)

记录锁是最基础的行锁,锁定的是 B+Tree 索引中的某一条具体记录

-- 假设 id=10 这行存在,这条语句对 id=10 加记录锁
SELECT * FROM users WHERE id = 10 FOR UPDATE;

记录锁分为两种:

  • 共享锁(S Lock):允许持有锁的事务读取该行,阻止其他事务写入。SELECT ... LOCK IN SHARE MODE 加共享锁。
  • 排他锁(X Lock):阻止其他事务读取(在当前读模式下)和写入该行。SELECT ... FOR UPDATEINSERTUPDATEDELETE 都加排他锁。

锁的兼容性:

已持有 \ 申请S 锁X 锁
S 锁兼容(可以同时读)冲突(不能在读时写)
X 锁冲突(不能在写时读)冲突(不能同时写)

2.2 间隙锁(Gap Lock)

间隙锁锁定的不是某条记录本身,而是两条记录之间的”间隙”(或第一条记录之前、最后一条记录之后的空间)。它的唯一目的是防止其他事务在这个间隙内插入新记录,从而防止幻读。

间隙锁只在 REPEATABLE READ 隔离级别下存在。在 READ COMMITTED 级别下,InnoDB 不使用间隙锁。

举例:假设 users 表的 age 列有索引,表中 age 的值为 10, 20, 30。

-- 在 RR 隔离级别下
SELECT * FROM users WHERE age BETWEEN 15 AND 25 FOR UPDATE;

这条语句会对 age 在 (10, 30) 范围内的”间隙”加间隙锁(因为 15-25 落在这个范围内),阻止其他事务插入 age 在 10 到 30 之间的新记录。

生产避坑

间隙锁的范围可能比你预期的大得多。如果查询范围没有精确命中索引值,InnoDB 会锁定更大的间隙。很多看似无关的 INSERT 被阻塞,就是因为踩到了间隙锁。这也是为什么 RC 级别在高并发写入场景下性能更好——它完全不用间隙锁。

2.3 临键锁(Next-Key Lock)

临键锁是记录锁 + 间隙锁的组合,锁定的是某条记录本身以及它之前的间隙。它是 InnoDB 在 RR 级别下的默认加锁单元

以 age 值为 10, 20, 30 的表为例,临键锁的范围是:

  • (-∞, 10]
  • (10, 20]
  • (20, 30]
  • (30, +∞)

每个临键锁锁定”左开右闭”的区间。当你的查询命中某条记录时,InnoDB 会对该记录加临键锁——既锁住这条记录(防止被修改),也锁住它之前的间隙(防止插入幻影行)。

2.4 意向锁(Intention Lock)

除了行级锁,InnoDB 还有表级的意向锁,用于解决行锁和表锁之间的快速兼容性判断问题。

当事务要对某行加共享行锁时,会先对该表加意向共享锁(IS);加排他行锁前,先对表加意向排他锁(IX)

意向锁的价值在于:如果没有意向锁,要判断表上是否有某个行锁时,需要扫描整张表的所有行——代价太高。有了意向锁,只需要检查表级的意向锁标志即可。意向锁之间相互兼容,不会阻塞彼此。


第 3 章 加锁规则推导

3.1 基本原则

InnoDB 的加锁规则可以从以下几条基本原则推导出来:

  1. 加锁的对象是索引上的记录,而不是行数据本身。如果查询走的是主键索引,锁加在主键索引上;如果走的是二级索引,锁加在二级索引上,同时还要对对应的主键索引加锁(防止通过主键直接修改)。
  2. 扫描到的记录都会加锁,包括最终不满足 WHERE 条件被过滤掉的记录(在 RR 级别下)。
  3. RR 级别默认使用临键锁,在某些条件下可以退化为记录锁或间隙锁。

3.2 主键等值查询

-- 假设 id=10 这行存在
SELECT * FROM users WHERE id = 10 FOR UPDATE;

加锁结果:对 id=10 这一条记录加记录锁(临键锁退化为记录锁,因为等值查询不需要锁住间隙)。

退化原因:等值查询精确命中一条记录,不存在”在这附近插入新记录导致幻读”的问题,间隙部分没有存在的必要,退化为纯记录锁。

-- 假设 id=10 这行不存在(在 5 和 15 之间)
SELECT * FROM users WHERE id = 10 FOR UPDATE;

加锁结果:对 (5, 15) 这个间隙加间隙锁(因为没有命中记录,无法加记录锁,只能锁间隙防止插入)。

3.3 主键范围查询

SELECT * FROM users WHERE id > 10 FOR UPDATE;

假设表中有 id = 10, 20, 30。这条查询会:

  • id=20 加临键锁:锁住 (10, 20]
  • id=30 加临键锁:锁住 (20, 30]
  • (30, +∞) 加间隙锁(或临键锁,锁住最后一个间隙)

结果:其他事务无法插入任何 id > 10 的新记录,也无法修改 id=20 或 id=30 的记录。

3.4 二级索引查询

-- age 列有普通索引 idx_age
SELECT * FROM users WHERE age = 20 FOR UPDATE;

假设 age=20 对应的主键是 id=5。加锁分两部分:

  1. idx_age 索引上,对 age=20 的记录加临键锁(包含间隙锁防止插入其他 age=20 的行)
  2. 在主键索引上,对 id=5记录锁(防止通过主键直接修改这行)

3.5 UPDATE 与 DELETE 的加锁

UPDATEDELETE 的加锁规则与 SELECT ... FOR UPDATE 基本相同,区别在于:

  • UPDATE 在修改索引列时,可能需要先删除旧索引记录再插入新索引记录,涉及更复杂的锁操作
  • RC 级别下,UPDATE半一致性读(Semi-Consistent Read) 优化:如果目标行被其他事务锁定且不满足 WHERE 条件,直接跳过不等待,减少锁等待时间

第 4 章 死锁:从现象到根因

4.1 什么是死锁

死锁是两个或多个事务因为循环等待对方持有的锁而无限阻塞的状态。

最经典的死锁场景:

事务 A                          事务 B
BEGIN;                          BEGIN;
UPDATE t SET v=1 WHERE id=1;    UPDATE t SET v=2 WHERE id=2;
(A 持有 id=1 的锁)             (B 持有 id=2 的锁)

UPDATE t SET v=1 WHERE id=2;    UPDATE t SET v=2 WHERE id=1;
(A 等待 B 释放 id=2 的锁)      (B 等待 A 释放 id=1 的锁)

→ 两个事务互相等待,永远不会结束 → 死锁!

InnoDB 内置了死锁检测(Deadlock Detection) 机制:通过等待图(Wait-for Graph) 算法,当检测到事务之间存在循环等待时,自动选择一个”代价最小”的事务(通常是修改行数最少的事务)作为牺牲者(Victim) 回滚,让其他事务继续执行。

被回滚的事务会收到错误:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

4.2 读懂死锁日志

当 InnoDB 检测到死锁时,会将最近一次死锁的详细信息记录到 SHOW ENGINE INNODB STATUS 的输出中:

SHOW ENGINE INNODB STATUS\G

在输出的 LATEST DETECTED DEADLOCK 部分,可以看到死锁的详细信息。典型输出结构如下:

LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 100, ACTIVE 5 sec starting index read
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) undo log entries 1
MySQL thread id 10, OS thread handle 1234, query id 100 localhost root updating
UPDATE orders SET status=2 WHERE id=1

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 5 page no 3 n bits 72 index PRIMARY of table `shop`.`orders` 
trx id 100 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: id=1

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 5 page no 3 n bits 72 index PRIMARY of table `shop`.`orders`
trx id 100 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: id=2

*** (2) TRANSACTION:
TRANSACTION 101, ACTIVE 4 sec starting index read
...
UPDATE orders SET status=3 WHERE id=2

*** (2) HOLDS THE LOCK(S):
...id=2 的锁...

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
...等待 id=1 的锁...

*** WE ROLL BACK TRANSACTION (2)

解读关键信息

  • (1) HOLDS THE LOCK(S):事务 1 持有哪些锁
  • (1) WAITING FOR THIS LOCK TO BE GRANTED:事务 1 在等待哪个锁
  • lock_mode X locks rec but not gap:排他记录锁(不含间隙锁)
  • lock_mode X:临键锁(含间隙锁)
  • WE ROLL BACK TRANSACTION (2):InnoDB 选择回滚事务 2 作为牺牲者

核心概念

建议开启 innodb_print_all_deadlocks = ON 参数,将所有死锁信息输出到 MySQL 错误日志中(而不只是保留最近一次)。这样可以通过日志分析死锁的频率和模式,找到系统中的高危 SQL 组合。

4.3 死锁案例分析

案例一:经典的 AB-BA 死锁

-- 事务 A                          事务 B
BEGIN;                             BEGIN;
UPDATE t SET v=1 WHERE id=1;       UPDATE t SET v=2 WHERE id=2;
UPDATE t SET v=1 WHERE id=2;       UPDATE t SET v=2 WHERE id=1;
-- A 等 B 释放 id=2 的锁,B 等 A 释放 id=1 的锁 → 死锁

根因:两个事务以相反的顺序获取相同的锁。

预防:统一锁的获取顺序。例如,总是先更新 id 较小的行,再更新 id 较大的行。这样就不会出现循环等待。

案例二:间隙锁 + INSERT 的死锁

这类死锁更隐蔽,很多开发者没有意识到间隙锁的存在。

假设表 orders 有一个普通索引 idx_user_id (user_id),表中 user_id 的值为 1, 5, 10。

-- 事务 A                          事务 B
BEGIN;                             BEGIN;
SELECT * FROM orders               SELECT * FROM orders
WHERE user_id = 3 FOR UPDATE;      WHERE user_id = 7 FOR UPDATE;
-- A 对 (1, 5) 间隙加间隙锁        -- B 对 (5, 10) 间隙加间隙锁
 
INSERT INTO orders (user_id=7,...) INSERT INTO orders (user_id=3,...) 
-- A 尝试在 (5,10) 间隙插入        -- B 尝试在 (1,5) 间隙插入
-- 被 B 的间隙锁阻塞                -- 被 A 的间隙锁阻塞
→ 死锁!

根因:两个事务的间隙锁范围互相重叠,各自的 INSERT 都被对方的间隙锁阻塞。

预防:在允许的情况下,将隔离级别改为 RC(RC 不使用间隙锁),或者通过唯一索引约束避免重复插入(唯一索引约束冲突不会死锁,只会报 Duplicate Key 错误)。

案例三:UPDATE 改变索引列值导致的死锁

-- 表结构:id 主键,status 有索引,假设表中有一行 id=10, status=1
-- 事务 A
UPDATE orders SET status=2 WHERE id=10;
-- A 锁住主键 id=10 的记录,同时锁住 status 索引上 status=1 的记录,
-- 然后在 status 索引上插入 status=2 的新记录
 
-- 事务 B(同时执行)
SELECT * FROM orders WHERE status=1 FOR UPDATE;
-- B 尝试锁 status=1 索引上的记录,被 A 持有的锁阻塞
 
-- 如果 B 先锁住了 status 索引,A 在插入 status=2 时等待 B 的间隙锁 → 死锁

第 5 章 SELECT … FOR UPDATE vs SELECT … LOCK IN SHARE MODE

5.1 两种锁定读的使用场景

SELECT ... FOR UPDATE(加排他锁) 适用于:

  • “读后写”模式:先查询当前状态,再根据状态决定是否更新。必须用排他锁,否则在读和写之间可能有其他事务修改了状态。
    BEGIN;
    SELECT stock FROM products WHERE id=1 FOR UPDATE;
    -- 如果 stock > 0,扣减库存
    UPDATE products SET stock=stock-1 WHERE id=1;
    COMMIT;
  • 避免重复处理:确保一批工作项只被一个 Worker 处理。

SELECT ... LOCK IN SHARE MODE(加共享锁) 适用于:

  • 确认数据存在后再插入子记录:先加共享锁确认父记录存在,再插入子记录。
    BEGIN;
    SELECT id FROM users WHERE id=100 LOCK IN SHARE MODE; -- 确认用户存在
    INSERT INTO orders (user_id=100, ...) VALUES (...);
    COMMIT;

5.2 锁定读的风险

SELECT ... FOR UPDATE 的一个常见错误是在不必要的地方使用它,导致并发性能下降。

-- ❌ 这种用法通常是错误的:只是为了读取数据却加了排他锁
SELECT * FROM products WHERE category='手机' FOR UPDATE;
-- 这会锁住所有手机类商品,阻塞其他事务的读写,完全没必要

对于纯粹的读取操作,使用普通 SELECT(快照读)即可,不需要加任何锁。只有在”读取后必须确保该数据不被他人修改”的场景,才需要 FOR UPDATE


第 6 章 乐观锁 vs 悲观锁

6.1 悲观锁(Pessimistic Locking)

悲观锁的思路是:假设并发冲突会频繁发生,所以在读取数据时就先加锁,防止其他事务修改。SELECT ... FOR UPDATE 就是悲观锁的实现。

适合场景:

  • 数据争用激烈(同一行被频繁并发修改)
  • 事务操作时间短(持锁时间不长)
  • 不能容忍冲突失败(银行转账等必须成功的场景)

缺点:

  • 锁等待时间增加
  • 死锁概率更高
  • 持锁期间其他事务被阻塞,影响并发度

6.2 乐观锁(Optimistic Locking)

乐观锁的思路是:假设并发冲突很少发生,所以读取时不加锁,在更新时检查数据是否被修改过。如果发现被修改,更新失败,由应用层重试。

最常见的实现方式是版本号(Version) 字段:

-- 表结构中增加 version INT 字段
-- 读取时获取版本号
SELECT id, stock, version FROM products WHERE id=1;
-- 假设得到 stock=100, version=5
 
-- 更新时带上版本号作为条件
UPDATE products SET stock=99, version=version+1
WHERE id=1 AND version=5;
 
-- 检查受影响行数
-- 如果 affected_rows=1,说明更新成功(没有人抢先修改)
-- 如果 affected_rows=0,说明数据被其他事务修改了,需要重试

适合场景:

  • 数据争用不激烈(冲突概率低)
  • 可以容忍重试(业务逻辑允许失败重试)
  • 读多写少的场景(乐观锁不影响读操作的并发)

乐观锁 vs 悲观锁的工程权衡

维度悲观锁乐观锁
锁定时机读取时加锁更新时校验
并发性能较低(读也阻塞其他写)较高(读不加锁)
死锁风险无(不使用数据库锁)
冲突处理等待锁释放重试
适用场景高争用、不能重试低争用、可重试

第 7 章 六条死锁预防策略

7.1 策略一:统一加锁顺序

对于需要操作多行数据的事务,总是以固定的顺序获取锁(如按主键 ID 从小到大)。这可以彻底消除 AB-BA 型死锁。

// 好的做法:按 ID 排序后再更新
List<Long> ids = Arrays.asList(id1, id2);
Collections.sort(ids); // 确保顺序一致
for (Long id : ids) {
    updateById(id);
}

7.2 策略二:缩小事务范围

事务越小(持锁时间越短),两个事务”持锁时间重叠”的概率就越低,死锁就越不容易发生。将事务拆小,只保留核心的原子操作。

7.3 策略三:在 RC 级别下操作

如果业务允许,将隔离级别设为 READ COMMITTED。RC 级别不使用间隙锁,可以完全消除间隙锁参与的死锁,也减少了大量由于间隙锁范围覆盖而引起的”锁等待”问题。

7.4 策略四:避免大范围的 FOR UPDATE

SELECT ... FOR UPDATE 的范围越大,锁住的行越多,与其他事务冲突的概率就越高。尽量让 FOR UPDATE 的查询条件精确,只锁定真正需要的行。

-- ❌ 危险:锁住所有 pending 状态的订单
SELECT * FROM orders WHERE status='pending' FOR UPDATE;
 
-- ✅ 更安全:只锁需要处理的订单
SELECT * FROM orders WHERE id IN (1001, 1002, 1003) FOR UPDATE;

7.5 策略五:添加合适的索引

如果 WHERE 条件没有走索引,InnoDB 会扫描全表并锁住所有记录(全表临键锁),这极大地增加了与其他事务的冲突面。为常用的查询条件添加索引,使 InnoDB 只锁定精确的行范围。

7.6 策略六:对死锁做好应用层重试

即使采取了所有预防措施,死锁也无法被完全消除。应用层必须对 1213 错误码(Deadlock)做好重试逻辑

@Retryable(value = DeadlockLoserDataAccessException.class, maxAttempts = 3)
@Transactional
public void processOrder(Long orderId) {
    // 业务逻辑
}

Spring 的 @Retryable 或手动实现的重试机制,配合指数退避(Exponential Backoff),是处理死锁的最后一道防线。


第 8 章 小结

本文构建的知识链:

  1. 三种锁类型:记录锁锁住单行,间隙锁锁住行间空间(防止插入),临键锁 = 记录锁 + 间隙锁(RR 级别的默认单元)
  2. 加锁规则的推导逻辑:等值查询命中记录 → 退化为记录锁;等值查询未命中 → 间隙锁;范围查询 → 临键锁
  3. RC 不用间隙锁:这是 RC 比 RR 并发性能高的关键原因,也是 RC 无法防止幻读的原因
  4. 死锁 = 循环等待:InnoDB 通过等待图检测并自动回滚代价最小的事务
  5. 读懂死锁日志:从 SHOW ENGINE INNODB STATUSLATEST DETECTED DEADLOCK 段找出循环等待关系
  6. 乐观锁 vs 悲观锁:高争用用悲观锁,低争用且可重试用乐观锁
  7. 六条死锁预防策略:统一顺序、缩小事务、RC 级别、精确 FOR UPDATE、正确索引、应用层重试

锁和死锁是 MySQL 并发性能调优中最难的部分,但只要掌握了”锁是加在索引上的、加锁范围由查询方式决定”这个核心认知,绝大多数死锁问题都能被分析和预防。


思考题

  1. 分区表将一个逻辑表拆分为多个物理分区——查询时通过分区裁剪(Partition Pruning)只扫描相关分区。Range 分区按值范围(如按月份)拆分——适合时序数据。在一个按月分区的日志表中,WHERE log_date BETWEEN '2024-01-01' AND '2024-01-31' 只扫描 1 月的分区。但如果查询 WHERE user_id = 123(无分区裁剪),需要扫描所有分区——性能可能比非分区表更差。你如何设计查询以充分利用分区裁剪?
  2. 分区表的一个常见用途是’快速删除历史数据’——ALTER TABLE t DROP PARTITION p202301DELETE FROM t WHERE log_date < '2024-02-01' 快得多(直接删除文件而非逐行删除)。在什么数据保留策略下分区表最有价值?分区表与 TTL 机制(如 ClickHouse 的 TTL)相比有什么优劣?
  3. MySQL 的分区表有一些限制:不支持外键、唯一索引必须包含分区键、分区数量有上限(默认 8192)。在考虑使用分区表之前,你是否应该先评估分库分表方案?分区表和分库分表的适用场景有什么区别?