一句话结论

InnoDB 有三种锁:行锁(锁定具体行)、间隙锁(锁定行之间的间隙,防插入)、临键锁(行锁+间隙锁,InnoDB 默认)。RR 隔离级别通过临键锁解决幻读。

核心原理

三种锁

表数据: [id=5] ...(间隙)... [id=10] ...(间隙)... [id=15]

SELECT * FROM t WHERE id = 10 FOR UPDATE;
  → 临键锁: 锁定 (5,10] + (10,15)
  → 既锁了 id=10 这行,也锁了前后的间隙
  → 其他事务无法插入 id=6,7,8,9 或 id=11,12,13,14

加锁规则

查询条件

加的锁

等值查询 + 命中

临键锁(退化为行锁)

等值查询 + 未命中

间隙锁

范围查询

临键锁 + 间隙锁

唯一索引等值查询

退化为行锁

死锁

-- 会话 A
START TRANSACTION;
UPDATE t SET v=1 WHERE id=10; -- 锁 id=10

-- 会话 B
START TRANSACTION;
UPDATE t SET v=2 WHERE id=20; -- 锁 id=20

-- 会话 A
UPDATE t SET v=3 WHERE id=20; -- 等 B 释放 → 阻塞

-- 会话 B
UPDATE t SET v=4 WHERE id=10; -- 等 A 释放 → 死锁!
-- InnoDB 检测到死锁 → 回滚其中一个事务

死锁排查

-- 查看当前锁等待
SHOW ENGINE INNODB STATUS;
-- LATEST DETECTED DEADLOCK 段落显示最近死锁详情

-- 查看当前事务
SELECT * FROM information_schema.INNODB_TRX;

-- 查看锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

项目中的应用

在 电商交易系统 中,秒杀扣库存避免死锁:

-- ✅ 固定加锁顺序(先锁 product 再锁 order)
START TRANSACTION;
SELECT * FROM products WHERE id = 1 FOR UPDATE;  -- 先
INSERT INTO orders (...) VALUES (...);             -- 后
COMMIT;

速记

行锁→锁行。间隙锁→锁间隙防插入。临键锁=行+间隙(RR 默认)。唯一索引等值退化为行锁。死锁=互等→InnoDB 回滚一方。排查 SHOW ENGINE INNODB STATUS。


深入原理

一、三种锁的精确语义

+------+         +------+         +------+
|      |         |      |         |      |
| Record Lock   | Gap Lock     | Next-Key Lock |
|      |         |      |         |      |
+------+         +------+         +------+

Record Lock:    锁定索引中的一行记录
Gap Lock:       锁定索引记录之间的间隙(不锁定记录本身)
Next-Key Lock:  Record Lock + 该记录前面的 Gap Lock(InnoDB 默认)

精确图示(表中已有 id=5、10、15、20 四行):

索引记录和间隙分布:

    间隙1      记录      间隙2      记录      间隙3      记录      间隙4      记录      间隙5
    (-∞,5)    id=5     (5,10)     id=10    (10,15)    id=15    (15,20)    id=20    (20,+∞)
  
Next-Key Lock 示例(RR 下锁定 id=10):
    (-∞,5]  +  (5,10]  +  (10,15)    ← 实际锁住 (5,15)
    或更精确地说:锁住间隙 (5,10)、记录 10、间隙 (10,15)

Record Lock 示例(唯一索引等值命中 id=10):
    只锁 id=10 这一行

Gap Lock 示例(等值查询 id=7,表中无此行):
    只锁间隙 (5,10),不锁任何记录

二、不同隔离级别下的加锁行为

操作

READ UNCOMMITTED

READ COMMITTED

REPEATABLE READ

SERIALIZABLE

普通 SELECT

不加锁

不加锁(快照读)

不加锁(快照读)

自动加 S 锁(当前读)

SELECT ... FOR UPDATE

Record Lock

Record Lock

Next-Key Lock

Next-Key Lock

SELECT ... LOCK IN SHARE MODE

Record Lock

Record Lock

Next-Key Lock

Next-Key Lock

UPDATE

Record Lock

Record Lock

Next-Key Lock

Next-Key Lock

DELETE

Record Lock

Record Lock

Next-Key Lock

Next-Key Lock

INSERT

插入意向锁

插入意向锁

插入意向锁(可能被 Gap Lock 阻塞)

插入意向锁

核心差异:

  • RC: 只有 Record Lock,无 Gap Lock → 并发度高但可能出现幻读(当前读场景)

  • RR: 默认 Next-Key Lock → 防幻读但并发度降低

  • RC 的 binlog 必须用 ROW 格式(否则主从不一致),MySQL 5.7+ 默认 ROW

三、插入意向锁(Insert Intention Lock)

特殊的 Gap Lock——INSERT 操作在插入行之前,先对目标间隙加"插入意向锁"。

多个事务可以同时对同一个间隙加插入意向锁(不互斥)
但插入意向锁与 Gap Lock 互斥 → 若间隙已被 Gap Lock 锁定,插入意向锁需等待

这就是 Gap Lock 防止幻读的机制:
  事务 A: SELECT * FROM t WHERE id > 5 FOR UPDATE;  → 对 (5, +∞) 加 Gap Lock
  事务 B: INSERT INTO t VALUES (8);                  → 需要插入意向锁 → 被阻塞
  事务 A 的 Gap Lock 阻止了 B 在间隙中插入 → 防止了幻读

插入意向锁 vs Gap Lock 的兼容矩阵:

Gap Lock

插入意向锁

Record Lock

Gap Lock

兼容

冲突

兼容

插入意向锁

冲突

兼容

兼容

Record Lock

兼容

兼容

冲突

四、完整加锁实例分析

准备数据: CREATE TABLE t (id INT PRIMARY KEY, age INT, INDEX idx_age(age));
INSERT INTO t VALUES (1,10), (3,20), (5,30), (7,40), (9,50);

案例 1:主键等值查询命中

-- RR 隔离级别
SELECT * FROM t WHERE id = 5 FOR UPDATE;
-- 加锁:只对 id=5 加 Record Lock
-- 原因:主键唯一索引,等值命中 → 退化为行锁

案例 2:主键等值查询未命中

SELECT * FROM t WHERE id = 6 FOR UPDATE;
-- 表中无 id=6,id=6 落在 gap (5,7) 中
-- 加锁:对间隙 (5,7) 加 Gap Lock
-- 原因:等值未命中 → 退化为间隙锁
-- 效果:无法插入 id=6,但 id=5 和 id=7 可以自由更新(行本身未锁)

案例 3:普通索引等值查询命中

-- 表中 age 值:10, 20, 30, 40, 50
SELECT * FROM t WHERE age = 30 FOR UPDATE;
-- 加锁:
--   1. 对 age=30 的记录加 Next-Key Lock:(20,30]
--   2. 对 age=30 后面的间隙加 Gap Lock:(30,40)
--   3. 对主键 id=5 的行加 Record Lock
-- 
-- 组合效果:锁住 gap (20,40),以及 age=30 的行本身
-- 注意:即使只有一行 age=30,前后间隙都锁了

案例 4:范围查询

SELECT * FROM t WHERE id > 3 AND id < 8 FOR UPDATE;
-- 表中 id 有:1, 3, 5, 7, 9
-- 扫描到的行:id=3(不满足>3但被扫描)、id=5(满足)、id=7(满足)
-- 
-- 加锁过程:
--   先定位到 id=3(首个>=3的记录)
--   对 id=3 加 Next-Key Lock:(1,3]
--   继续扫描到 id=5:(3,5]
--   继续扫描到 id=7:(5,7]
--   继续扫描到 id=9(不满足<8),对 id=9 加 Next-Key Lock:(7,9],然后停止
-- 
-- 最终锁范围:(1,9) 全部被锁!
-- 结果:无法插入 id=2,4,6,8

五、死锁完整案例分析

案例 1:交叉加锁顺序(最常见)

-- 数据:t(id pk, val int),表中的行:id=1(val=10), id=2(val=20)

-- 时间线:
T1: 事务 A: UPDATE t SET val=11 WHERE id=1;    -- 持有 id=1 的 X 锁
T2: 事务 B: UPDATE t SET val=21 WHERE id=2;    -- 持有 id=2 的 X 锁
T3: 事务 A: UPDATE t SET val=12 WHERE id=2;    -- 等待 B 释放 id=2 的锁(阻塞)
T4: 事务 B: UPDATE t SET val=22 WHERE id=1;    -- 等待 A 释放 id=1 的锁(死锁!)
    -- InnoDB 检测到死锁 → 回滚事务 B → 事务 A 获取 id=2 的锁,继续执行

-- 死锁日志关键信息:
-- *** (1) TRANSACTION: 事务 A 在等待 id=2 的锁
-- *** (2) TRANSACTION: 事务 B 持有 id=2 的锁,在等待 id=1 的锁
-- *** (2) WAITING FOR THIS LOCK TO BE GRANTED: 锁 id=1
-- *** (1) HOLDS THE LOCK: 持有 id=1 的锁

案例 2:INSERT ON DUPLICATE KEY UPDATE 死锁

CREATE TABLE t (id INT PRIMARY KEY, val INT);

-- 事务 A:
INSERT INTO t VALUES (10, 100) ON DUPLICATE KEY UPDATE val = val + 1;
-- 行不存在 → 加插入意向锁 + 插入

-- 事务 B(同时):
INSERT INTO t VALUES (10, 200) ON DUPLICATE KEY UPDATE val = val + 1;
-- 同一行 → 插入前加插入意向锁
-- A 已插入 id=10,B 发现冲突 → 转为 UPDATE → 需对 id=10 加 X 锁
-- 但 A 还未提交,A 持有 id=10 的 X 锁 → B 等待
-- 如果 A 此时也要更新或其他操作 → 可能形成等待环

-- 解决方案:使用简单的 INSERT 或 UPDATE 代替 ON DUPLICATE KEY UPDATE
--         或用 REPLACE(注意 REPLACE = DELETE + INSERT)

案例 3:不同索引路径导致的死锁

CREATE TABLE t (
  id INT PRIMARY KEY,
  name VARCHAR(20),
  age INT,
  INDEX idx_age(age)
);
INSERT INTO t VALUES (1, 'a', 20), (2, 'b', 20), (3, 'c', 30);

-- 事务 A: UPDATE t SET name='x' WHERE age = 20;
-- 扫描方式:走 idx_age(age) 索引 → 扫描到 age=20 的两行(id=1 和 id=2)
-- 加锁顺序:先锁 id=1,再锁 id=2

-- 事务 B: DELETE FROM t WHERE id = 1;
-- 加锁顺序:锁 id=1

-- A 已经锁了 id=1 → B 等 A
-- A 继续锁 id=2 → 成功
-- 不会死锁但这个场景说明:
-- 走不同索引会导致不同的加锁顺序
-- 走 idx_age → 按 age 索引的排序顺序(先 1 后 2)
-- 走 PRIMARY → 直接定位

-- 死锁场景:
-- 事务 A: UPDATE t SET name='x' WHERE age = 20;     -- 先锁 id=1,后锁 id=2
-- 事务 B: DELETE FROM t WHERE id = 2;               -- 锁 id=2
-- 事务 B: DELETE FROM t WHERE id = 1;               -- 等 A
-- 时间重叠时可能形成复杂等待图

六、死锁预防最佳实践

// 规则 1:固定所有事务的加锁顺序
// 所有涉及多行更新的操作,按主键升序处理

public void batchUpdate(List<Long> ids) {
    Collections.sort(ids);  // 关键:先排序!
    for (Long id : ids) {
        productMapper.selectForUpdate(id);  // 按主键顺序加锁
    }
}

// 规则 2:事务尽可能短
@Transactional
public void createOrder(OrderDTO dto) {
    // ✅ 只在事务中做数据库操作
    Long orderId = orderMapper.insert(dto);
    stockMapper.decrease(dto.getProductId(), dto.getQty());
    // ❌ 不在事务中:发消息、调 RPC、写文件、做复杂计算
}
// 事务在方法结束时提交

// 规则 3:避免在事务中等待用户输入
// ❌ 错误示范:
@Transactional
public void processOrder(Long id) {
    Order order = orderMapper.selectById(id);
    // ... 等待用户确认 → 事务持有锁等待,极危险!
    // ... 用户可能几分钟后才确认
    orderMapper.updateStatus(id, "confirmed");
}

// ✅ 正确做法:先查询 → 脱离事务 → 等待用户 → 再开事务更新
public void processOrder(Long id) {
    Order order = orderMapper.selectById(id);  // 只读,自动提交
    // ... 等待用户操作 ...
    orderService.confirmOrder(id);  // 在新事务中更新
}

// 规则 4:使用索引避免锁升级
// ❌ WHERE 条件不走索引 → 全表扫描期间会锁所有行
// ✅ 确保 WHERE 条件有索引

// 规则 5:减少锁定行数
// ❌ SELECT * FROM orders WHERE status='pending' FOR UPDATE;  -- 可能锁几万行
// ✅ SELECT * FROM orders WHERE id IN (具体ID列表) FOR UPDATE;  -- 只锁具体行

深度面试追问

Q1:RR 隔离级别下 SELECT * FROM t WHERE id > 5 FOR UPDATE 到底锁了哪些范围?

30 秒回答: 从 id=5 的下一条(含)开始,一直到索引末尾,对所经过的所有行加 Next-Key Lock。实际效果是 (5, +∞) 范围的记录和间隙全被锁。

深入回答: 加锁过程是逐行扫描、逐行加 Next-Key Lock:

假设表中 id 有:1, 3, 5, 7, 9, 11

SELECT * FROM t WHERE id > 5 FOR UPDATE;

执行过程:
1. 定位到 id=5(主键索引中 >=5 的第一个值)
2. id=5 不满足 >5 → 但扫描经过它 → 加 Next-Key Lock (3,5]
3. 扫描到 id=7  → 满足 >5 → 加 Next-Key Lock (5,7]
4. 扫描到 id=9  → 加 Next-Key Lock (7,9]
5. 扫描到 id=11 → 加 Next-Key Lock (9,11]
6. 扫描到 supremum(虚拟最大记录)→ 加 Next-Key Lock (11, +∞]

最终锁范围:(3, +∞]
等效于:id=3 的行锁 + 间隙 (3,5) + 行 5 + 间隙 (5,7) + 行 7 + ...
即 id=5,7,9,11 的行锁 + (3, +∞) 所有间隙锁

追问 1: 为什么 id=5 不满足条件也被锁了?

因为 InnoDB 是逐行扫描的——它需要先到 id=5 才能知道"下一行是 id=7"。扫描经过的行如果不在结果集内,锁会被释放吗?不会——RR 下扫描过的索引行都会被加锁,直到事务结束才释放。这就是为什么有时"锁了不该锁的行"。

追问 2: 怎么减少这种范围锁?

  1. 用 RC 隔离级别(无 Gap Lock);2) 缩小范围:建精确索引;3) MySQL 8.0 的 SKIP LOCKED 或 NOWAIT 语法配合 RC 实现非阻塞锁。


Q2:怎么分析一个 SQL 具体加了什么锁?

30 秒回答: MySQL 8.0 用 SELECT * FROM performance_schema.data_locks 查看当前持有的锁。通过分析 LOCK_TYPE(TABLE/RECORD)、LOCK_MODE(X/S/GAP/INSERT_INTENTION)和 LOCK_DATA 确定锁类型。

深入回答:

-- MySQL 8.0 查看当前锁
SELECT 
    ENGINE_TRANSACTION_ID AS trx_id,
    OBJECT_NAME,
    INDEX_NAME,
    LOCK_TYPE,
    LOCK_MODE,
    LOCK_STATUS,
    LOCK_DATA
FROM performance_schema.data_locks;

-- LOCK_MODE 解读:
-- 'X' → 行 X 锁
-- 'X,GAP' → Gap Lock(X 型)
-- 'X,REC_NOT_GAP' → Record Lock(纯行锁,无间隙)
-- 'S' → 行 S 锁
-- 'S,GAP' → S 型 Gap Lock
-- 'INSERT_INTENTION' → 插入意向锁
-- 'AUTO_INC' → 自增锁
-- 'X,GAP,INSERT_INTENTION' → 插入意向锁(组合)

-- LOCK_DATA 解读:
-- 数字 → 行锁对应的索引值(如 '5' 表示 id=5)
-- 'supremum pseudo-record' → 虚拟最大记录

实战分析流程(MySQL 8.0):

-- 1. 开启事务并在一条操作前后抓取锁信息
-- 会话 A:
START TRANSACTION;
SELECT * FROM t WHERE id > 5 FOR UPDATE;

-- 2. 查看锁(另一会话)
SELECT 
    trx_id, lock_type, lock_mode, lock_data
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 't';

追问 1: MySQL 5.7 怎么看锁?

SHOW ENGINE INNODB STATUS 的 TRANSACTIONS 段落,或者 information_schema.INNODB_LOCKS 和 INNODB_LOCK_WAITS(8.0 已废弃,建议用 performance_schema)。

追问 2: LOCK_DATA 显示的是主键值吗?

取决于加锁的索引。在二级索引上加的锁:LOCK_DATA 是二级索引列值 + 主键值。在聚簇索引上加的锁:LOCK_DATA 是主键值。supremum pseudo-record 表示的是索引中的虚拟最大记录——通常出现在范围查询的末端。


Q3:业务高峰期偶尔出现死锁,如何根治?

30 秒回答: 不是"偶尔"就不管。通过死锁日志分析加锁顺序 → 统一所有事务的加锁顺序 → 缩短事务 → 考虑使用乐观锁替代悲观锁。

深入回答: 根治三部曲:

1. 抓取死锁日志:
   SHOW ENGINE INNODB STATUS\G  → 找到 LATEST DETECTED DEADLOCK
   分析涉及的 SQL、锁类型、等待关系

2. 反推根因:
   - 是哪些 SQL 导致了死锁?
   - 加锁顺序是否一致?
   - 事务是否太长?(与业务代码一起分析)

3. 修复:
   - 统一加锁顺序(按主键大小排序)
   - 拆分大事务(一个事务只做一件事)
   - 考虑乐观锁代替悲观锁
   - 死锁重试机制(应用层 try-catch + 指数退避重试)

4. 预防:
   - 降低隔离级别(RR → RC,如果业务允许)
   - 合理设计索引(减少锁范围)
   - 避免在事务中调用外部服务

项目三实践:

@Retryable(
    value = DeadlockLoserDataAccessException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 50, multiplier = 2)
)
@Transactional
public void processOrder(OrderDTO dto) {
    // 业务逻辑
}

追问 1: 如果死锁频率很高(每分钟几十次),可能是什么问题?

大概率是某个高频接口的事务内有热点行锁竞争,或者范围锁太大。排查:performance_schema.events_statements_summary_by_digest 找到最频繁的 SQL,看它的锁范围。解决方案:热点行拆分(如库存分段)、队列串行化(用 Redis 队列排队处理热点行)。

追问 2: 死锁重试会不会导致雪崩?

会!如果死锁频率高 → 大量重试 → 更多竞争 → 更多死锁。必须配合:1) 指数退避 + 随机抖动(jitter);2) 熔断机制(连续失败 N 次后直接失败返回,让上层降级);3) 从根源解决死锁而非依赖重试。