InnoDB 锁机制详解:透彻理解各种锁的研究笔记

InnoDB 锁机制详解:透彻理解各种锁的研究笔记
网上讲 InnoDB 锁的资料很多但多数是名词解释的堆砌——背得下来遇到问题还是推不动。 这篇是我把每种锁都追问了一遍“它为什么存在、解决什么问题、和其他锁是什么关系”之后 整理出的一张有内在逻辑的地图。最后用一个发号器死锁的小例子验证这张地图能不能落地。全文取“使用者”视角而非“内核研究者”视角不为背概念只为写业务代码时不踩锁的坑。0. 先立框架锁是模式 × 粒度的二维组合聊锁最常见的混乱是把不同维度的概念放在一起并列。先把坐标系立起来锁模式 S共享 / X排他 ← 决定冲不冲突 × 组合 锁粒度 表锁含意向锁 IS/IX、MDL 行锁 ┬ Record Lock 锁记录本身 ├ Gap Lock 锁空隙禁插入RR 独有 ├ Next-Key Record Gap └ 插入意向锁 你想插入空隙时有 Gap 锁被堵住并且拿一个插入意向锁排队(意思是我要往XX间隙里插入数据)X 锁是模式行锁是粒度两者是组合关系——平时说的行锁多数指行级 X 锁。 下面按这张图逐格展开。1. 锁模式S 与 X模式全称语义谁会加S 锁Shared共享锁读锁我在读别人可以一起读不能改SELECT ... FOR SHAREX 锁eXclusive排他锁写锁我在改读锁写锁都别想加UPDATE/DELETE/INSERT/SELECT ... FOR UPDATE兼容矩阵一句话记忆S 和 S 是朋友X 和谁都不是朋友。死锁环上互相等的全是 X 锁。三个基本事实是后面一切推理的地基普通 SELECT 什么锁都不加。不带 FOR UPDATE / FOR SHARE 的查询走 MVCC 快照读 完全不碰锁系统——这是读多写少的系统在默认隔离级别下依然飞快的原因。锁不是语句结束就放而是事务提交才放两阶段锁协议。一条 UPDATE 执行完 锁还扣在手里直到 COMMIT/ROLLBACK。事务多长锁就多长。锁的持有者是事务不是连接、不是程序。分析并发问题时主语永远是事务。冷知识为什么叫 X命名出自 1975 年 IBM System R 项目 Jim Gray 的论文《Granularity of Locks and Degrees of Consistency in a Shared Data Base》——现代数据库锁理论的开山文献。X 取自 eXclusive X 自带“打叉、封锁”的视觉语义 而且配套的意向锁能叫 IX。此后 50 年Oracle、PostgreSQL、SQL Server 全部沿用。2. 表级锁与意向锁一个为什么不直接查的推导2.1 问题ALTER TABLE 怎么确认表里没有行锁表级 X 锁ALTER TABLE、LOCK TABLES动工前必须确认表里任何一行上都没有别人的行锁。 但行锁在锁管理器里是按数据页哈希组织的不存在按表查全部行锁的索引——直接查等于全量扫描。顺着怎么高效查往下推导第一步优化给每张表维护一个增量更新的汇总标记——每次加行锁时表级标记 1释放 -1。 查询变成 O(1)。第二步补漏光查不够。查完没行锁到真正锁表之间有空窗新行锁还是能溜进来。 必须让行锁申请者也在这个标记上排队——检查和阻断合并成原子操作。第三步收编把这个可排队的汇总标记做成一种锁模式塞进 S/X 兼容矩阵—— 等待队列、唤醒、排队调度、死锁检测全部复用锁管理器现有机制零新代码。后文第 4 节的插入意向锁也是同一套思路做成锁模式放进队列充当唤醒抓手死锁检测也是扫描这个等待队列。推导到底得到的东西就叫意向锁意向锁 行锁在表级的冗余汇总分读/写两档IS/IX 一个让表级与行级操作互相排队的原子闸门。注意这里的意向锁和插入意向锁不是同一个东西IS 声明我要读表里某些行IX 声明我要写表里某些行。加行锁前自动先挂意向锁强制、无感知。2.2 兼容矩阵与两条铁律已持有 → 想申请 ↓ISIXS表锁X表锁IS✅✅✅❌IX✅✅❌❌S✅❌✅❌X❌❌❌❌铁律一意向锁家族内部无条件全兼容左上 2×2 全 ✅。它们都只是预告不构成实际占用 真正的冲突判定下沉到行级——写不同行并行无碍撞同一行在那一行的 X 锁上分胜负。铁律二意向锁是向上汇报的不是向下管辖的。行与行之间的冲突由行锁自己判定 IS/IX 全程不参与它唯一拦的对象是表级 S/X 申请者。最容易理解反的就是这点—— 意向锁不是当有人来读写行时判断要不要排斥来读写行的人根本不看它。2.3 开发者需要关注意向锁吗日常写代码不用。全自动、对 DML 零影响、没有显式语法、加锁成本纳秒级。 推演两个事务会不会互等时意向锁可以直接从脑内删除。读死锁日志要认识它然后跳过它。SHOW ENGINE INNODB STATUS里的TABLE LOCK ... lock mode IX永远不是死锁原因IX 全兼容构不成互等是例行记账 真凶在RECORD LOCKS行里的lock_mode X、gap before rec、insert intention字样。 新手读死锁日志最常见的弯路就是盯着 IX 研究半天。2.4 顺带DDL 为什么不需要业务停机程序一直在跑MDL 读锁/意向锁此起彼伏ALTER 怎么等到独占答案是它不等空档它入队掐流 锁队列先来先服务ALTER 的请求入队后新事务全排它后面增量掐断它只等在场的存量事务 跑完——OLTP 事务毫秒级提交几十毫秒队列放干。机制的唯一天敌是长事务存量清不掉 ALTER 排队当路障全表新查询堆积连接池打满雪崩。这也是 DBA 变更前先杀长事务、 给 ALTER 设lock_wait_timeout的原因。3. 行锁三形态Record、Gap、Next-Key假设索引上现有 k 10、20、30 三条记录gap gap gap gap (-∞ ──────) 10 (──────) 20 (──────) 30 (────── ∞) ↑记录 ↑记录 ↑记录锁范围例子RR 下Record Lock 记录锁只锁存在的那一行UPDATE ... WHERE k20唯一索引命中→ 锁记录 20Gap Lock 间隙锁两条记录之间的空隙只禁插入不锁记录WHERE k25 FOR UPDATE25 不存在→ 锁间隙 (20,30)Next-Key Lock 临键锁记录 它前面的间隙左开右闭范围扫描的默认加锁单位如 (10,20]3.1 范围查询一条 SQL多把 Next-KeyNext-Key 的单位是每条被扫到的记录范围查询扫多条就加多把WHERE k10→ 锁 (10,20] (20,30] (30,∞)整个右半轴。20、30 不能改不能删 任何 k10 的新行都插不进来。最后一段挂在页内的 supremum正无穷伪记录上—— 死锁日志里的lock on supremum就是它。WHERE k10→ 一行都匹配不到照样锁 (-∞,10]。扫描必须走到记录 10 才能确认没有匹配连不满足条件的记录 10 本身都被锁了Next-Key 左开右闭含右端点。对照记忆等值未命中锁纯 gap不含端点范围扫描扫到哪条记录哪条吃 Next-Key含端点。3.2 Gap 锁的两个关键性格RR 独有。它存在的唯一使命是防幻读RR 承诺同事务内两次范围查询结果一致 所以必须把空隙锁住不许插入。RC 下没有 gap 锁。gap 锁与 gap 锁互相兼容。两个事务可以同时持有同一个空隙的 gap 锁——大家都只是 不许别人插入没人真占着。这个反直觉的兼容性正是后文经典死锁的伏笔。3.3 一个决定性的执行细节UPDATE 先锁后判断InnoDB 执行 UPDATE 的真实流程按索引定位行 → 每扫到一行先加 X 锁 → 再判断剩余 WHERE 条件 → 满足才改加锁发生在判断之前所以改了 0 行不等于没加锁。不匹配的行锁放不放隔离级别决定隔离级别扫到但不匹配的行READ COMMITTED半一致性读优化判断完不匹配立即放锁REPEATABLE READMySQL 默认锁不放扣到事务结束推论RR 下一条UPDATE ... WHERE k? AND 余额?在余额不足时什么都没改成 却白拿了一把记录锁如果 k 对应的行不存在白拿的是一把gap 锁。 这是并发事故分析里最容易漏掉的一类“隐形持锁”。如果不加锁后面再读到这个 k 时有可能别人插进去了就不是 0 行了——这就是幻读RR 靠这把 gap 锁把它挡住。4. 插入意向锁只在受阻时现身的借道凭证4.1 INSERT 的两条路径INSERT k25 检查 (20,30) 间隙上有没有别人的 gap/Next-Key 锁 ├─ 没有 → 直接写入连显式锁都不创建见第 5 节隐式锁——快路径零开销 └─ 有 → 创建插入意向锁等待态挂入锁队列事务挂起等 gap 锁释放插入意向锁是一张等待票据只有需要排队时才被实体化。它的三个作用缺一不可给锁队列一个可授予、可唤醒的实体——插入意向锁登记在它想插入的那个间隙上 间隙上的 gap 锁一释放锁管理器扫描这个间隙的等待队列找到登记在此的插入意向锁唤醒它给死锁检测器提供边——wait-for 图里T1 在等 T2 的 gap 锁这条边就是它的实体把锁语义收窄到最小。如果受阻者申请的是普通 gap 锁拿到后会冻结整个间隙 误伤想插同间隙其他位置的第三者。插入意向锁的语义只有我要在间隙某一点落一条记录插入意向锁之间互相兼容插不同位置并排过它单向地被 gap 锁挡自己不挡任何人。一句话gap 锁是占地插入意向锁是借道——借道的互相让行只有占地的能拦借道的。4.2 命名陷阱插入意向锁和表级意向锁 IS/IX 是两种完全不同的锁只是共用意向二字共同的命名逻辑 为一个更细粒度的后续动作提前打招呼。区别要分清意向锁 IS/IX插入意向锁级别表级行级官方归类为 gap 锁变种但别被带偏它只是声明“我要往哪个间隙插入”不锁任何范围跟谁冲突只跟表级 S/X跟别人的 gap / Next-Key 锁要不要关注不用要——行级死锁分析的主角4.3 经典死锁模型背下来T1 持有 gap(20,30)想往里 INSERT → 插入意向锁被 T2 的 gap 锁挡住 ⏳ T2 也持有 gap(20,30)gap-gap 兼容双方都拿到了也想 INSERT → 被 T1 挡住 ⏳ → 互相等待死锁诡异之处两个事务持的是同一个空隙的兼容锁本来相安无事坏就坏在都想再插入。 同 gap 并持 双双插入是 InnoDB 死锁日志里出镜率最高的模型REPLACE INTO/INSERT ... ON DUPLICATE KEY/ 唯一键冲突检查都可能踩进来。5. 隐式锁不占锁表的锁5.1 机制快路径 INSERT 之后锁表里关于新行一条记录都没有新行靠什么保护InnoDB 每条记录都有隐藏列 DB_TRX_ID最后修改它的事务 ID本来就为 MVCC 存在。规则任何事务想锁某行之前先看 DB_TRX_ID——若该事务还活跃视同这行上存在一把它持有的 X 锁。锁不在锁表里而在这条检查规则里。这就是隐式锁implicit lock。转换过程T2 想锁 T1 刚插入的行 1. 查锁表 → 空 2. 读行的 DB_TRX_ID → 是 T1 3. T1 还活跃 → T2 【替 T1】在锁表里补登记一把显式 X 锁持有者T1已授予 4. T2 给自己建一把等待态的锁排在后面 5. T1 提交 → 释放 → T2 被唤醒最反直觉的是第 3 步转换动作不是锁的主人做的而是来抢锁的人代劳的。T1 插完就没再管过这行它甚至不知道自己的隐式锁被实体化了。5.2 为什么这么设计INSERT 是最高频写操作绝大多数新行在事务存活期内没人竞争给每次插入都建锁结构是纯浪费。 隐式锁把成本从人人走的快路径转移到极少数冲突路径——惰性实体化。 这也解释了死锁日志里为什么看不到隐式锁锁表里没有它能看到的都是已实体化的显式锁。5.3 作用边界隐式锁挡写和加锁读UPDATE/DELETE/FOR UPDATE 排队等提交不挡普通快照读—— 别的事务的普通 SELECT 读不到未提交的新行不是被锁挡住等待而是 MVCC 让这行对它不可见。 两条机制结果相似拿不到你的新数据本质完全不同一个是阻塞一个是无视。6. 补一块拼图事务边界决定锁的生命周期第 1 节说过“事务多长锁就多长”。在 Java 应用里事务边界不由 SQL 决定 由 Spring 的传播行为Propagation决定——这是 Spring 在 Java 层对“用哪个连接、 何时发 BEGIN/COMMIT”的编排数据库本身没有“传播”概念。三种常用传播行为一张表速览外层有事务时的行为传播行为物理事实锁的命运REQUIRED默认加入外层同一连接、同一事务一起生一起死我加的锁被外层事务扣押到它 commitREQUIRES_NEW挂起外层拿新连接新开事务内层提交即落袋我的锁我做主方法结束就释放NESTED同一事务内打 savepoint内层可局部回滚锁仍随外层回滚到 savepoint 也不提前放锁与锁分析直接相关的只有一条REQUIRED默认值在调用方有事务时会“加入”它——你方法里加的所有锁 要等调用方的整个事务提交才释放。这意味着一个本该几毫秒的热点行更新锁持有时长实际由调用方决定而调用方今天没事务、 明天加个Transactional你的锁窗口就静默膨胀几个数量级没有任何告警。 对发号器、计数器这类热点行操作用REQUIRES_NEW把“独立短事务、改完就提交放锁” 固化下来是防御性的写法。它具体能坏到什么程度第 7 节的例子里会看到。7. 举个例子一个发号器的死锁用上面的知识 60 秒推完一个数据库发号器seq_master总号池 seq_pool_0~9十张分表缓存号段。取号逻辑独立短事务-- ①取号分表随机挑一张号段够就扣减 k为日期业务代码 20260705Z UPDATE seq_pool_3 SET idlast_insert_id(id1), numnum-1 WHERE k? AND num1 -- ②号段耗尽 → 去总池续领 UPDATE seq_master SET idlast_insert_id(id?) WHERE k? -- ③总池也没有这个 key每天第一次→ 初始化 REPLACE INTO seq_master(k,id) VALUES(?,?) -- ④把号段写回分表 REPLACE INTO seq_pool_3(k,id,num) VALUES(?,?,?)生产环境每天零点前后死锁报警 用本文知识推死锁环只需要三步第一步找隐形持锁3.3 节。零点日切key 是日期业务线全是新值 ①②两条 UPDATE 都命中 0 行——但 RR 下未命中也持锁且行不存在白拿的是gap 锁。 今天所有新 key 都比昨天的大全落在索引最右侧同一个间隙里。第二步套经典模型3.2 4.3 节。T1keyk1和 T2keyk2并发 两者的②都未命中 → 各拿同一间隙的 gap 锁gap-gap 兼容双双成功 接着两者都执行③ REPLACE 插入→ 各自的插入意向锁被对方的 gap 锁挡住→ 环闭合死锁。--发现有人持有gap锁就 准备插入意向锁排队 分表上的①④是同一配方的翻版①未命中拿 gap④ REPLACE 插入互撞。第三步注意错误写法。catch 补在 UPDATE 上但卡死点是 REPLACE——按异常堆栈打补丁 没推出环。且 InnoDB 杀死锁牺牲者时回滚整个事务--数据库回滚时事务结束在 Spring 事务里做语句级重试 实际跑在隐式新事务上前半段数据已丢、Spring 却继续 commit——比死锁更糟的静默不一致。死锁重试必须在事务边界外整体重试。7.1 再埋一个雷调用方也加了事务且事务里不止取一次号6 节隐患的具体化取号方法声明的是Transactional(propagation REQUIRED)。目前调用方都没开事务 每次取号都是独立小事务取完即提交放锁。但只要未来某个调用方写成这样Transactional public void doRefund() { String refundId seqIdService.nextId(key); // 取号①’锁被外层扣押不再是取完就放 // ... 中间一堆业务查库、调接口、写退款单 ... String receiptId seqIdService.nextId(key); // 取号②’同一事务内的第二把锁 } // ← 到这里才 commit两把锁才一起释放三个条件瞬间凑齐**锁被长事务扣押REQUIRED 加入外层 一个事务持多把锁取了两次号加锁顺序不可控分表下标随机**。两个并发请求就能凑出教科书级的 AB-BA 死锁时刻事务 T1事务 T2t1取号①’随机到 seq_pool_2 → 锁住它的 k 行 ✅t2取号①’随机到 seq_pool_7 → 锁住它的 k 行 ✅t3执行中间业务锁扣着不放执行中间业务锁扣着不放t4取号②’随机到 seq_pool_7 → 被 T2 占着等 ⏳t5取号②’随机到 seq_pool_2 → 被 T1 占着等 ⏳ →死锁注意这个雷的阴险之处它不需要日切、不需要 gap 锁两把普通记录锁就够而且引爆开关 在调用方手里——发号器代码一行不改调用方加个Transactional就把它接通了。 同理同一事务里用不同业务 key 取号锁同一张表的不同行顺序反转同样成立。 这就是 6 节说的“锁窗口静默膨胀”的完整代价不止是变慢是多了一整类死锁。 防御方法就是 6 节的结论发号器用REQUIRES_NEW取完立刻提交——t1 的锁在 t1 末尾就放了 t4 根本碰不上。修复对照表每条对应拆掉的知识点修复拆掉什么该事务改用 READ COMMITTED3.3 未命中持锁 → gap 锁根本拿不到环的边消失REPLACE换INSERT ... ON DUPLICATE KEY UPDATE4.3 插入路径的锁冲突面收窄传播用REQUIRES_NEW6 节7.1 节锁窗口固化为毫秒级同时拆掉调用方加事务引发的 AB-BA 引信重试上移到事务边界外5.1/两阶段锁语句级重试在已回滚的事务上是无效且危险的8. 带走三句话锁的分析永远从“谁持有、持多久”开始持有者是事务时长到提交为止—— 所以事务边界含 Spring 传播是锁问题的一半。改了 0 行不等于没加锁RR 下未命中的 UPDATE 白拿记录锁或 gap 锁 是死锁环上最隐蔽的边。读死锁日志的口诀跳过lock mode IX盯住gap before rec和insert intention—— “同 gap 并持 双双插入”是出镜率最高的死锁模型。9. 写在最后避坑清单把全文浓缩成几条能直接对号入座的检查项写代码前扫一眼你的代码里有这个模式吗风险怎么办先 UPDATE未命中就 INSERT/REPLACE且跑在 RR 下本文主角未命中的 UPDATE 白拿 gap 锁并发插入时“同 gap 并持 双双插入”直接成环该事务改 RC或用INSERT ... ON DUPLICATE KEY UPDATE一条语句完成新 key 集中出现的时刻如日切可提前预热初始化热点计数器/发号器类操作声明为默认的 REQUIRED调用方哪天加个Transactional锁窗口静默从毫秒膨胀到秒级还附送 7.1 的 AB-BA 死锁用REQUIRES_NEW把“独立短事务”固化进方法签名不要用 NEVER——它连自己的事务也一并禁掉LAST_INSERT_ID()这类连接级状态会错乱一个事务里对多个热点行加锁且顺序不固定随机分表、遍历无序集合AB-BA 死锁的标准配方要么缩短事务让锁不共存要么固定加锁顺序按 key 排序后再操作catch 死锁异常后在原事务里重发语句InnoDB 已回滚整个事务重试跑在隐式新事务上前半段数据静默丢失重试上移到事务边界外整个事务重跑依赖“查了没数据就认为没冲突”普通 SELECT 是快照读看不到未提交的行也挡不住并发插入真要互斥就用SELECT ... FOR UPDATE或唯一约束兜底别用快照读做决策最后说两句心里话死锁本身不可怕——InnoDB 毫秒级就能裁决正确地重试一次就过去了。可怕的是不知道它为什么发生 补丁打在错误的语句上、重试写在错误的层级里把小问题养成数据不一致的大问题。 把本文的地图装进脑子下次看到死锁日志你能在五分钟内把环画出来。祝大家少踩坑、多涨知识下篇见。