一句话结论
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),不锁任何记录
二、不同隔离级别下的加锁行为
核心差异:
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 的兼容矩阵:
四、完整加锁实例分析
准备数据: 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: 怎么减少这种范围锁?
用 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) 从根源解决死锁而非依赖重试。