MySQL 8.4 锁机制:记录、间隙、next-key 与事务重试
一次 UPDATE 等待,可能是目标记录被占用;一次 INSERT 等待,却可能是尚不存在的键落入了受保护间隙。本文限定 MySQL 8.4、InnoDB,默认讨论 RR(REPEATABLE READ)。下面 SQL 与时序均未连接数据库实跑,结果是依据官方规则的预期,不是运行日志。
锁落在索引上,不只落在返回行上
InnoDB 的记录锁锁定索引记录;没有显式索引时,也会使用内部聚簇索引。S 记录锁可共享,X 记录锁与其他事务的 S/X 冲突。表级 IS/IX 表达事务准备获取行锁的意图,不表示整张表的记录都已锁住。
| 类型 | 索引键为 10、20、30 时的示意 | 主要作用 |
|---|---|---|
| 记录锁 | 仅记录 20 | 阻止冲突地修改该记录 |
| 间隙锁 | 开区间 (10,20) | 阻止其他事务向间隙插入 |
| next-key 锁 | (10,20] | 记录锁与其前方间隙锁的组合 |
间隙锁用于抑制插入;不同事务的间隙锁即使标为 S/X 也可以共存,不能套用记录锁的互斥关系。插入意向锁是插入前取得的一种间隙锁,不同位置的插入通常不互相阻塞,但会等待已有的冲突间隙保护。最大记录之后还存在由 supremum 伪记录表示的上界间隙。参见官方 InnoDB Locking。
RR 下,完整唯一键等值命中已有记录,通常只需记录锁;范围扫描通常涉及 next-key。唯一键未命中仍可能锁住插入位置的间隙,联合唯一索引只使用部分列也不能套用等值命中的结论。普通一致性 SELECT 通常不加这些行锁。
RC(READ COMMITTED)通常关闭搜索和扫描的间隙锁,但外键约束及重复键检查有例外。锁范围还依赖访问路径;缺少合适索引会扩大扫描和加锁范围,不能把 WHERE 的逻辑边界直接画成物理锁边界。参见官方 各类语句设置的锁。
双会话观察:查不到也能挡住插入
仅在独立测试库准备一次:
CREATE TABLE locking_demo (
id INT PRIMARY KEY,
balance INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO locking_demo VALUES (10,100), (20,100), (30,100);按顺序切换两个独立连接,不要把等待中的 B 当作已经执行完毕:
-- 1. A:显式 RR 事务
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM locking_demo WHERE id = 15 FOR UPDATE;
-- 预期空集,但保护 15 所在的 (10,20) 间隙
-- 2. B
START TRANSACTION;
INSERT INTO locking_demo VALUES (15,100);
-- 预期等待;此时切回 A
-- 3. A
ROLLBACK;
-- 4. B:预期 INSERT 解除等待并成功后,再清理
ROLLBACK;“查询空集,所以不会阻塞插入”是预期失败断言。若 B 等待过久触发 1205,应回滚 B 后重做,不能声称观察到了成功插入。将 A 改为命中 id=20,通常只保护记录 20;改为范围 id>=10 AND id<=20,则需要结合执行计划与实际锁信息分析边界,不照搬本例的单个间隙。
锁顺序:没有慢 SQL 也会死锁
上一轮两端均回滚后,按表格顺序执行。A、B 都先 START TRANSACTION:
| 步骤 | A | B |
|---|---|---|
| 1 | UPDATE locking_demo SET balance=balance-1 WHERE id=10; | 暂停 |
| 2 | 暂停 | UPDATE locking_demo SET balance=balance-1 WHERE id=20; |
| 3 | UPDATE locking_demo SET balance=balance+1 WHERE id=20;,预期等待 | 暂停 |
| 4 | 等待中 | UPDATE locking_demo SET balance=balance+1 WHERE id=10; |
A 持有 10 等待 20,B 持有 20 等待 10,等待图成环。默认开启检测时,预期某个事务收到 1213 并整事务回滚;不能预先指定牺牲者。另一方解除等待后也应 ROLLBACK,恢复示例数据。这里的 1213 是预期失败场景,未实际触发。
业务应让所有路径以相同表顺序、相同主键顺序获取锁,例如都先 10 后 20;明确逐条锁定比假定 IN (10,20) 的书写顺序就是锁顺序可靠。保持事务短小,把远程调用移出持锁区间。二级索引、外键与重复键检查仍可能引入额外锁,所以统一顺序降低风险,却不能替代重试。依据官方 死锁处理建议。
检测:从等待关系找原因
默认 innodb_deadlock_detect=ON 会检测 InnoDB 的死锁并选择事务回滚;关闭后依赖 innodb_lock_wait_timeout 解围,并不意味着死锁消失。检测对跨存储引擎等场景存在边界,热点下的检测也有成本,见官方 Deadlock Detection。
-- 观察连接 C:以下命令也未执行
SELECT * FROM performance_schema.data_lock_waits;
SELECT ENGINE_TRANSACTION_ID, INDEX_NAME, LOCK_TYPE,
LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
SHOW ENGINE INNODB STATUS;等待视图关联请求方与阻塞方,再结合锁表确认索引及记录;死锁解除后,现场等待可能已消失。STATUS 的最近一次死锁也可能被后续事件覆盖。需要持续记录时,可由有权限者评估开启 innodb_print_all_deadlocks,本文未修改任何实例配置。
重试必须覆盖整个业务事务
1213 回滚整个事务;1205 默认仅回滚超时语句,先前修改和锁可能仍留在事务内,启用 innodb_rollback_on_timeout 时行为不同。统一恢复策略可以先明确回滚,再从业务事务入口重试,而非只重放最后一句。依据官方 错误处理。
最多尝试 3 次: # 伪代码,非 Java 实测
开始新事务
按统一顺序加锁;重新读取并校验业务条件;完成全部写入
提交成功 -> 返回
捕获错误:
明确回滚;回滚失败则丢弃连接
仅对已识别的 1213 / 可重试的 1205,且未超次数时继续
在事务外执行有上限的指数退避并加入随机抖动
其他错误或次数耗尽 -> 上抛重试要重新校验余额等条件,并保留业务幂等键。提交时连接中断可能意味着提交结果未知,不能仅按“网络错误”盲目重做;应查询幂等记录确认结果。短信、支付等外部副作用不能随事务任意重复,可采用事务内写 outbox、提交后幂等投递的设计。
相关阅读
继续阅读:MVCC 与隔离级别、MySQL 索引、事务隔离级别、Spring 事务提交边界。站内链接用于延伸阅读,技术依据为本文链接的 MySQL 官方手册。
