MySQL(四):锁机制
MySQL(四):锁机制
导语:锁是 MySQL 面试中最"细节密集"的部分。本篇覆盖表级锁(含 MDL、自增锁)、行级锁的三种形态(记录锁 / 间隙锁 / 临键锁)、意向锁兼容矩阵、加锁规则与两阶段锁协议、死锁排查,以及乐观锁与悲观锁。共 11 题。
一、锁的粒度与类型
1. MySQL 有哪些锁?表级锁和行级锁有什么区别?
答: 按锁粒度划分:
| 维度 | 表级锁 | 行级锁 |
|---|---|---|
| 锁定范围 | 整张表 | 只锁相关行 |
| 支持引擎 | MyISAM、MEMORY 的默认;InnoDB 也支持 | 仅 InnoDB |
| 加锁开销 | 小、快 | 大、慢(要定位到索引记录) |
| 并发度 | 最低(所有写互相阻塞) | 最高 |
| 死锁风险 | 基本无 | 可能有 |
InnoDB 同时支持表锁和行锁,默认走行锁。
最关键的一句话:InnoDB 的行锁是加在「索引记录」上的。若
WHERE条件没有命中索引(或索引失效),InnoDB 只能全表扫描,并对扫描到的每一行都加锁——最终效果等同于锁住了全表,并发度骤降且极易阻塞(详见第 8 题)。
2. 表级锁还有哪些类型?MDL 和自增锁是什么?
答: 除了"显式表锁"(LOCK TABLES ... READ/WRITE,一般不用),InnoDB 还有两种由系统自动维护的重要表级锁:
1)元数据锁(MDL, Metadata Lock)
- 自动加锁,无需显式申请:访问表时自动加 MDL 读锁,修改表结构时自动加 MDL 写锁;
- 作用:保证表结构变更与数据访问之间的一致性——防止"正在查询时表结构被改";
- MDL 读锁之间兼容、读写互斥;
- 经典事故场景:一个长事务/慢查询持有 MDL 读锁,之后
ALTER TABLE(需要 MDL 写锁)被阻塞并排在队列里,而后续所有新的查询都要排在它后面——结果整张表被"堵死"。- 排查:
SHOW PROCESSLIST看到大量Waiting for table metadata lock; - 规避:DDL 前先确认没有长事务,设置
lock_wait_timeout避免无限等待。
- 排查:
2)自增锁(AUTO-INC Lock)
- 用于
AUTO_INCREMENT列的取号,保证并发插入时自增值不重复; - 由参数
innodb_autoinc_lock_mode控制,三个取值:
| 取值 | 模式 | 特点 |
|---|---|---|
0 | 传统(traditional) | 语句级表锁,并发最差;能保证批量插入的 ID 连续 |
1 | 连续(consecutive,MySQL 5.7 默认) | 简单插入用轻量级互斥量,批量插入(INSERT ... SELECT)才用表锁 |
2 | 交错(interleaved,MySQL 8.0 默认) | 全部用轻量级互斥量,并发最高;但批量插入的 ID 可能不连续 |
注意:MySQL 8.0 把默认值改为
2,因为主从复制默认用ROW格式,不再依赖语句级的 ID 连续性,因此可以放开并发。若业务强依赖自增 ID 连续(如生成单号),需显式设为1。
3. 共享锁(S)和排他锁(X)有什么区别?兼容性如何?
答:
| 锁类型 | 别名 | 作用 | 自动加锁的语句 |
|---|---|---|---|
| S 锁(Shared) | 读锁 | 允许多个事务同时读 | SELECT ... LOCK IN SHARE MODE(8.0 的 FOR SHARE) |
| X 锁(Exclusive) | 写锁 | 独占,其他事务不能加任何锁 | UPDATE、DELETE、SELECT ... FOR UPDATE |
兼容矩阵:
| S | X | |
|---|---|---|
| S | ✅ 兼容 | ❌ 互斥 |
| X | ❌ 互斥 | ❌ 互斥 |
关键点:
- 普通
SELECT不加任何锁(走 MVCC 快照读)——所以"读被写阻塞"只发生在当前读场景; - 已加 X 锁的记录,其他事务的
UPDATE、SELECT ... FOR UPDATE、LOCK IN SHARE MODE都必须等待; - 显式加锁语句:
SELECT ... FOR UPDATE(X 锁)、SELECT ... FOR SHARE/LOCK IN SHARE MODE(S 锁)。
4. 意向锁(IS/IX)的作用是什么?
答: 意向锁是表级锁,作用是让"加表锁"这个动作能快速完成。
没有意向锁的问题:若事务 A 已锁住表中某几行(行级 X 锁),事务 B 想加表级 X 锁,就必须逐行扫描判断"表里有没有行被锁"——代价极高。
有了意向锁之后:事务加行级锁之前,先自动在表上加一个"意向锁"作为标记:
- 加行级 S 锁前 → 先加表级 IS 锁(意向共享锁);
- 加行级 X 锁前 → 先加表级 IX 锁(意向排他锁)。
这样事务 B 想加表锁时,只需看表上有没有意向锁即可判断,无需逐行扫描。
兼容矩阵(表级):
| IS | IX | S | X | |
|---|---|---|---|---|
| IS | 兼容 | 兼容 | 兼容 | 互斥 |
| IX | 兼容 | 兼容 | 互斥 | 互斥 |
| S | 兼容 | 互斥 | 兼容 | 互斥 |
| X | 互斥 | 互斥 | 互斥 | 互斥 |
关键理解:意向锁之间互相兼容(IS 与 IX 兼容、IX 与 IX 也兼容)。因为意向锁只是"有人在某行上加锁"的标记,不同事务完全可以在不同行上加锁。意向锁只与表级的 S/X 锁互斥,不与行级锁互斥。
意向锁是系统自动加的,用户不需要(也无法)手动申请。
5. InnoDB 的行锁有哪几种?
答: 三种形态,都加在索引记录上:
| 类型 | 锁定对象 | 作用 |
|---|---|---|
| Record Lock(记录锁) | 单条已有的索引记录 | 锁住这一行,别的写操作等待 |
| Gap Lock(间隙锁) | 两条记录之间的"间隙"(不含记录本身) | 只阻止插入,不阻止其他事务对已存在行的修改 |
| Next-Key Lock(临键锁) | 间隙 + 记录本身,即左开右闭区间 (a, b] | Record + Gap 的组合 |
默认行为:InnoDB 在 RR 级别默认使用 Next-Key Lock,这是它能在 RR 下防止幻读的关键。
退化规则(高频考点):
- 唯一索引上的等值查询命中记录 → Next-Key Lock 退化为 Record Lock(因为唯一性保证不会再有相同的值,无需锁间隙);
- 唯一索引上的等值查询未命中 → 退化为 Gap Lock;
- RC 级别下禁用 Gap Lock,只有 Record Lock。
一句话记忆:"记录锁锁行、间隙锁锁缝、临键锁锁行加缝"。
6. 什么是临键锁(Next-Key Lock)?如何防止幻读?
答: Next-Key Lock = Record Lock + Gap Lock,锁定一个左开右闭的区间,例如 (10, 20]:既锁住 20 这条记录,也锁住 10 到 20 之间的间隙。
如何防幻读:
- 事务对范围内数据做当前读时,不仅锁住已有记录,还锁住相邻间隙;
- 其他事务无法在区间内插入新记录(插入前必须获得插入意向锁,而它与 Gap Lock 冲突);
- 因此本事务后续的当前读不会看到"凭空多出来的行"。
举例:
-- 表 t 的 id 有记录 10、20、30
-- 事务 A(RR)
SELECT * FROM t WHERE id = 20 FOR UPDATE; -- 若无唯一索引:锁住 (10, 20] 甚至更宽
-- 事务 B
INSERT INTO t (id) VALUES (15); -- 被阻塞!15 落在被锁的间隙内R 级别差异:
| 级别 | Gap Lock | 能否防当前读幻读 |
|---|---|---|
| RR | 启用 | ✅ 能 |
| RC | 禁用(仅外键与唯一性校验场景除外) | ❌ 不能 |
为什么 RC 禁用间隙锁:间隙锁会锁住"不存在的记录",导致锁范围不可预期、冲突与死锁增多。RC 选择牺牲"防幻读"来换取更高并发——这也是很多互联网公司改用 RC 的原因(见《MySQL(三)》第 16 题)。
7. 间隙锁(Gap Lock)的作用与注意事项?
答: Gap Lock 锁住索引记录之间的间隙(以及首尾之外的范围),唯一作用是阻止其他事务在这个间隙里插入,是 Next-Key Lock 的组成部分。
四个必须知道的注意事项:
- 只在 RR 级别生效,RC 下会关闭(
innodb_locks_unsafe_for_binlog历史参数,现已由隔离级别决定); - 间隙锁之间互不冲突——两个事务可以同时锁住同一个间隙,因为它们的目的都是"防插入",彼此不矛盾。这一点与直觉相反,也是"为什么加了间隙锁还会死锁"的根源之一;
- 会导致"插入被阻塞",表现为业务侧莫名卡住(常见于批量写入撞上某事务的范围锁);
- 唯一索引上的等值查询若命中记录,会退化为 Record Lock(不锁间隙);只有范围查询或未命中时才使用 Gap / Next-Key Lock。
排查小技巧:
SELECT * FROM performance_schema.data_locks;(MySQL 8.0)可以查看当前的锁等待情况,比老版本的INNODB_LOCKS更直观。
8. 行锁到底是加在数据行上还是索引上?为什么没命中索引会锁全表?
答: 行锁加在「索引记录」上,而不是直接加在数据行上。
UPDATE/DELETE的WHERE命中了索引 → InnoDB 通过索引定位到匹配的索引记录,只锁这些记录及必要的间隙;WHERE字段没有索引、或索引失效(函数、隐式转换等) → InnoDB 只能全表扫描,对扫描到的每一行都加锁。虽然最终只修改符合条件的行,但扫描过程中行行加锁,实际表现等同于锁住全表。
后果:并发度急剧下降,其他事务几乎全部阻塞,极易引发锁等待超时与死锁。
推论与最佳实践:
UPDATE/DELETE的WHERE条件字段必须有索引(这是最重要的一条);- 避免在索引列上做函数运算或隐式类型转换(会让索引失效,锁范围扩大到全表);
- 尽量缩小更新范围(精确到主键),避免大范围
UPDATE; - 若必须大批量更新,分批执行并控制每批行数,减少单次持锁时间。
对比理解:MyISAM 只有表锁,所以"锁全表"是它的常态;InnoDB 的优势正来自"锁索引记录"带来的细粒度并发——但这个优势的前提是"走了索引"。
9. 一条 UPDATE 语句的加锁规则是怎样的?
答: 这是锁部分的"压轴题",核心是记住 2 个原则 + 2 个优化(出自《MySQL 实战 45 讲》的总结):
两个原则:
- 加锁的基本单位是 Next-Key Lock(前开后闭区间
(a, b]); - 只有"查找过程中真正访问到的对象"才会加锁——所以索引选择直接决定锁的范围。
两个优化:
- 唯一索引上的等值查询命中记录 → Next-Key Lock 退化为 Record Lock(唯一性已保证不会再有同值记录,没必要锁间隙);
- 非唯一索引上的等值查询,向右遍历到第一个不满足条件的记录时 → Next-Key Lock 退化为 Gap Lock(因为只需要阻止"等于该值"的新记录插入,无需锁住更大范围)。
一个"Bug"(面试加分点):
- 唯一索引上的范围查询,会一直扫描到不满足条件的第一个值为止,因此会多锁一个 next-key lock——锁范围比直觉上更大。
示例演练:表 t(id 主键, c 普通索引),数据 c = 5, 10, 15, 20:
-- A 事务执行
UPDATE t SET d = d + 1 WHERE c = 10; -- 命中 c=10 的记录
-- 分析加锁:
-- 1. c 是非唯一索引,先在 c 索引上加 Next-Key Lock (5, 10]
-- 2. 向右遍历到 c=15(第一个不满足 c=10 的),退化为 Gap Lock (10, 15)
-- 3. 再回到主键索引,对 id 加 Record Lock配套概念:两阶段锁协议(2PL)
- InnoDB 在事务执行过程中不断加锁,但所有锁都要等到事务提交/回滚时才统一释放;
- 推论(最重要的实践):把最可能造成锁冲突、最可能影响并发度的操作尽量放到事务的最后,以缩短持锁时间。
- 反例:一个事务里先锁住热门商品行,然后去调用外部 RPC/耗时计算,最后才提交——持锁时间被无限拉长,其他事务全部排队。
一句话总结:"锁的单位是 next-key lock,锁的范围由索引决定,所有锁在事务提交时才释放——所以要控制好事务边界与执行顺序。"
二、死锁与并发控制
10. 死锁是怎么产生的?如何避免和排查?
答: 死锁指两个(或多个)事务互相持有对方需要的锁,形成循环等待,全部无法推进。
典型场景:T1 先锁 A 行再请求 B 行,T2 先锁 B 行再请求 A 行。此外还有一个 MySQL 特有场景:两个事务都持有间隙锁,然后同时尝试插入(间隙锁互不冲突,双方都能拿到,随后插入意向锁互斥 → 死锁)。
四个必要条件(同 Java 死锁,Coffman 条件):互斥、占有并等待、不可剥夺、循环等待。
避免手段:
| 手段 | 说明 |
|---|---|
| 约定固定访问顺序 | 所有事务按相同顺序(如按主键升序)更新表和行,破坏循环等待 |
| 缩短事务、尽快提交 | 两阶段锁下锁一直持有到提交,事务越长锁越久,冲突概率越大 |
| 保证 WHERE 走索引 | 避免锁范围扩大到全表(见第 8 题),大幅降低冲突面 |
| 降低隔离级别(RC) | 禁用间隙锁,减少锁范围与"间隙锁 + 插入意向锁"型死锁 |
| 拆分大事务/批量操作 | 避免一次更新大量行;大批量操作分批执行 |
| 业务层重试 | 捕获死锁异常后随机退避重试(死锁是正常现象,不可能完全消除) |
排查手段:
| 手段 | 用法 |
|---|---|
SHOW ENGINE INNODB STATUS | 查看 LATEST DETECTED DEADLOCK 段落,能看到两个事务各自持有什么锁、在等什么锁、执行的 SQL,是最直接的依据 |
innodb_print_all_deadlocks = ON | 把所有死锁信息写入错误日志(默认只保留最近一次) |
performance_schema.data_locks / data_lock_waits | MySQL 8.0 查看当前锁与等待关系 |
INFORMATION_SCHEMA.INNODB_TRX | 查看当前运行的事务及其持锁情况 |
重要认知:死锁是数据库正常现象,无法彻底消除。工程上的目标不是"杜绝死锁",而是减少发生频率 + 保证发生时可自动恢复(应用层重试 + 监控告警)。
11. 乐观锁与悲观锁分别是什么?SELECT ... FOR UPDATE 属于哪种?
答:
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 思想 | 假设冲突一定会发生,操作前先加锁 | 假设冲突很少,不加锁,提交时才校验 |
| 实现 | SELECT ... FOR UPDATE(X 锁)、LOCK IN SHARE MODE(S 锁) | 版本号 / 时间戳字段 + WHERE version = old;或 CAS 思想 |
| 适用 | 写冲突频繁、需要强一致的场景(如扣库存、账务) | 读多写少、冲突概率低的场景 |
| 缺点 | 阻塞等待、可能死锁、并发度低 | 冲突时需业务重试;高冲突下重试开销大 |
SELECT ... FOR UPDATE 属于悲观锁,加的是排他锁(X 锁),且是当前读(读最新已提交版本)。
乐观锁的典型写法:
-- 1. 先查询拿到版本号
SELECT id, stock, version FROM product WHERE id = 1; -- version = 5
-- 2. 更新时带上版本条件
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 影响行数 = 0 → 说明已被其他事务修改,业务层需重新查询并重试三种方案的选择建议:
- 能用原子操作就别加锁:
UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0—— 一条 SQL 完成"判断 + 扣减",天然原子且无显式锁; - 需要"先读再判断"的复杂逻辑 → 用
FOR UPDATE(悲观锁)或乐观锁 + 重试; - 读多写少、冲突低 → 乐观锁(避免持锁);
- 绝不能只查不锁:
SELECT查库存 → 判断 →UPDATE,这三步之间没有任何保护,是超卖的经典 bug。
