Theory · MySQL · InnoDB

InnoDB 锁体系

从实例到索引记录 —— 全局锁 / MDL / 意向锁 / 行锁四件套 / 死锁推演,加锁的对象永远是"索引"

层级全景

全局(FTWRL / 备份锁)→ 表级(表锁、MDL、意向锁 IS/IX、AUTO-INC)→ 行级(Record / Gap / Next-Key / Insert Intention)

加锁规则

基本单位是 next-key(前开后闭);唯一等值命中退化为记录锁;等值未命中退化为 gap——三条规则推遍所有案例

事故与排查

长事务卡死 DDL 的 MDL 雪崩、gap 互相等待与乱序更新两类死锁逐帧推演,配套 SHOW ENGINE INNODB STATUS 排查链

锁是 MySQL 面试三大件里最容易考动手推演的:给你一个表结构和两条事务,问你锁了什么、谁等谁、怎么死锁。这份 deck 的主线是"层级 + 规则":先把全局到行级的层级立起来,再用三条加锁规则推案例,最后落到 MDL 事故和两类经典死锁。所有锁的原句定义都取自手册 17.7.1,加锁规则部分采用工程界通行的归纳口径并标注了来源。

Why Locking Matters

先看现象:两段都没写错的代码,合起来让账目凭空多出 80 元

-- accounts 表:id=1 的账户余额 100 元
-- 同一毫秒来了两个扣款请求,各扣 80 元

-- T1(先读后写)
BEGIN;
SELECT balance FROM accounts WHERE id=1;  -- 读到 100
UPDATE accounts SET balance = 100-80 WHERE id=1;
COMMIT;                                    -- 余额 = 20

-- T2(与 T1 完全相同的代码,同时执行)
BEGIN;
SELECT balance FROM accounts WHERE id=1;  -- 也读到 100(T1 还没提交)
UPDATE accounts SET balance = 100-80 WHERE id=1;
COMMIT;                                    -- 余额还是 = 20

-- 结果:扣了两次 80,余额只剩 20 —— 凭空多出 80 元
关键观察:两条 SQL 单独看都没毛病,问题出在「读到的数」和「写回的数」之间隔了一段时间,而这段时间里别人也读了同一个数。这就是并发里的经典故障——丢失更新(Lost Update)

没有锁时,数据库只能"各看各的"

数据库的默认行为是让每个事务看到一致的数据快照,但不会主动帮你排队。两个事务同时读走同一个 100,各自算各自的,最后写回的那一版就把对方的结果覆盖掉了——跟两个人同时编辑同一份文档、后者保存覆盖前者是一回事。

那么"锁"换掉了什么?

它把"同时进行"变成"排队进行":谁先动手谁先锁住这行,后来者必须等它提交完、看到新值才能继续。代价是吞吐下降、可能等待、极端情况互相堵死(死锁)。所以锁的全部学问,就是在"正确性"和"并发度"之间找平衡点

但这个平衡点很难踩

锁的粒度:锁一整张表最安全但没人能干活;锁一行最精细,可没有索引时 InnoDB 会把全表每一行都锁上(第 10 页);
锁的范围:除了真实存在的行,InnoDB 还会锁"不存在的缝隙"(间隙锁)来防幻读,代价是凭空多出一类死锁(第 13 页);
锁的时机:锁要一直握到事务提交才放(2PL),一个忘提交的长事务能把整张表卡死(第 12 页)。

先建立一个直觉:锁就像卫生间的门锁——进去锁门(拿锁)、用完开门(提交才放)。真正的复杂度不在"锁不锁",而在锁多大范围(一个隔间还是整层楼)、什么时候放、以及两个人各占一半资源互相等时怎么办
动机页:给一条能算出具体金额的最小例子(余额 100、两次扣 80、结果只剩 20),让读者先"看到"丢失更新,再说明锁的价值是把并发变成排队。右侧三问:没有它怎么办、它换掉了什么、为什么平衡点难踩(粒度/范围/时机)——这三点正好对应后面第 10/13/12 三页,已经做好回指。最后用"卫生间门锁"的类比建立最小直觉。

Prerequisites & Glossary

先把词认全:下面每一页都会用到它们

术语一句话理解(先记住这个,细节后面展开)
事务一组"要么全做完、要么全不做"的数据库操作,以 COMMIT 结束才算生效
共享锁 S读锁:我读的时候不许别人改,但别人也能读。写法 SELECT … FOR SHARE
排他锁 X写锁:我改的时候别人既不能读(加锁读)也不能改UPDATE 自动加它
当前读最新的、已提交的数据并加锁,如 FOR UPDATE/UPDATE/DELETE
快照读普通的 SELECT:读事务开始时的一致性快照,不加锁
行锁锁住某一行(精确说是它的索引记录)——粒度最细,并发度最高
间隙锁 Gap锁住两条记录之间不存在的缝隙,唯一目的是不许往里插新行
临键锁 Next-Key行锁 + 它前面那段间隙,区间前开后闭——RR 下加锁的基本单位
MDL 元数据锁表结构而不是锁数据:事务用着这张表,就不许别人 ALTER/DROP
死锁两个事务各握着对方要的锁,互相等且谁也不放手——只能靠一方被回滚打破

需要但不在这份 deck 里的前置

mvcc.html → 快照读 / 当前读的机制、next-key 与 gap 的定义、幻读怎么被挡住
transaction-basics.html → 隔离级别、2PL(为什么锁到提交才放)、长事务治理
OS · 死锁 → 等待图检测、四条件与四策略的通用机制(不限数据库)

本 deck 怎么用这些词

为了不纠结名词,后面统一按"谁想对哪个对象做什么操作,被谁挡住了"来分析。你只需要盯住一件事:锁到底圈住了多大范围的数据

最小心智模型(一句话统摄全文):
锁做的唯一一件事,就是把并发的写操作排成一条队

后面所有锁类型与死锁场景,都是同一个问题的变体:这条队怎么排、圈多大范围、会不会首尾相接堵成环
阅读提示:术语不用背,遇到忘了的回来查这一页。真正要背的只有"next-key 是基本单位 + 两个退化"那三条规则(第 9 页)和最后那张速查表。
前置页:挑出本 deck 真正会用到的十个术语。当前读/快照读的区分是理解"为什么 SELECT 不加锁"的前提,gap/next-key 的定义与 mvcc deck 同源(互相链接)。右侧三条外链前置覆盖机制、事务语义与通用死锁理论。最小心智模型"把并发写排成一条队"在第 13 页的死锁场景会被直接回指复用。

Lock Hierarchy

锁层级全景:实例 → 表 → 索引记录

开场那 80 元的窟窿,靠锁住 id=1 这一行就补上了——"锁哪些行、锁多大范围"正是这张图要回答的。

InnoDB 锁层级:全局锁、表级锁、行级锁 全局层包含 FTWRL 与 8.0 备份锁;表级层包含表锁、MDL 元数据锁、意向锁 IS/IX 与 AUTO-INC 锁;行级层包含记录锁、间隙锁、临键锁与插入意向锁,行锁加在索引记录上。 全局锁 · 实例级 FLUSH TABLES WITH READ LOCK · LOCK INSTANCE FOR BACKUP(8.0) 表级锁 表锁 LOCK TABLES server 层,READ / WRITE 8.0 日常业务很少用 MDL 元数据锁 访问表即持有,事务结束才放 防 DDL 撕裂事务(第 7/12 页) 意向锁 IS / IX 拿行锁前先在表级"登记" 表级快速判断(第 6 页) AUTO-INC 锁 自增列分配,8.0 默认 mode=2 不再用表级锁(第 8 页) 行级锁 · 加在"索引记录"上 Record 记录锁 锁单条索引记录 · S / X Gap 间隙锁 纯抑制性:只防插入(第 11 页) Next-Key 临键锁 记录锁 + 前面的 gap(定义见 mvcc) Insert Intention 插入前的意向 gap,同 gap 不同位不互斥 快照读不走锁(MVCC)· 隔离性 = MVCC + 锁的组合拳 —— 机制见 mvcc deck
这页是锁体系的地图,后面每一页都是图上某个格子的放大。三层从粗到细:全局层管实例一致性,表级层管结构变更与全表操作,行级层管并发读写。两个贯穿性提示:行锁永远加在索引记录上,这句话会在第 10 页演变成"无索引更新锁全表";以及快照读根本不走锁,走 MVCC,所以锁讨论的都是当前读和写写冲突。面试开场先画这张图,再按面试官指的方向深入。

Global Locks

全局锁:FTWRL 的重与 8.0 备份锁的巧

FLUSH TABLES WITH READ LOCK(FTWRL)

server 层全局只读:阻塞一切写与 DDL,同时关闭表并同步 binlog 位点——逻辑备份取一致性位点(--source-data / --master-data)的传统手段。代价:主库执行期间写入全部排队,"主库备份 = 变相停写"。

LOCK INSTANCE FOR BACKUP(8.0 新增)

实例级备份锁,需要 BACKUP_ADMIN 权限。手册:"permits DML during an online backup while preventing operations that could result in an inconsistent snapshot"——允许 DML 继续,只阻止建/改/删文件、REPAIR/TRUNCATE/OPTIMIZE 与账号管理;多会话可同时持有;8.0.28 起 PLUGINS 期间禁 PURGE BINARY LOGS。

维度FTWRLLOCK INSTANCE FOR BACKUP
引入版本古老(5.x 即有)8.0 新增(§15.3.5)
阻塞什么全部写 + DDL(全库只读)仅"会造成备份不一致"的操作(文件级)
DML阻塞放行——在线备份不卡业务
典型组合老版 mysqldump 取位点8.0 mysqldump:backup lock 定文件一致性 + FLUSH TABLES … WITH READ LOCK 短暂取 binlog 位点,写阻塞窗口缩到极短
从库只读备库统一只读用 read_only / super_read_only,不是加锁问题——别混为一谈
答题要点:"全局锁考的不是锁本身,而是你知不知道 8.0 用备份锁把'一致性快照'和'只读'解耦了——DML 放行、文件操作挡住,这是在线备份的题眼。"
全局锁对比要讲出演进逻辑。FTWRL 是全库只读,阻塞所有写和 DDL,主库慎用;8.0 的 LOCK INSTANCE FOR BACKUP 是实例级备份锁,允许 DML 继续,只挡住会造成备份不一致的文件级操作,比如建表、删文件、TRUNCATE。8.0 的 mysqldump 用两段式:备份锁保证文件一致,再用很短的 FTWRL 取 binlog 位点,把写阻塞窗口压到极短。最后提醒 read_only 是变量不是锁,别混。

Table & Intention Locks · §17.7.1

意向锁 IS / IX:让"有没有行锁"在表级一眼可见

解决的问题:全表操作不想逐行扫

事务 T1 锁了某一行(X 行锁);事务 T2 想执行 LOCK TABLES t WRITE——若无表级标记,T2 必须逐行检查整表有无行锁。意向锁方案:T1 拿行 X 锁之前先拿表级 IX,T2 看一眼表级就知道"有行锁存在",直接等待。

手册的两句定性

① "Intention locks are table-level locks that indicate which type of lock (shared or exclusive) a transaction requires later for a row in a table."② "Intention locks do not block anything except full table requests (for example, LOCK TABLES ... WRITE)."——只与全表请求互斥,IS/IX 彼此之间、与行级 S/X 之间互不冲突。

请求 ↓ / 已持有 →ISIXS(表)X(表)含义
IS(行 S 前置)读意向互相不打扰
IX(行 X 前置)写意向只怕"全表级"写
S(全表读,LOCK TABLES READ)全表读容不下任何"写意向"
X(全表写,LOCK TABLES WRITE)全表写谁都容不下
一句话记忆:意向锁是"多粒度协议"的登记簿——它不锁数据,只声明意图;唯一的对手是 LOCK TABLES 这类全表请求。行锁真正的冲突发生在索引记录那一层(下一页起)。
意向锁这页回答"存在意义"。先讲反事实:没有表级登记,全表操作就得逐行扫锁。有了 IS 和 IX,行锁之前先在表上登记意图,全表请求看一眼就能决定等不等。兼容矩阵重点记两行:IS 和 IX 互相全兼容,它们只和全表级的 S、X 互斥。手册那句 do not block anything except full table requests 可以直接背。答题收尾句:意向锁不锁数据,只声明意图,真正的行锁冲突在索引记录层。

Metadata Locks · §10.11.4

MDL:事务用着表,就不许别人改结构

它防什么

手册:"To ensure transaction serializability, the server must not permit one session to perform a DDL statement on a table that is used in an uncompleted ... transaction in another session."——server 在事务用表时取 MDL,推迟到事务结束才释放;MDL 保证结构在事务生命周期内稳定(否则同一个 SELECT 前后读的可能已经不是同一张表)。

兼容性一句话

读读兼容、读写互斥:增删改查 = MDL 读(共享),ALTER/DROP = MDL 写(排他)。多个读锁并存无碍;一个写锁请求进来,不仅要等存量读锁放完,还会挡住后续新的读锁请求——这就是雪崩的机制根源(第 12 页推演)。

要点内容
覆盖范围不只表:库、表、存储过程、触发器、表空间等都有 MDL;DML 与 SELECT 也会持有 MDL 读锁
持有时长事务结束(commit / rollback)才释放——不是语句结束。autocommit 下语句即事务,所以"长事务"才成为风险源
观察手段performance_schema.metadata_locks 表(谁持有、谁在等);手册:"useful for seeing which sessions hold locks, are blocked waiting for locks"
等待上限lock_wait_timeout(MDL 等待超时,默认 31536000 秒 ≈ 1 年——等于不设防,必须靠流程而非默认值兜底)
死锁检测手册:语句逐个获取 MDL 并在其中做死锁检测;但"持有+排队"结构决定了长事务仍能把 DDL 与后续读写一起卡住
MDL 的定位一句话:事务用着表就不许别人改结构。防的是事务生命周期内表结构被撕裂。兼容性记六个字:读读兼容读写互斥。最要命的细节有两个:一是 MDL 到事务结束才释放,不是语句结束,这是长事务风险的根源;二是 lock_wait_timeout 默认一年,等于不设防,DDL 一旦排在长事务后面就会无限等。观察用 performance_schema 的 metadata_locks 表,能看到谁持有谁在等。雪崩推演放在第 12 页专门讲。

Row Locks · §17.7.1

行锁四件套:Record / Gap / Next-Key / Insert Intention

定义(手册口径)关键性质
Record 记录锁锁在索引记录上的 S / X 锁——同一行走不同索引,锁的就是不同索引记录唯一索引等值命中时"退化"出现的就是它
Gap 间隙锁锁索引记录之间的开区间;"purely inhibitive"——唯一目的就是阻止别人往间隙里插入S/X 不分、可共存;RR 才有,RC 基本禁用(第 11 页)
Next-Key 临键锁"a combination of a record lock on the index record and a gap lock on the gap before the index record"= 记录锁 + 前面的 gap,区间前开后闭 (上一值,本值]RR 加锁的基本单位;supremum 伪记录兜住 +∞(mvcc 已给定义)
Insert Intention 插入意向"a type of gap lock set by INSERT operations prior to row insertion"——插入前先声明"我要插进这个间隙"同 gap 不同位置互不阻塞;撞上别人的 gap 锁才等待

AUTO-INC 锁与 innodb_autoinc_lock_mode

mode 0 传统表级 AUTO-INC 锁(到语句结束);mode 1 连续(简单插入走轻量互斥,批量插入才上表锁);mode 2 交错(完全不用表级锁,最快,值单调但不保证连续)。8.0 起默认 2——手册明说原因:默认复制格式从 statement 变 row,不再需要 mode 1 的连续性保证;statement 复制仍需 0/1。

为什么"四件套"而非四种锁?

四件套是同一次扫描的不同形态:默认形态是 next-key;命中唯一等值退化为 record;等值未命中退化为 gap;INSERT 进场先挂 insert intention。记住"形态退化"视角,第 9 页的三条规则就不需要死记。

行锁四件套定义页,原句都能背出来最好。记录锁锁的是索引记录;间隙锁是纯抑制性的开区间;临键锁是记录锁加前面的间隙,前开后闭,是 RR 加锁的基本单位;插入意向锁是插入前的声明,同间隙不同位置不互斥。AUTO-INC 部分记版本结论:8.0 默认 mode 2,官方给的理由是默认复制变成 row 格式,不再需要连续性。最右下角那个视角转换很重要:四件套不是四种独立锁,而是同一次扫描的四种形态,这直接引出下一页的加锁规则。

Locking Rules 1/2 · 等值

RR 加锁规则(等值):三条规则推遍案例

RR 等值查询的三种加锁形态:记录锁、next-key、next-key 加 gap 索引值 10、15、20、25:唯一索引等值命中退化为记录锁;等值未命中退化为 next-key 的一半即 gap 加记录;普通索引等值命中为 next-key 加右侧 gap。 -∞ 10 15 20 25 +∞ 二级索引 idx_k(值 10 · 15 · 20 · 25)· 表 t(id PK, k INT, KEY idx_k(k)) ① 唯一索引等值命中(WHERE id=15) → 记录锁:只锁 15 本身 ② 唯一索引等值未命中(WHERE id=16) → gap (15,20):next-key 退化为 gap——16~19 插不进,20 本身不受锁 ③ 普通索引等值命中(WHERE k=20) → next-key (15,20] + gap (20,25):k=21~24 全插不进(不唯一,还要防重复插入) 规则(工程界通行归纳,丁奇《MySQL 实战 45 讲》,与 §17.7.1 定义一致): 加锁基本单位 = next-key(前开后闭)· 等值访问到的对象才加锁 · 唯一索引等值命中退化为记录锁 · 等值未命中退化为 gap
加锁规则的讲法是"先给规则再推案例"。三条规则:加锁基本单位是 next-key,前开后闭;只有扫描访问到的对象才加锁;两个退化——唯一索引等值命中退化成记录锁,等值未命中退化成 gap。对着图走三个案例:唯一命中只锁 15 一个点;查 16 不存在,锁住 15 到 20 的临键区间,16 插不进;普通索引查 20,除了左侧临键还要加右侧 gap 到 25,防的是下一个 20 插进来。归纳口径出自丁奇 45 讲,与手册定义一致,答题时可以点明出处。

Locking Rules 2/2 · 范围

范围扫描与 supremum;"锁加在索引上"的推论

案例(idx_k:10·15·20·25)加锁结果推演要点
WHERE k>=16 AND k<=24(范围)next-key (15,20] + next-key (20,25]沿途全部 next-key;右侧第一个不满足的值 25 也被 next-key 锁住——gap (20,25) 挡住插 21~24,25 本身再挂记录锁
WHERE k>20(开右界)(20,25] + (25, supremum]扫到索引末端时由 supremum 伪记录兜住 +∞——正无穷那一段是"最大的间隙"(mvcc 已述)
UPDATE t SET … WHERE name='x'无索引聚簇索引全表扫描 → 扫过的每条记录都加 next-key → 等价锁全表"锁加在索引记录上"的推论:没有可用索引,锁的就是全表的记录;业务高频事故源

RC 为什么"锁得更少"

① gap/next-key 基本禁用 → 只锁记录本身;② 半一致读:UPDATE 扫描遇被锁行,先返回最新已提交版本判 WHERE,不匹配就放行不等锁——无索引更新的灾难被大幅缓解(代价:幻读 + 仅 row binlog,transaction-basics)。手册:半一致读"greatly reduces the probability of deadlocks"。

实战守则

① UPDATE/DELETE 的 WHERE 列必须有索引(EXPLAIN 验证 type 不为 ALL,见 index-btree);② RR 下查"是否存在"用唯一索引等值,退化成记录锁最轻;③ 8.0 对 LIMIT 等场景会提前停止加锁,复杂语句用 performance_schema.data_locks 实测锁范围,别背结论。

答题口径:"RR 的加锁规则可以压缩成一句话——next-key 是基本单位,两个退化(唯一等值命中→记录锁;等值未命中→gap),范围扫到哪锁到哪、supremum 兜底;而一切的前提是锁在索引上,没有索引就锁全表。"
范围扫描两个要点:范围条件沿途全加 next-key,右边第一个不满足的值也会被锁住;扫到索引末端由 supremum 伪记录兜住正无穷。这页最值钱的是第三个案例:无索引更新的等价效果是锁全表,因为锁加在索引记录上,走聚簇全表扫描就把每条记录都锁了,这是生产事故的高频根源。RC 下好两点:gap 禁用加半一致读,不匹配的行提前放行。收尾那句压缩口诀值得背,三个案例都能从它推出来。

Gap Lock Properties · §17.7.1

间隙锁的三个"反直觉"特性

① 纯抑制性

手册:"Gap locks in InnoDB are 'purely inhibitive', which means that their only purpose is to prevent other transactions from inserting to the gap."——gap 不阻止、不阻止 gap 之间共享,只阻止一件事:往里插。

② S/X 不分,可共存

手册:"There is no difference between shared and exclusive gap locks. They do not conflict with each other."——两个事务对同一 gap 各拿一份"gap 锁"完全合法;冲突发生在 gap vs 插入意向那一对。

③ RC 下禁用(有例外)

手册:改为 READ COMMITTED 后,搜索与索引扫描不再加 gap,"used only for foreign-key constraint checking and duplicate-key checking"——外键检查与唯一键查重仍会加mvcc 隔离级别页同口径)。

冲突关系结果
gap 锁 vs gap 锁(无论谁先谁后)共存——为第 13 页"互相等待 gap"死锁埋下伏笔:都拿得住 gap,插入时才互相卡
gap 锁 vs 插入意向锁阻塞——INSERT 先声明意向,发现 gap 被占则等待
插入意向锁 vs 插入意向锁(不同位置)共存——同一间隙插不同 key 不互等(并发插入的友好性来源)
唯一索引等值命中 vs gap不需要 gap——退化为记录锁;这就是"查存在性用唯一条件"最轻的原因
设计启示:高并发写入场景 gap 是双刃剑——RR 用它防幻读,但"锁住不存在的东西"天然容易制造死锁(先互持 gap 再互插)。写多场景改 RC 或改用"唯一条件查重 + upsert"是常见的减负手段。
间隙锁三个反直觉特性都配了手册原句。第一,纯抑制性,只防插入不防读;第二,没有共享排他之分,同间隙可以互持,这是后面死锁案例的机制基础;第三,RC 下禁用但有两个例外:外键检查和唯一键查重,很多人答成 RC 完全没有 gap,这里能纠偏。冲突关系表重点记两对:gap 和 gap 共存、gap 和插入意向互斥;插入意向之间不同位置共存,这是并发插入还能跑得动的原因。最后的减负手段是把理论接到选型上。

MDL Pitfall & Online DDL · §10.11.4 / §17.12

经典事故:长事务如何卡死一张表;Online DDL 边界

-- 雪崩四步(时间自上而下)
T1  BEGIN; SELECT * FROM t WHERE id=1;   -- ① 持 MDL 读(事务未提交 → 一直持有)
T2  ALTER TABLE t ADD COLUMN c INT;      -- ② 等 MDL 写(排在 T1 后面)
T3  SELECT * FROM t;                     -- ③ 也要等!MDL 写请求"插队"挡住一切新读
T4… 连接池被 ②③ 耗尽 → 该表读写全停 → 上游超时雪崩
-- 解法:变更前先杀长事务(INNODB_TRX / processlist),错峰执行 DDL
--       lock_wait_timeout 默认 1 年 → 必须显式设小或靠流程兜底
操作(8.0)ALGORITHM重建表并发 DML
ADD COLUMNINSTANT(8.0.12 起默认;仅元数据)允许
DROP COLUMNINSTANT(8.0.29 起默认;此前 INPLACE)允许
ADD / DROP 二级索引INPLACE允许(见 index-btree
MODIFY COLUMN 改类型COPY(唯一选择)不允许——锁写,大表慎用
Online DDL 的边界:"online"指的是 DDL 执行期间允许并发 DML(INPLACE/INSTANT 时),不等于不加 MDL——起止仍需短暂的 MDL 排他,长事务照样能把它卡在起点。手册:显式指定 ALGORITHM=INSTANT/INPLACE 时"statement halts immediately if it cannot use the specified algorithm";INSTANT 加/删列有 64 个 row versions 上限(8.0.29 口径),超限需重建。
左边四步是 MySQL 运维最经典的雪崩剧本:长事务持 MDL 读,DDL 等 MDL 写,关键在第三步——新的读请求排在写请求后面也全被挡住,表瞬间不可读写。解法是变更前查长事务、杀掉再执行,并把 lock_wait_timeout 显式设小,它的默认值是一年。右边 Online DDL 简表记四行:加列 INSTANT 只改元数据,加索引 INPLACE 不重建,改类型必须 COPY 且锁写。最重要的一句话:online 指允许并发 DML,不等于不加 MDL,起止那一下照样怕长事务。

Deadlock Case 1 · §17.7.5

死锁案例 ①:先互持 gap,再互插——等待环

间隙锁互相等待导致的死锁时间线 T1 与 T2 先后对同一间隙加锁,gap 锁可共存;随后 T1 插入被 T2 的 gap 阻塞,T2 插入被 T1 的 gap 阻塞,形成等待环,InnoDB 回滚代价小的一方。 gap 锁可共存 ✓ INSERT 等 T2 持有的 gap ✗ INSERT 等 T1 的 gap ✗ T1 ① SELECT * WHERE k=6 FOR UPDATE k=6 不存在 → 持 gap (5,10) ③ INSERT k=7(先挂插入意向) 意向撞上 T2 的 gap → 等待 T2 ② SELECT * WHERE k=8 FOR UPDATE 同一 gap (5,10) → 也持一份,共存成功 ④ INSERT k=9(先挂插入意向) 意向撞上 T1 的 gap → 等待 等待环成立 → 死锁:InnoDB 立即检测并回滚代价小的事务(undo 量少者)
第一类死锁是 gap 锁的副产品。四步:T1 对不存在的 k 等值加锁,拿到 gap;T2 对同一 gap 加锁,因为 gap 可共存所以成功;T1 往 gap 里插入,插入意向撞上 T2 的 gap 开始等待;T2 插入同理撞上 T1 的 gap。等待环闭合,死锁检测立刻回滚代价小的一方。这个案例的点睛之处是:两个事务谁都没做错,锁都合法拿到,死锁来自 gap 的共存特性加上插入这个动作。RR 的写并发场景里这类死锁非常常见,也是很多团队切 RC 的直接原因。

Deadlock Case 2 & Diagnosis · §17.7.5

死锁案例 ②:更新顺序相反;以及死锁排查三件套

T1(先 A 后 B)T2(先 B 后 A)状态
t1UPDATE accounts SET … WHERE id=1T1 持 id=1 行 X 锁
t2UPDATE accounts SET … WHERE id=2T2 持 id=2 行 X 锁
t3UPDATE … WHERE id=2 → 等 T2T1 等 T2 释放 id=2
t4UPDATE … WHERE id=1 → 等 T1等待环 → 死锁,回滚 undo 少的一方

排查 ① LATEST DETECTED DEADLOCK

SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段:两条事务各自持有的锁与等待的锁、回滚了谁。手册:它只保留最近一次死锁现场。

排查 ② innodb_print_all_deadlocks

手册:"enable innodb_print_all_deadlocks to print information about all deadlocks to the mysqld error log"——把每一次死锁都打进错误日志,配合日志采集统计死锁模式。

排查 ③ 实时锁视图(8.0)

performance_schema.data_locks / data_lock_waits + sys.innodb_lock_waits 视图:直接回答"谁持有什么、谁在等谁"(8.0 取代了旧的 INNODB_LOCKS/INNODB_LOCK_WAITS)。

两个超时/检测开关别混:innodb_deadlock_detect=on(默认开,主动检测立即回滚受害者)vs innodb_lock_wait_timeout=50(秒,等锁超时只回滚当前语句)。热点行高并发下有"关检测、靠超时或应用排队"的取舍——检测本身有 O(n) 开销。
第二类死锁是转账类业务的原型:两个事务以相反顺序更新两行,各自持有一把等另一把,环就闭死了。解法在下一页,先记住排查三件套:LATEST DETECTED DEADLOCK 只留最近一次现场,能看到双方持锁和等待链;innodb_print_all_deadlocks 把全部死锁打进错误日志做模式统计;8.0 用 data_locks 和 sys.innodb_lock_waits 实时看谁等谁,旧的两张 INNODB_LOCKS 表已经移除。最后别把死锁检测和锁等待超时混为一谈:前者回滚整个事务,后者只回滚当前语句。

Deadlock Prevention · §17.7.5

死锁避免:手册四条 + 工程三条

手册的四条(§17.7.5 原意)

① 用事务而非 LOCK TABLES;② 让 insert/update 型事务尽量小、不长期开着;③ 多表/多行更新时所有事务按相同顺序访问(配套 SELECT … FOR UPDATE);④ 给 FOR UPDATE 与 UPDATE … WHERE 的列建索引——没索引会扫全表锁全表(第 10 页)。

一个反直觉的官方结论

手册:"The possibility of deadlocks is not affected by the isolation level, because the isolation level changes the behavior of read operations, while deadlocks occur because of write operations."——隔离级别不影响死锁概率(但 RC 禁 gap 后,"gap 型"死锁确实消失,这是锁形态变化而非级别魔力,答题时要把两层都说到)。

工程手段做法与适用
应用层重试捕获死锁错误(ER_LOCK_DEADLOCK 1213)重试整个事务——死锁不是错误而是"重试信号",幂等是前提
热点行削峰秒杀扣库存:单行并发更新排队 → 队列化/合并扣减/分段库存(stock 拆 N 行),避免"检测风暴"
统一加锁顺序 + 小事务代码规范层面固化访问顺序(如按 id 升序);事务里不排队不做 RPC——锁窗口=事务时长(transaction-basics · 2PL
避免死锁先背手册四条:用事务不用表锁、事务小、同序访问、给加锁条件建索引。然后是这个页最出彩的反直觉结论:官方明说隔离级别不影响死锁概率,因为死锁源于写操作而级别改变的是读行为。但要说两层:级别不改变统计意义上的死锁率,RC 确实让 gap 型死锁消失,这是锁形态变了。工程三条里最实用的是应用层重试,死锁错误码 1213,本质是重试信号而不是故障,前提是业务幂等。热点行那格顺带把秒杀的分流思路带出来。

Pessimistic vs Optimistic

悲观锁 vs 乐观锁:一个是 DB 机制,一个是业务协议

维度悲观锁(数据库机制)乐观锁(业务协议)
实现SELECT … FOR UPDATE(当前读加 X 锁)
或直接 UPDATE … WHERE 条件 借行锁串行化
版本号:UPDATE t SET stock=stock-1, version=version+1 WHERE id=? AND version=?,affected rows=0 即冲突重试
冲突处理阻塞等待(或 NOWAIT / SKIP LOCKED 立即返回)提交时发现冲突 → 应用层重试
适用场景冲突高、临界区短、必须同步互斥(账户扣减、库存防超卖)冲突低、可重试、读多写少(内容编辑、状态机)
代价锁窗口 = 事务时长(2PL);热点行吞吐骤降;连接被占用无 DB 锁,但冲突率高时重试风暴 + DB 徒劳写入

热点行首选:单条原子 UPDATE

扣库存别先查后写:UPDATE stock SET n=n-1 WHERE id=? AND n>0——一条语句在行锁内完成"检查+扣减",天然防超卖;n=0 影响行数为 0 即售罄。这比 FOR UPDATE + 业务判断少一次持锁往返。

和 MVCC 的关系(防混淆)

InnoDB 的 MVCC 是引擎内部的"读不加锁"(mvcc);业务乐观锁是应用层的"写前校验"。前者由数据库自动完成、对业务透明,后者要自己在表里加 version 字段——面试时明确"不是一回事,只是思想同源"。

悲观和乐观锁的对比表按行讲:实现、冲突处理、场景、代价。重点是纠正一个常见混淆:悲观锁是数据库机制,FOR UPDATE 或原子 UPDATE;乐观锁是业务协议,自己维护 version 字段,和 MVCC 思想同源但不是一回事。热点行那格要能写出那条原子 UPDATE,先查后写在并发下必然超卖,一条语句完成检查加扣减才是正解。NOWAIT 和 SKIP LOCKED 是 8.0 的新选项,队列表场景直接略过被锁行,很实用。

Cheat Sheet · 1/2

速查上篇:层级 · 加锁规则 · gap 三特性

① 层级:谁管什么

全局FTWRL(全库只读)|8.0 LOCK INSTANCE FOR BACKUP放行 DML,只挡文件级不一致操作)
表级表锁 LOCK TABLESMDL(锁结构,事务结束才放)|意向锁 IS/IX|AUTO-INC
行级Record/Gap/Next-Key/Insert Intention —— 永远加在索引记录上
意向锁口诀只"登记意图",IS/IX 互相兼容、与行锁不冲突,唯一对手是 LOCK TABLES

② 加锁规则:一句口诀推全部

基本单位next-key(前开后闭:记录锁 + 它前面的 gap)
退化 ①唯一索引等值命中 → 退化为记录锁(只锁这一个点)
退化 ②等值未命中 → 退化为 gap(锁缝隙,右界那条记录本身不锁)
普通索引等值next-key (前值, 值] + 右侧 gap (值, 下一值]——还要防下一个同值插进来
范围扫描扫到哪锁到哪,右边第一个不满足的值也会被锁;扫到末端由 supremum 兜住 +∞
无索引 UPDATE聚簇全表扫描 → 每行都加 next-key → 等价锁全表(生产事故头号来源)

③ gap 的三个反直觉

纯抑制性只防插入,不防读、不防别人也持同一 gap
S/X 不分共享排他 gap 锁没区别、可共存;冲突对是「gap vs 插入意向」
RC 下禁用例外:外键检查与唯一键查重仍会加 gap
速查上篇(机制侧):先认层级,再背加锁规则口诀(基本单位 + 两个退化 + 范围扫描与 supremum + 无索引锁全表),最后 gap 三特性——"gap 既不是 S 也不是 X、同间隙可共存"是面试最常答错的一点。

Cheat Sheet · 2/2

速查下篇:死锁 · 排查 · 事故与选型

④ 两类死锁:都是"环"

gap 互等两事务各持同一 gap(可共存)→ 各自 INSERT → 插入意向互撞对方 gap → 成环。谁都没做错
乱序更新两事务以相反顺序更新两行 → 各持一把等另一把 → 成环(转账类业务原型)
谁被回滚InnoDB 选代价小(undo 量少)的一方回滚,客户端收到 1213

⑤ 排查与处置

看最近一次SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK
看全量innodb_print_all_deadlocks=on → 打进 error log 做模式统计
看实时8.0:performance_schema.data_locks / data_lock_waits + sys.innodb_lock_waits
检测 vs 超时innodb_deadlock_detect:发现环回滚整个事务innodb_lock_wait_timeout(默认 50s):只回滚当前语句
应用侧捕获 1213 → 整事务重试(前提是幂等);死锁是重试信号不是故障

⑥ 事故与选型

MDL 雪崩长事务持 MDL 读 → DDL 等 MDL 写 → 连后续新读也一起挡住 → 连接池耗尽。lock_wait_timeout 默认 1 年,等于不设防
Online DDL"online" = 执行期允许并发 DML,不等于不加 MDL;ADD COLUMN=INSTANT、ADD INDEX=INPLACE、改类型=COPY 且锁写
悲观 vs 乐观悲观 = DB 机制(FOR UPDATE/原子 UPDATE);乐观 = 业务协议(自己加 version 字段)——与 MVCC 不是一回事
防超卖一条语句:UPDATE stock SET n=n-1 WHERE id=? AND n>0,affected=0 即售罄。先 SELECT 再 UPDATE 必超卖
一句话背下来: 锁把并发的写排成一条队;next-key 是基本单位、两个退化管形态,锁永远加在索引上——没索引就锁全表,死锁则是这条队首尾相接成了环
速查下篇(事故与处置侧):两类死锁的成环机制与受害者选择 → 排查三件套(最近一次 / 全量 / 实时)与"检测 vs 超时"的回滚粒度差别 → MDL 雪崩链路、Online DDL 边界、悲观乐观锁辨析、防超卖的原子 UPDATE。收尾句把第 3 页的最小心智模型(排队)与死锁(成环)串成一句。

Interview QA · 1/2

锁机制 8 连问

先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回第 17 / 18 页速查表。

1 · InnoDB 有哪些锁?

全局/表级/行级

全局:FTWRL、8.0 LOCK INSTANCE FOR BACKUP;表级:表锁、MDL、意向锁 IS/IX、AUTO-INC 锁;行级:Record / Gap / Next-Key / Insert Intention。行锁加在索引记录上。

2 · 意向锁是干嘛的?和行锁冲突吗?

表级登记只挡全表请求

多粒度协议的登记簿:拿行 S/X 前先在表级挂 IS/IX,让全表操作不用逐行扫锁。IS/IX 互相兼容、与行级锁不冲突,唯一对手是 LOCK TABLES 这类全表请求(手册原话)。

3 · gap 锁是 S 还是 X?和插入意向锁什么关系?

纯抑制S/X 不分

都不是——手册:共享与排他 gap 锁没有区别、互不冲突,唯一目的是阻止插入(purely inhibitive)。冲突对是"gap vs 插入意向":INSERT 声明意向时撞上 gap 才等待;插入意向之间不同位置共存。

4 · RR 下:唯一索引等值命中/未命中、普通索引等值各锁什么?

记录锁next-keynext-key+gap

唯一命中:退化记录锁;唯一未命中:next-key 退化为 gap (前值, 右界),右界记录不受锁;普通索引等值:next-key (前值, 值] + gap (值, 下一值)——防重复插入。基本单位 next-key 前开后闭,两个退化是全部口诀。

5 · 无索引的 UPDATE 会怎样?RC 为什么好一点?

锁全表半一致读

RR:聚簇全表扫描 → 每条记录 next-key → 等价锁全表。RC:gap 禁用只锁记录 + 半一致读(不匹配行提前放行)大幅缓解——手册称半一致读大幅减少死锁。代价:幻读、仅 row binlog。

6 · MDL 是什么?长事务怎么卡死 DDL?

读读兼容读写互斥事务结束才放

MDL 保证事务期间表结构稳定:DML 持读锁、DDL 要写锁,写锁还会挡住后续一切读请求。长事务持有读锁不放 → DDL 排队 → 全表读写跟着排队 → 连接池耗尽。lock_wait_timeout 默认一年,必须显式设小。

7 · Online DDL 是不是完全不锁表?

并发 DML≠无 MDL

不是。online 指 INPLACE/INSTANT 执行期间允许并发 DML;起止仍需短暂 MDL 排他,长事务照样能卡住它。ALGORITHM 记四行:ADD COLUMN INSTANT、ADD INDEX INPLACE、DROP INDEX INPLACE、改类型 COPY 锁写。

8 · AUTO-INC 锁?8.0 默认哪个 mode?

mode 2 交错row binlog 因果

三种模式:0 传统表级 AUTO-INC 锁、1 连续(批量插入才表锁)、2 交错无表锁。8.0 起默认 2——官方理由:默认复制从 statement 变 row,不再需要连续性;statement 复制仍需 0/1(§17.6.1.6 原话)。

QA 第一组是机制主干。第二题要说出意向锁的存在意义而不是背定义。第三题是最容易答错的:gap 锁既不是 S 也不是 X,它俩没有区别。第四题三条规则对着白板推。第五题把锁全表和半一致读连起来,能自然引出 RC 选型。第六题背六个字读读兼容读写互斥,再报 lock_wait_timeout 默认一年这个细节。第七题纠正 online 不等于无锁。第八题报出 8.0 默认 mode 2 和 row binlog 的因果,这是验证过的手册原话。

Interview QA · 2/2 · 先自答再对照

死锁与实战 8 连问

9 · 画两个经典死锁场景?

gap 互等乱序更新

① 同一 gap 两个事务互持 gap(可共存)后互插,插入意向互相撞 gap → 环。② 两事务以相反顺序更新两行,各持一把 X 锁等另一把 → 环。共同点:持有并等待 + 顺序不一致。

10 · 死锁怎么排查?

LATEST DETECTED DEADLOCKdata_locks

SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK(仅最近一次:双方持锁/等锁/回滚了谁);innodb_print_all_deadlocks 全量打进错误日志做统计;8.0 用 performance_schema.data_locks + sys.innodb_lock_waits 实时看谁等谁。

11 · 怎么避免死锁?隔离级别影响死锁吗?

同序/小事务/索引手册:不影响

手册四条:事务化、小事务、同序访问、加锁列建索引。隔离级别不影响死锁概率(死锁因写、级别改读)——但 RC 禁 gap 后"gap 型"死锁消失,是锁形态变化。再补应用层 1213 重试与热点行削峰。

12 · 死锁检测和锁等待超时的区别?

回滚事务 vs 回滚语句

innodb_deadlock_detect(默认 on):发现环立即回滚代价小的事务;innodb_lock_wait_timeout(默认 50 秒):等锁超时只回滚当前语句、事务仍在。超高并发热点行有"关检测靠超时/应用排队"的取舍——检测有代价。

13 · RC 为什么死锁更少?为什么能减 gap?

gap 禁用半一致读

RC 下搜索与扫描不加 gap(外键/唯一键查重除外),"锁不存在的东西"这类死锁源头消失;UPDATE 半一致读让不匹配行提前放行,持锁窗口更短。代价:幻读仍在、仅 row binlog(binlog 约束见 transaction-basics)。

14 · FOR UPDATE NOWAIT / SKIP LOCKED 什么时候用?

不等待跳过锁行

NOWAIT:拿不到锁立即报错 3572——用于快速失败防堆积;SKIP LOCKED:跳过被锁行继续取——任务队列轮询的标配(多 worker 互不阻塞消费)。两者仅作用行级锁,statement 复制不安全。

15 · 乐观锁怎么落地?失败怎么办?

version 字段重试/降级

表加 version:UPDATE … SET v=v+1 WHERE id=? AND v=?,affected=0 即冲突。处理:有限次重试(幂等前提)、退避后再试、或改悲观/队列。冲突率高时重试风暴——乐观锁适用冲突低的场景,选型别硬套。

16 · 库存扣减怎么写才不超卖?

原子 UPDATE分段库存

一条语句:UPDATE stock SET n=n-1 WHERE id=? AND n>0——行锁内完成检查与扣减,affected=0 即售罄,天然防超卖。热点再上分段库存/合并扣减/异步落库;先 SELECT 再 UPDATE 的写法在并发下必超卖。

QA 第二组偏实战。第九题能把两个死锁场景画出来基本就过了。第十题三件套按"现场、全量、实时"的顺序报。第十一题两层论要分清:级别不影响死锁率是官方结论,gap 型死锁消失是形态变化。第十二题的区别点在回滚粒度。第十四题 NOWAIT 和 SKIP LOCKED 各配一个场景,说明你写过队列。第十五十六连着答:乐观锁的落地和重试策略,库存扣减那条原子 UPDATE 最好能脱口而出,这是后端面试的经典收束题。

Related & References

相关知识点与参考

本领域相关 deck

mvcc.html · next-key / gap / supremum 定义与幻读阻断的机制视角
transaction-basics.html · 2PL(锁到 commit)、隐式提交与 MDL 事故链入口
index-btree.html · "锁在索引上"的结构基础;Online DDL 与索引变更
log-redo-undo-binlog.html · 死锁回滚的 undo 代价、RC 仅 row binlog 的日志约束
replication.html · AUTO-INC mode 与 binlog 格式的复制因果

答题串联 · 一图流

层级 → 全局 / 表级(MDL·意向)/ 行级四件套
规则 → next-key 基本单位 + 两个退化 + supremum 兜底
事故 → 长事务 × MDL × DDL = 全表雪崩
死锁 → 两类环(gap 互等 / 乱序更新)→ 三件套排查
OS 对照 → OS · 死锁(等待图检测与恢复/预防策略的通用机制)
选型 → 冲突高悲观原子 UPDATE / 冲突低乐观 version

最值得原文精读的一篇:§17.7.1 InnoDB Locking——本 deck 所有锁的原始定义(record/gap/next-key/insert intention、"purely inhibitive"、S/X gap 无区别、意向锁只挡全表请求、RC 的两个例外)全在这一节,逐句读完就能自己推导出加锁规则;其余材料按需查阅。
收尾页上半:给出五个方向的深讲链接与答题串联,并明确标出最值得原文精读的一篇(§17.7.1)。复习时把两条主线走通:层级图能默画,两个死锁案例能在白板上推帧,锁这块的面试基本无死角。

References

参考来源:本 deck 全部结论可溯源

dev.mysql.com/doc/refman/8.0/en/innodb-locking.html§17.7.1:record/gap/next-key/insert intention 定义、"purely inhibitive"、S/X gap 无区别、意向锁用途、RC 例外(外键/重复键检查)
dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html§17.7.2.4:锁到 commit/rollback 才释放、FOR SHARE vs FOR UPDATE、NOWAIT / SKIP LOCKED
dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html§17.7.5:死锁定义与 victim、SHOW ENGINE INNODB STATUS、innodb_print_all_deadlocks、减少死锁清单、隔离级别不影响死锁结论
dev.mysql.com/doc/refman/8.0/en/metadata-locking.html§10.11.4:MDL 推迟到事务结束、防 DDL 撕裂、performance_schema.metadata_locks、逐个取锁并做死锁检测
dev.mysql.com/doc/refman/8.0/en/innodb-auto-increment-handling.html§17.6.1.6:innodb_autoinc_lock_mode 0/1/2,8.0 起默认 2 及 row 复制因果
dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl.html · …-operations.html§17.12 / §17.12.1:INSTANT/INPLACE/COPY 支持矩阵、ADD COLUMN INSTANT 自 8.0.12、DROP COLUMN INSTANT 自 8.0.29、64 row versions、"halts immediately"
dev.mysql.com/doc/refman/8.0/en/lock-instance-for-backup.html§15.3.5:实例级备份锁、BACKUP_ADMIN、允许 DML 阻止文件级不一致操作、8.0.28 PURGE BINARY LOGS 限制
dev.mysql.com/doc/refman/8.0/en/performance-schema-data-locks-table.html · …-data-lock-waits-table.html8.0 行锁实时视图(取代 INNODB_LOCKS / INNODB_LOCK_WAITS)与 sys.innodb_lock_waits
dev.mysql.com/doc/refman/8.0/en/innodb-parameters.htmlinnodb_lock_wait_timeout(默认 50s)、lock_wait_timeout(默认 31536000s)、innodb_deadlock_detect
book:丁奇《MySQL 实战 45 讲》锁规则篇"next-key 基本单位 + 唯一命中退化记录锁 + 等值未命中退化 gap"的通行归纳口径(本 deck 第 9/10 页)
怎么查:把上表第一段路径拼到 https:// 后面即达官方页面。本 deck 全部按 MySQL 8.0 撰写,5.7 在 INSTANT DDL / AUTO-INC mode / data_locks 上行为不同。
参考页:锁定义出自 §17.7.1,锁读与 2PL 出自 §17.7.2.4,死锁出自 §17.7.5,MDL 出自 §10.11.4,Online DDL 出自 §17.12,AUTO-INC 默认值出自 §17.6.1.6,备份锁出自 §15.3.5,参数默认值出自 innodb-parameters,均核对过原句;第 9、10 页的加锁规则归纳口径来自丁奇 45 讲并做了标注。