MySQL 深度教学(五):锁机制全解(行锁、间隙锁、死锁与排查)
行锁为什么加在索引上、间隙锁怎么防幻读、Next-Key Lock 锁的是哪个区间、两阶段锁协议怎么决定锁的释放时机、死锁是怎么形成的、以及怎么用 performance_schema 真实观测到一把锁。
系列进度(共 10 篇) ① 整体架构与一条 SQL 的旅程 ✅ ② InnoDB 存储引擎核心原理 ✅ ③ 索引深度解析与高性能索引设计 ✅ ④ 事务与 MVCC 多版本并发控制 ✅ ⑤ 锁机制全解(本篇) ⑥ 日志系统:Redo / Undo / Binlog 三件套 ⑦ SQL 优化与执行计划(EXPLAIN) ⑧ 查询性能调优实战 ⑨ 高可用与复制架构 ⑩ 备份恢复、分库分表与运维实战
开篇:有了 MVCC,为什么还要锁
上一篇(四)我们把事务和 MVCC 拆完了。MVCC 用”多版本”让快照读不加锁、写不阻塞读,听起来已经很完美。但第④篇结尾也点明了一个事实:MVCC 解决的是”读一致”,而当前读和写写冲突终究要靠锁来仲裁。一个关键事实是:MVCC 管”读”,锁管”写”和”当前读的互斥”。
设想两个事务同时给同一个账户加钱,都用 MVCC 读旧值再写新值,如果中间没有任何锁,第二次写就可能覆盖第一次写,造成”更新丢失”。再比如你在 RR 下用 SELECT … FOR UPDATE 想锁住一批数据防止别人插入,这也需要锁。所以 MVCC 和锁是互补的:读多用 MVCC,写和当前读用锁,二者合起来才构成 InnoDB 完整的并发控制。
这正是第④篇结尾留给我们的待解问题:MVCC 让快照读实现了无锁并发,但当前读和写写冲突终究要靠锁来仲裁。当时那篇的收尾已经把本篇要讲透的几个点列了出来,这里直接把它们变成”本篇要回答的问题”:
- 行锁、间隙锁、临键锁(Next-Key Lock)三者到底怎么配合,RR 下 UPDATE 一个范围为什么会把间隙也锁住;
- 死锁是怎么形成的,又如何检测和避免;
- 锁按粒度与模式怎么分(S / X、表锁 / 行锁、MDL、意向锁),两阶段锁协议又怎么决定锁的释放时机;
- 怎么用 performance_schema 真实观测到一把锁、并在死锁发生时抓到现场。
一、锁的两个分类维度
很多人一谈锁就晕,是因为混淆了维度。锁可以从两个互相垂直的维度去看,再叠加几种”特殊用途锁”,就构成了 InnoDB 完整的锁全景。
1.1 按锁定粒度
| 粒度 | 说明 |
|---|---|
| 全局锁 | 锁整个数据库实例,命令是 FLUSH TABLES WITH READ LOCK(FTWRL)。加锁后整库只读,数据更新、DDL、事务提交全被阻塞。常用于全库逻辑备份,但因为会让业务停摆,生产一般改用 —single-transaction 的一致性快照备份来绕开它。 |
| 表锁 | 锁整张表。MyISAM 默认用表锁;InnoDB 也有表级锁(如意向锁、DDL 的 MDL、显式 LOCK TABLES),但日常写走行锁。表锁并发低,InnoDB 场景下应尽量避免手动 LOCK TABLES。 |
| 行锁 | 锁索引上的某条记录,粒度最细、并发最高。InnoDB 的主力锁,也是本篇重点。注意:行锁加在索引项上,不是物理数据行上(这点第二节展开)。 |
1.2 按锁定模式(S / X)
从互斥性看,行锁分为共享锁和排他锁,这是理解一切锁冲突的基础:
| 模式 | 含义与加锁方式 |
|---|---|
| 共享锁 S(Shared,读锁) | 多个事务可同时持有同一行的 S 锁,互不冲突。SELECT … LOCK IN SHARE MODE 或 MySQL 8.0 的 SELECT … FOR SHARE 加 S 锁。 |
| 排他锁 X(Exclusive,写锁) | 独占读写。一个事务持有 X 锁后,其他事务不能再对该行加任何类型的锁(S 或 X 都不行),直到释放。UPDATE / DELETE / INSERT 会自动加 X 锁;SELECT … FOR UPDATE 显式加 X 锁。 |
兼容关系只有一条:S 和 S 兼容,其余 S-X、X-X、X-S 全部冲突。这意味着”读读”可以并发,“读写""写写”必须串行。
1.3 元数据锁 MDL(Metadata Lock)
这是面试高频、又最容易被忽略的一类锁。MDL 不需要显式使用,访问一个表时由 Server 层自动加上:对表做增删改查时加 MDL 读锁;对表做结构变更(ALTER / DROP 等 DDL)时加 MDL 写锁。读锁之间兼容,读写锁与写写锁互斥。
最大的坑在这里:长事务不提交,会一直持有 MDL 读锁,导致后续的 DDL(加字段、建索引)被阻塞;而 DDL 一旦在等 MDL 写锁,后面所有新的读写请求又会排队等这个 DDL,瞬间引发全表雪崩式阻塞。线上加字段卡死、连接数暴涨,十有八九是这个原因。
-- 查看当前哪些会话持有了 MDL、哪些在等(8.0+)
SELECT * FROM performance_schema.metadata_locks
WHERE OBJECT_TYPE = 'TABLE'
AND LOCK_STATUS = 'PENDING'; -- PENDING 表示在等 MDL
排查思路:找到持有 MDL 读锁却长时间不提交的事务(结合 information_schema.innodb_trx 的 trx_started),把它 kill 掉,DDL 就能继续。
1.4 意向锁 IS / IX
意向锁是表级锁,作用像一个”信号灯”,用来快速判断表内是否有行被锁定,避免为了检查行锁而逐行扫描。规则是:事务准备在某几行加 S 锁前,先在表上加 IS;准备加 X 锁前,先加 IX。意向锁由 InnoDB 自动维护,用户无法手动操作。
| 已持有 \ 请求 | IS | IX |
|---|---|---|
| IS | 兼容 | 兼容 |
| IX | 兼容 | 兼容 |
意向锁之间全部兼容(多个事务可以同时对一个表持有 IX),但它们与表级的 S / X 锁互斥。换言之,意向锁不阻塞行级操作,只阻塞”对整张表加锁”的企图,让表锁和行锁能高效共存。
二、行锁(Record Lock):加在索引上,不是数据行上
这是理解行锁最核心、也最常被误解的一点:InnoDB 的行锁是加在索引项上的,不是加在物理数据行上的。这意味着 WHERE 条件必须能命中索引,行锁才生效;否则 InnoDB 只能全表扫描,给聚簇索引上每一行都加上锁,实际效果等同锁全表——并发环境下是灾难。
2.1 为什么无索引会锁全表
假设一张 user 表的 name 列没有索引,执行 UPDATE user SET age=20 WHERE name=‘duang’。因为 name 无法快速定位,存储引擎只能逐行扫描,对扫描到的每一行(包括最终不匹配的行)都加上 X 锁,直到事务提交才释放。线上一条”本意只改一行”的语句,可能把整张表锁住几十秒。
-- 验证:用 performance_schema.data_locks 看一条无索引 UPDATE 锁了多少行
-- 事务 A 执行(name 无索引)
BEGIN;
UPDATE user SET age=20 WHERE name='duang';
-- 另一个会话查看锁(8.0+)
SELECT engine_transaction_id, object_name, index_name, lock_type, lock_mode
FROM performance_schema.data_locks
WHERE object_name = 'user' AND lock_type = 'RECORD';
-- 结果会看到大量 RECORD 锁,index_name 多为 PRIMARY(全表扫描锁行)
常见坑:索引”看起来有”但失效了,锁照样退化成全表。函数包裹(WHERE YEAR(create_time)=2024)、隐式类型转换(WHERE phone=13800000000 而 phone 是 varchar)、前导通配符(LIKE ‘%abc’)都会让索引失效,行锁变表锁。这和第③篇讲的”索引失效”是同一个根因,只是后果从”慢”升级成”锁全表”。
2.2 S 与 X 的兼容
行级 S/X 锁的兼容规则前面 1.2 已经给过:S-S 兼容,其余冲突。特别要注意:一旦某行被加了 X 锁,其他事务的 SELECT … FOR UPDATE(也要 X)和 SELECT … LOCK IN SHARE MODE(要 S)都会被阻塞,直到持有者提交或回滚。
三、间隙锁(Gap Lock):防插入,专治幻读
间隙锁锁住的是”索引记录之间的空隙”,而不是某条已存在的记录。它的唯一目的就是阻止其他事务往这个空隙插入新数据,从而防止当前读场景下的幻读。
3.1 关键特性
间隙锁只在可重复读(RR)及以上隔离级别生效;RC 级别没有间隙锁。还有一点反直觉但很重要:间隙锁之间互相兼容,它只阻塞 INSERT。也就是说,两个事务可以同时持有一个空隙的间隙锁,但谁要是想往这个空隙 INSERT,就会被阻塞。间隙锁不会阻塞对已有记录的 UPDATE / DELETE——它管的是”防新增”,不是”防修改”。
-- 示例:id 现有值 5, 10, 15
-- 事务 A
BEGIN;
SELECT * FROM t WHERE id BETWEEN 10 AND 15 FOR UPDATE;
-- 锁住记录 10、15,以及空隙 (10,15)
-- 事务 B(另一会话)
INSERT INTO t(id) VALUES (12); -- 被阻塞!12 落在 (10,15) 间隙内
UPDATE t SET c=1 WHERE id=10; -- 不被阻塞,已有记录可改
这里有个实战要点:如果业务能接受幻读(很多互联网场景用唯一约束或乐观锁兜底),把隔离级别降到 RC 可以彻底消除间隙锁,并发和死锁概率都会明显下降——这正是第④篇提到的”很多公司生产直接用 RC”的代价权衡。
四、Next-Key Lock(临键锁):InnoDB 的默认行锁算法
Next-Key Lock = 记录锁 + 间隙锁,锁定一个”左开右闭”的区间 (prev, current]。它是 InnoDB 在 RR 隔离级别下的默认加锁算法,专门用来在当前读时同时防住”改已有行”和”插新行”,从而解决幻读。
4.1 降级规则(面试必背)
Next-Key Lock 并非永远锁一整段,它会按索引类型和查询方式降级:
| 查询类型 | 索引情况 | 实际加的锁 |
|---|---|---|
| 等值查询 = | 主键 / 唯一索引,且命中 | 退化为纯记录锁(只锁这一行,并发度最高) |
| 等值查询 = | 主键 / 唯一索引,未命中 | 间隙锁(锁住该值前后的空隙,防别人插入这个不存在的 ID) |
| 等值查询 = | 普通(非唯一)索引,命中或未命中 | Next-Key Lock,且会额外给”右侧第一个不满足条件的记录”加间隙锁 |
| 范围查询 > < BETWEEN | 任意索引 | 逐区间加 Next-Key Lock(锁住范围内所有行 + 所有间隙) |
| 无索引 | 全表扫描 | 对聚簇索引每一行加 Next-Key Lock,等效锁全表(灾难) |
-- 普通索引 idx_age 上 age=20 有两条记录,其余为 18, 25
-- 事务 A
BEGIN;
SELECT * FROM user WHERE age = 20 FOR UPDATE;
-- 加锁范围:(18,20] 和 (20,25] 两个 Next-Key 区间
-- 即锁住 age=20 的两行,以及 (18,20) 和 (20,25) 两个空隙
-- 事务 B
INSERT INTO user(age) VALUES (19); -- 阻塞,落在 (18,20) 空隙
INSERT INTO user(age) VALUES (22); -- 阻塞,落在 (20,25) 空隙
理解这个降级规则,才真正懂”为什么唯一索引等值更新不会加间隙锁、普通索引等值查询反而锁一大片”——这是第④篇 11.4 提到的 RR 防幻读机制在”当前读”一侧的具体落地。
五、插入意向锁(Insert Intention Lock)
插入意向锁是一种特殊的间隙锁,在 INSERT 时自动加上。它的巧妙之处在于:多个事务往同一个空隙的不同位置插入记录时,彼此不需要互相等待。比如空隙 (10,15) 里,事务 A 想插 12、事务 B 想插 13,两者都持有”插入意向锁”且目标位置不同,可以并发进行,不会互相阻塞。
但它和”已存在的间隙锁”会冲突:如果事务 A 已经用 SELECT … FOR UPDATE 锁住了 (10,15) 这个空隙(间隙锁),事务 B 的插入意向锁就会被 A 的间隙锁阻塞——这正是后面死锁章节的核心冲突来源。
六、两阶段锁协议(2PL):锁什么时候释放
光知道”怎么加锁”还不够,工程上更要命的是”什么时候放锁”。InnoDB 遵循两阶段锁协议(Two-Phase Locking):加锁阶段(事务执行中,按需逐步获取锁)和收缩阶段(提交或回滚时,一次性释放所有锁)。也就是说,一把锁一旦加上,会一直持有到事务结束,中间不会提前释放。
这个结论带来的关键实践是:尽量把最可能引起锁冲突的写操作(尤其是 SELECT … FOR UPDATE、UPDATE 热点行)放在事务的后面执行,以此缩短排他锁的持有时间,减少别的会话被阻塞的窗口。一个典型反例是:先 SELECT … FOR UPDATE 锁住一行做一堆远程调用(RPC / 发消息),再做本地更新——这段 RPC 耗时里锁一直握在手里,并发直接被打垮。
-- 反例:锁占用时间 = RPC耗时 + 本地更新,窗口太长
BEGIN;
SELECT * FROM account WHERE id=1 FOR UPDATE; -- 早早加锁
CALL remote_risk_check(1); -- 长达几百毫秒的 RPC
UPDATE account SET balance=balance-100 WHERE id=1;
COMMIT; -- 锁在这里才释放
-- 正例:先做完外部依赖,最后再加锁改数据
BEGIN;
SET @ok = remote_risk_check(1); -- 先 RPC,不加锁
SELECT * FROM account WHERE id=1 FOR UPDATE; -- 临近提交才加锁
UPDATE account SET balance=balance-100 WHERE id=1;
COMMIT; -- 锁持有窗口极短
七、自增锁(AUTO-INC Lock)
给列加了 AUTO_INCREMENT 后,插入时要分配递增的值,这个过程由自增锁保护。传统实现是语句级表锁:一条 INSERT 执行期间持有,执行完释放;对于 INSERT … SELECT 这类”插入行数不确定的语句”仍用这种方式,会阻塞其他插入,保证自增值连续。
轻量级模式(innodb_autoinc_lock_mode=2,8.0 默认)改为:在生成本次插入需要的自增值时获取一下轻量级锁,生成完立刻释放,不等整条语句执行完。优点是不用锁表、插入并发更高,代价是”一个语句内分配的自增值可能不连续”(但业务通常不依赖连续性)。面试被问”自增主键会不会空洞”,答案就在这里:并发插入、事务回滚、轻量级模式,都会导致自增值出现空洞,这是正常现象。
八、锁兼容矩阵
把前面所有锁类型放进一张矩阵,能一眼看清谁和谁冲突。行级 S/X 的兼容前面讲过,这里补齐表级意向锁与行锁的关系:
| 已持有 \ 请求 | S(行) | X(行) | IS(表) | IX(表) |
|---|---|---|---|---|
| S(行) | 兼容 | 冲突 | 兼容 | 冲突 |
| X(行) | 冲突 | 冲突 | 冲突 | 冲突 |
| IS(表) | 兼容 | 冲突 | 兼容 | 兼容 |
| IX(表) | 冲突 | 冲突 | 兼容 | 兼容 |
特别注意间隙锁的兼容特例:两个间隙锁之间永远兼容(都只是防插入,互不排斥),但插入意向锁与已存在的间隙锁冲突。这条规则是理解下一节死锁的关键。
九、死锁:成因、检测与避免
死锁就是两个或多个事务互相等待对方释放锁,形成环路,谁都推进不下去。InnoDB 通过**等待图(Wait-For Graph)**实时检测环:一旦发现死锁,立刻回滚”代价较小”的事务(通常是修改行数少、undo 少的那个),抛出 1213 错误,让另一个事务继续。这与 lock_wait_timeout(默认 50 秒)的超时回滚不同——死锁检测是立即发生的。
9.1 死锁的四个必要条件
缺一不可:互斥(锁只能被一个事务持有)、请求与保持(持锁的同时请求新锁)、不可剥夺(锁只能由持有者自己释放)、循环等待(事务间形成等待环)。
9.2 可复现场景一:ABBA 顺序反转
-- 两个会话,以相反顺序更新两行
-- 会话 A 会话 B
BEGIN; BEGIN;
UPDATE t SET c=1 WHERE id=1; UPDATE t SET c=1 WHERE id=2;
UPDATE t SET c=1 WHERE id=2; UPDATE t SET c=1 WHERE id=1;
-- A 等 B 释放 id=2,B 等 A 释放 id=1,环路 → 死锁,一方报 1213
9.3 可复现场景二:间隙锁 + 插入意向锁
-- 普通索引 idx_ct 上值 [10:00, 10:30, 11:00]
-- 会话 A 会话 B
BEGIN; BEGIN;
SELECT * FROM o WHERE ct BETWEEN '10:00' AND '11:00'
FOR UPDATE; -- 锁住 (10:30,11:00) 等间隙 SELECT * FROM o WHERE ct BETWEEN '10:00' AND '11:00'
FOR UPDATE; -- 同样锁住该间隙
INSERT INTO o(ct) VALUES ('10:45'); -- 请求插入意向锁,被 B 的间隙锁阻塞
INSERT INTO o(ct) VALUES ('10:50'); -- 被 A 的间隙锁阻塞 → 互等 → 死锁
9.4 怎么避免死锁
工程上不是”消灭死锁”,而是”降低概率 + 兜底重试”:
- 统一加锁顺序:多行更新时,所有事务按固定顺序(如 ID 升序)获取锁,从根上打破循环等待。代码层对 ID 列表排序后再更新。
- 大事务拆小:缩短持锁时间,减少冲突窗口;避免在事务里做 RPC、复杂计算。
- 合理加索引:确保查询走索引,避免无索引导致锁全表、锁范围爆炸。
- 降低隔离级别:非严格场景从 RR 降到 RC,消除间隙锁,死锁概率大幅下降(代价是不能防幻读,用唯一约束兜底)。
- 应用层重试:捕获 1213 异常,加随机退避后重试;否则两个事务可能立刻以相同顺序再次竞争,连环死锁。
十、锁排查实战
理论和事故之间,差的就是”能亲手观测”。下面三件工具,是线上遇到锁等待、死锁时的标准排查路径。
10.1 performance_schema.data_locks(8.0+ 首选)
这是 8.0 引入的锁信息表,取代了老版本的 INNODB_LOCKS,能直接看到”谁持有了什么锁、锁在哪一行的哪个索引”。排查锁等待时,先看这张表比看任何文档都直观。
-- 查看当前所有 RECORD(行)锁,含事务 ID、表名、索引、锁模式
SELECT
engine_transaction_id,
object_name,
index_name,
lock_type,
lock_mode,
lock_status, -- GRANTED 已持有 / WAITING 在等
lock_data -- 被锁的主键值(能看到具体锁了哪一行)
FROM performance_schema.data_locks
WHERE lock_type = 'RECORD';
-- 看谁在等、等谁:data_lock_waits 给出阻塞关系
SELECT
requesting_engine_transaction_id AS 等待方,
blocking_engine_transaction_id AS 阻塞方
FROM performance_schema.data_lock_waits;
lock_mode 常见值含义:S / X 是共享/排他记录锁;S,GAP / X,GAP 是间隙锁;X,REC_NOT_GAP 是纯记录锁(无间隙);INSERT_INTENTION 是插入意向锁。看到 WAITING 状态且 lock_mode 带 GAP,基本就是被间隙锁挡住了。
10.2 SHOW ENGINE INNODB STATUS
看死锁现场,最经典的是输出里的 LATEST DETECTED DEADLOCK 段,它会把参与死锁的两个事务各自持有什么锁、在等什么锁、对应的 SQL 和索引都列出来。重点看三件事:哪个事务被回滚了(代价较小的那个)、两个事务各等的是什么锁(lock_mode X locks gap before rec insert intention waiting 表示在等插入意向锁)、涉及的索引(普通索引上的 Gap Lock 概率最高)。
-- 开启总死锁日志(生产环境建议常开,否则只保留最近一次)
SET GLOBAL innodb_print_all_deadlocks = ON;
SHOW ENGINE INNODB STATUS; -- 关注 LATEST DETECTED DEADLOCK 段
十一、乐观锁 vs 悲观锁
前面讲的都是 InnoDB 内置的”悲观锁”(假设冲突会高,先加锁再操作)。业务层还有一种常用的”乐观锁”(假设冲突低,操作时不加锁,提交时再校验),面试常和数据库锁一起问。
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 实现 | SELECT … FOR UPDATE / LOCK IN SHARE MODE,由数据库加锁 | 版本号或 CAS:UPDATE t SET val=?, ver=ver+1 WHERE id=? AND ver=?,影响行数=1 即成功,0 则冲突重试 |
| 适用 | 写多读少、冲突频繁 | 读多写少、冲突概率低 |
| 代价 | 持锁期间阻塞他人,易锁等待 / 死锁 | 高冲突下大量重试,浪费 CPU |
-- 乐观锁典型写法:扣库存前比对版本号
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5;
-- 若返回影响行数 0,说明 version 已被别人改过,本次扣减失败,业务层重试
十二、综合案例:一条 UPDATE 到底加了哪些锁
把前面所有概念串起来。假设 user 表,主键 id,普通索引 idx_age,当前 age 值有 18、20、20、25。
-- 事务执行
BEGIN;
UPDATE user SET name='x' WHERE age = 20;
-- 用 data_locks 观测(8.0+)
SELECT index_name, lock_type, lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_name='user' AND engine_transaction_id = <本事务ID>;
预期加锁结果:因为 age 是普通索引且等值查询,按 4.1 规则走 Next-Key Lock——会对两条 age=20 的记录加 X 记录锁,并对它们前后的空隙加间隙锁,即锁住 (18,20] 和 (20,25] 两个区间。若换成主键等值命中(WHERE id=5),则退化为纯记录锁,只锁 id=5 这一行,并发度最高。这个案例直接说明了”为什么查询要尽量走唯一索引/主键”——锁范围能从”一片区间”缩到”一行”。
十三、小结与下篇预告
本篇从”MVCC 管读、锁管写”这条主线出发,把 InnoDB 的锁全景拆了一遍:按粒度分全局/表/行锁,按模式分 S/X,再加上 MDL、意向锁、自增锁这几类特殊用途锁;行锁加在索引上(无索引则退化全表锁),间隙锁防插入、Next-Key Lock 在 RR 下既锁行又锁空隙;两阶段锁协议决定了锁要到事务结束才释放,所以写冲突操作要后置;死锁靠等待图检测并回滚代价小的一方,避免手段是统一顺序、拆小事务、降 RC、应用重试;排查用 data_locks + SHOW ENGINE INNODB STATUS。
下一篇(六)我们进入日志系统:前面反复提到的 Redo Log、Undo Log、Binlog,到底各自负责什么、为什么写、怎么配合完成崩溃恢复和主从复制。尤其是 Redo 和 Binlog 的”两阶段提交”——那是 MySQL 数据不丢、主从一致的真正命门,我们下篇翻过去。
十四、面试高频考点清单
1. InnoDB 行锁为什么依赖索引?
行锁加在索引项上,不是物理数据行上。WHERE 必须能命中索引,才能精准锁定某行;否则全表扫描,给聚簇索引每一行加锁,等效锁全表。
2. 哪些情况行锁会退化成表锁?
三类:条件列完全没索引;索引”有但失效”(函数包裹、隐式类型转换、前导通配符 LIKE ‘%x’);范围查询没走上唯一索引导致临键锁范围过大。本质都是”无法通过索引精准定位”,只能扩大锁范围。
3. RC 和 RR 在锁上最大区别是什么?
RC 只有记录锁,没有间隙锁和 Next-Key Lock,每次查询刷新快照、无法防幻读;RR 有间隙锁 + Next-Key Lock,配合 MVCC 最大程度规避幻读。所以很多公司生产直接用 RC——并发更高、死锁更少,幻读交给唯一约束或乐观锁兜底。
4. Next-Key Lock 什么时候降级成记录锁?
唯一索引(主键/唯一键)的等值查询且命中时,直接锁定单行,退化为纯记录锁,不需要锁间隙,并发度最高。普通索引等值查询则会锁行 + 锁两侧间隙。范围查询一律逐区间加 Next-Key Lock。
5. 间隙锁会不会阻塞 UPDATE / DELETE?
不会。间隙锁只锁”空隙”,只阻塞 INSERT 新记录;已有行的修改、删除不受影响。这也是为什么两个事务可以同时持有同一空隙的间隙锁(间隙锁之间兼容),但谁要往里插数据谁就被阻塞。
6. 死锁的四个必要条件,怎么避免?
互斥、请求与保持、不可剥夺、循环等待,缺一不可。避免手段:统一加锁顺序(多行更新前对 ID 排序)、大事务拆小、合理加索引缩小锁范围、非严格场景降 RC、应用层捕获 1213 异常加退避重试。
7. 怎么排查线上死锁?
开启 innodb_print_all_deadlocks(生产建议常开);SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK 段,关注被回滚方、双方等待的锁模式、涉及索引;8.0+ 用 performance_schema.data_locks 和 data_lock_waits 直接看锁持有与阻塞关系。
8. 意向锁(IS/IX)能不能手动加?有什么用?
不能,由 InnoDB 自动维护。它是表级”信号灯”,事务要在某行加 S/X 锁前先在表上加对应意向锁,用来快速判断表内是否有行被锁,避免为了检查行锁而逐行扫描。意向锁之间全兼容,只与表级 S/X 锁互斥。
9. MDL 元数据锁的坑是什么?
访问表时自动加 MDL 读锁,做 DDL 时加 MDL 写锁。长事务不提交会一直持有 MDL 读锁,导致后续 DDL 阻塞,进而所有新读写排队雪崩。排查看 performance_schema.metadata_locks 的 PENDING 状态,kill 掉长事务持有者即可。
10. 乐观锁和悲观锁的区别,怎么实现?
悲观锁假设冲突高,先加锁再操作(SELECT … FOR UPDATE),适合写多读少。乐观锁假设冲突低,操作不加锁、提交时校验,典型实现是版本号 / CAS:UPDATE … SET val=?, ver=ver+1 WHERE id=? AND ver=?,影响行数 0 即冲突重试,适合读多写少。高冲突下乐观锁会因大量重试浪费 CPU。
加分题:RR 下幻读到底防住没有?
呼应第④篇——快照读靠 MVCC 防住幻读,当前读靠 Next-Key Lock 防住幻读,两条路合起来覆盖了绝大多数场景。但如果一个事务先快照读(判断某行不存在)、再当前读 UPDATE 它,就会看到自己 UPDATE 带进来的”幻影行”,这是混合用法的残留漏洞。能讲清这个边界,是区分”真懂”和”背了十个字”的关键。
十五、下一篇(六)待解问题
本篇把”锁”这座大山翻过去了,但要真正保证 ACID,还差一个绕不开的支撑层——日志。本篇反复提到”原子性靠 Undo、持久性靠 Redo”,可 Undo / Redo 到底是什么、为什么非写日志不可、崩溃之后又怎么恢复,我们一直没展开。这正是留给第⑥篇的待解问题:
- Undo Log 除了回滚,为什么还能支撑 MVCC 的多版本(版本链到底怎么和 Undo 段关联);
- Redo Log 怎么保证事务持久性、掉电也不丢,为什么写 Redo 比直接刷数据页快(WAL 思想);
- Binlog 是什么,和 Redo 有什么区别,为什么主从复制只能靠 Binlog;
- 两阶段提交(2PC)怎么让 Redo 和 Binlog 达成一致,避免”Redo 说提交了、Binlog 没记”的尴尬;
- 崩溃恢复时,MySQL 怎么用 Redo + Binlog 决定一个事务该提交还是回滚。
这些问题,正是第⑥篇《日志系统:Redo / Undo / Binlog 三件套》要逐一拆开的。本篇到此结束。