RSS
菜单
全部文章快讯开发科技深度热点

第 78 章 速查卡:锁与MVCC

内容摘要

锁与 MVCC 是 MySQL 并发控制的两大基石,也是面试高频考点。本篇以表格化形式汇总锁类型、行锁表锁对比、三种锁算法(record/gap/next-key)、MVCC 原理、死锁排查、锁等待排查等内容。所有示例基于 MySQL 8.0 语法。读完你能快速判断一条 SQL 会加什么锁、会不会死锁、怎么排查锁问题。

📖 本篇简介:锁与 MVCC 是 MySQL 并发控制的两大基石,也是面试高频考点。本篇以表格化形式汇总锁类型、行锁表锁对比、三种锁算法(record/gap/next-key)、MVCC 原理、死锁排查、锁等待排查等内容。所有示例基于 MySQL 8.0 语法。读完你能快速判断一条 SQL 会加什么锁、会不会死锁、怎么排查锁问题。

锁和 MVCC 是一对搭档:读用 MVCC(不加锁),写用锁。理解了这俩,MySQL 并发模型就通了一大半。

面试问锁,十有八九会问到 next-key lock、间隙锁、MVCC 可见性判断这三块。本篇把这些整理成表格,考前过一遍。

目录

  • 锁类型速查表
  • 行锁与表锁对比
  • 三种行锁算法
  • MVCC 与锁的关系
  • MVCC 实现细节速记
  • 死锁排查命令
  • 锁等待排查命令
  • 锁相关参数速查
  • 面试高频问答
  • 常见误区澄清
  • 隔离级别与锁对比
  • 加锁规则速记
  • 常见锁问题场景
  • 死锁案例速记
  • 自测:锁与MVCC速查

锁类型速查表

| 锁类型 | 说明 | 加锁时机 |

|---|---|---|

| 共享锁 S | 读锁,可共存 | SELECT ... LOCK IN SHARE MODE |

| 排他锁 X | 写锁,互斥 | UPDATE/DELETE/SELECT ... FOR UPDATE |

| 意向锁 IS/IX | 表级锁,表明意图 | 加行锁前自动加 |

| 记录锁 | 锁单行 | 唯一索引等值查询 |

| 间隙锁 | 锁范围间隙 | 范围查询,防插入 |

| Next-Key Lock | 记录+间隙 | RR 默认,防幻读 |

| 临键锁 | next-key 同义词 | |

| 插入意向锁 | INSERT 前等待 | INSERT 时 |

| AUTO-INC 锁 | 自增锁 | INSERT 自增列 |

| Metadata Lock | 元数据锁 | DDL 时 |

| 全局锁 | FTWRL,整库只读 | 备份时 |

| 命名锁 | RENAME TABLE 时 | 表名变更 |

| 保护锁 | 防止表被 DROP | DDL/DML 冲突 |

| 全局读锁 | FTWRL,整库只读 | 逻辑备份 |

| 备份锁 | 8.0 在线备份锁 | 不阻塞 DML |

| 命名锁 | RENAME TABLE 时 | 表名变更 |

| 保护锁 | 防止表被 DROP | DDL/DML 冲突 |

锁按粒度分:全局锁、表锁、行锁。按性质分:共享锁(S)、排他锁(X)。InnoDB 默认用行锁,并发高。

意向锁(IS/IX)是表级锁,加行锁前自动加,目的是让表锁快速判断有没有行锁,不用逐行查。

-- 显式加锁示例(本示例基于 MySQL 8.0)
-- 共享锁
SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE;
-- 排他锁
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 现在就跳过大事务,不用等(8.0)
SELECT * FROM account WHERE id = 1 FOR UPDATE NOWAIT;

行锁与表锁对比

| 对比项 | 行锁 | 表锁 |

|---|---|---|

| 粒度 | 行 | 整张表 |

| 并发 | 高 | 低 |

| 开销 | 大(加锁慢) | 小(加锁快) |

| 存储 | 锁对象多 | 锁对象少 |

| 适用引擎 | InnoDB | MyISAM / Memory |

| 死锁 | 可能 | 不可能 |

| 适用 | InnoDB 并发写 | MyISAM / DDL |

| 什么时候触发 | WHERE 走索引 | 不走索引、DDL |

| 释放时机 | 事务提交/回滚 | 语句结束 |

| 并发度 | 高 | 低 |

| 开销 | 大 | 小 |

表锁粒度大,并发低,但加锁快、开销小。行锁粒度小,并发高,但加锁慢、开销大。MyISAM 只用表锁,InnoDB 默认行锁。

关键结论:InnoDB 行锁是加在索引上的。如果 WHERE 不走索引,会退化成表锁(实际是全表加记录锁,效果等于表锁)。

三种行锁算法

| 算法 | 锁定范围 | 触发条件 |

|---|---|---|

| Record Lock | 单条记录 | 唯一索引等值查询,记录存在 |

| Gap Lock | 间隙(开区间) | 范围查询,防插入 |

| Next-Key Lock | 记录 + 前面间隙(左开右闭) | RR 默认,范围查询 |

| 插入意向锁 | INSERT 时等待间隙锁 | INSERT 语句 |

InnoDB 行锁有三种算法,理解了这三种就能判断一条 SQL 会锁哪些行。

-- 假设有索引 id: 1, 5, 10
-- SELECT * FROM t WHERE id = 5 FOR UPDATE
--   → Record Lock 锁 id=5 这一行
-- SELECT * FROM t WHERE id > 3 AND id < 8 FOR UPDATE
--   → Next-Key Lock 锁 (3, 5] 和 (5, 10) 之间的间隙
--   → 其他事务无法在这个范围 INSERT

间隙锁只在 RR 级别有。RC 级别没有间隙锁,所以 RC 会幻读。

插入意向锁:INSERT 时如果碰到间隙锁,要等。多个 INSERT 插入不同位置不互斥。

MVCC 与锁的关系

| 机制 | 读 | 写 |

|---|---|---|

| MVCC | 快照读,不加锁 | 不涉及 |

| 当前读 | 加锁读(FOR UPDATE 等) | 加 X 锁 |

| 锁 | 不涉及 | 保证写写互斥 |

| 阻塞 | 不阻塞写 | 读写不互相阻塞 |

MVCC 让读不加锁,锁只管写。这样读写不互相阻塞,并发性能高。

快照读 vs 当前读:

  • 快照读:普通 SELECT,走 MVCC,读历史版本,不加锁
  • 当前读:SELECT ... FOR UPDATE、UPDATE、DELETE,读最新版本,加锁
-- 快照读(本示例基于 MySQL 8.0)
SELECT * FROM account WHERE id = 1;  -- MVCC,不加锁

-- 当前读
SELECT * FROM account WHERE id = 1 FOR UPDATE;  -- 加 X 锁
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- 加 X 锁

关键:UPDATE 隐含当前读,先读最新版本再加锁。如果一个事务里先快照读再 UPDATE,可能看不到其他事务刚提交的修改,UPDATE 时才发现变了。

MVCC 实现细节速记

MVCC 是 InnoDB 高并发的核心,面试必问。理解它要拆开看三个组件。

MVCC = 隐藏列 + undo log 版本链 + ReadView。

| 组件 | 作用 |

|---|---|

| DB_TRX_ID | 最近修改这行的事务 ID |

| DB_ROLL_PTR | 回滚指针,指向上一个版本 undo log |

| undo log | 保存历史版本,形成版本链 |

| ReadView | 读视图,判断哪个版本可见 |

| 版本链 | undo log 串起来,找可见版本 |

ReadView 四要素:

  • m_ids:当前活跃事务 ID 列表
  • min_trx_id:最小活跃事务 ID
  • max_trx_id:下一个将分配的事务 ID
  • creator_trx_id:创建这个 ReadView 的事务 ID

可见性算法:版本的 trx_id 如果在 m_ids 里 → 不可见;< min_trx_id → 可见;> max_trx_id → 不可见;等于 creator → 可见。

RC vs RR 的 ReadView:

  • RC:每条 SELECT 都生成新 ReadView,能看到其他事务刚提交的
  • RR:事务第一条 SELECT 生成 ReadView,之后复用,可重复读

长事务危害:长事务持有 ReadView 很久,undo log purge 不掉,表越来越大。

死锁排查命令

死锁是并发写的常见问题。InnoDB 自动检测死锁,回滚代价小的事务。但要定位根因,得看死锁日志。

-- 查看最近死锁日志(本示例基于 MySQL 8.0)
SHOW ENGINE INNODB STATUS;
-- 找 LATEST DETECTED DEADLOCK 段

-- 查看当前锁等待
SELECT * FROM information_schema.innodb_lock_waits;

-- 查看当前持锁事务
SELECT * FROM information_schema.innodb_trx;

-- 杀死阻塞事务
KILL <trx_mysql_thread_id>;
-- KILL 后事务自动回滚,释放锁

死锁四个必要条件:互斥、持有并等待、不可剥夺、循环等待。破坏任意一个就能避免死锁。

死锁发生后:InnoDB 自动检测,回滚代价小的那个事务(undo log 少的),另一个继续。业务代码要捕获死锁异常并重试。

死锁日志怎么看:SHOW ENGINE INNODB STATUS 里 LATEST DETECTED DEADLOCK 段,会打印两个事务分别等什么锁、持什么锁。

锁等待排查命令

锁等待是业务卡住的常见原因。排查思路:先看谁在等锁,再看谁持着锁不放。

-- 查看锁等待超时时间(本示例基于 MySQL 8.0)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

-- 查看当前谁在等锁
SELECT
  r.trx_wait_started AS wait_time,
  b.trx_mysql_thread_id AS block_thread,
  r.trx_mysql_thread_id AS wait_thread
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
-- 查到 block_thread 后 KILL 掉它,等待方就能继续

锁等待超时:默认 50 秒,超时后报 ERROR 1205。生产环境建议改小到 5~10 秒,快速失败别占着。

排查思路:先看 innodb_trx 有没有长事务,再看 innodb_lock_waits 谁等谁,找到阻塞源 KILL 掉。

常见阻塞源:忘记提交的事务、大事务、DDL 等 MDL 锁、备份拿全局锁。

锁相关参数速查

| 参数 | 默认值 | 说明 |

|---|---|---|

| innodb_lock_wait_timeout | 50 秒 | 行锁等待超时 |

| innodb_deadlock_detect | ON | 死锁检测 |

| innodb_deadlock_log_prioritize | | 死锁日志优先级 |

| transaction_isolation | REPEATABLE-READ | 隔离级别 |

| innodb_autoinc_lock_mode | 1 | 自增锁模式 |

| innodb_print_all_deadlocks | OFF | 死锁打印到 error log |

| innodb_status_output_locks | OFF | 锁状态输出 |

| lock_wait_timeout | 31536000 | 表锁等待超时 |

| innodb_deadlock_detect | ON | 死锁检测开关 |

| innodb_thread_concurrency | 0 | 并发线程数限制 |

| innodb_rollback_on_timeout | OFF | 超时是否回滚整个事务 |

| innodb_print_all_deadlocks | OFF | 死锁打印到 error log |

锁相关参数大部分用默认值就行,但有几个建议根据业务调优。

面试高频问答

| 问题 | 一句话答案 |

|---|---|

锁与 MVCC 面试高频题,每题一句话答案,考前过一遍。

| 问题 | 一句话答案 |

|---|---|

| InnoDB 行锁加在哪 | 加在索引记录上,不走索引就锁全表 |

| 什么是 next-key lock | 记录锁+间隙锁,左开右闭区间 |

| RC 为什么没有间隙锁 | RC 不防幻读,不需要间隙锁 |

| 死锁怎么产生 | 两事务循环等锁 |

| 怎么避免死锁 | 统一加锁顺序、缩短事务、走索引 |

| MVCC 是什么 | 多版本并发控制,读写不阻塞 |

| 快照读和当前读区别 | 快照读读历史版本不加锁,当前读读最新加锁 |

| 什么是意向锁 | 表级锁,表明要加行锁的意图 |

| 表锁什么时候触发 | DDL、不走索引的 DML |

| 自增锁是什么 | 保证自增列连续,AUTO-INC |

| RC 和 RR 怎么选 | 互联网一般用 RC,金融强一致用 RR |

| 间隙锁什么时候有 | RR 级别范围查询 |

| 怎么排查死锁 | SHOW ENGINE INNODB STATUS 看日志 |

| 死锁了怎么办 | 自动回滚代价小的事务,业务重试 |

| 锁等待超时怎么办 | KILL 阻塞者,缩短事务 |

| 什么是元数据锁 | DDL 时保护表结构,防并发 DML |

| 为什么慢 SQL 锁全表 | WHERE 不走索引,全表加行锁 |

| 什么是插入意向锁 | INSERT 前等间隙锁的锁 |

| 自增锁有几种模式 | 0/1/2,2 是 interleaved 性能最好 |

| MDL 锁什么时候有 | DDL 操作时,保护表结构 |

| 为什么慢 SQL 会锁全表 | WHERE 不走索引,全表加行锁 |

| 死锁了业务怎么办 | 捕获异常重试 |

常见误区澄清

| 误区 | 正解 |

|---|---|

| 加了行锁就不会死锁 | 行锁之间也会循环等待 |

| MVCC 完全不用锁 | 写操作还是要加 X 锁 |

| 间隙锁没用 | 防幻读,RR 必须有 |

| RC 没有锁 | RC 有记录锁,只是没间隙锁 |

| 死锁是 bug | 死锁是并发的正常现象 |

| 锁越多越安全 | 锁多了并发低,要平衡 |

| SELECT 就不加锁 | SELECT FOR UPDATE 加 X 锁 |

| 事务越长越安全 | 长事务持锁久,阻塞多 |

| 间隙锁只锁索引 | 间隙锁在索引记录之间 |

| 死锁一定要避免 | 死锁是正常现象,重试就行 |

| MVCC 解决一切 | MVCC 只管读,写还是要锁 |

隔离级别与锁对比

不同隔离级别加锁行为不同,这也是面试常考。

| 隔离级别 | 快照读 | 当前读 | 间隙锁 |

|---|---|---|---|

| READ UNCOMMITTED | 读最新,不加锁 | 加锁 | 有 |

| READ COMMITTED | 每次 SELECT 新 ReadView | 加记录锁,无 gap | 无 |

| REPEATABLE READ | 第一次 SELECT 生成 ReadView | next-key lock | 有 |

| SERIALIZABLE | 所有读都升级为共享锁 | 加锁 | 有 |

| READ UNCOMMITTED | 读最新,不加锁 | 加锁 | 有 |

关键区别:RC 没有间隙锁,所以并发更高,但会幻读。RR 有间隙锁,防幻读,但并发稍低。

生产选型建议:互联网业务(订单、用户、商品)一般用 RC,并发高够用;金融、账务类用 RR,强一致。

加锁规则速记

MySQL 8.0 InnoDB 加锁规则(RR 级别):

加锁规则是面试难点,记住这几条原则就能推导出一条 SQL 会加什么锁。

| 规则 | 说明 |

|---|---|

| 原则 1 | 加锁的基本单位是 next-key lock(左开右闭) |

| 原则 2 | 查找过程中访问到的对象才会加锁 |

| 优化 1 | 索引上的等值查询,唯一索引命中,退化为记录锁 |

| 优化 2 | 索引上的等值查询,向右遍历最后一个不满足,next-key 退化为 gap |

| 等值查询未命中 | next-key 退化为 gap lock |

| 范围查询 | 访问到不满足条件的第一个值为止 |

| 唯一索引等值未命中 | next-key 退化为 gap lock |

| 非唯一索引等值 | next-key lock,不退化为记录锁 |

| 多列联合索引 | 按索引顺序逐列加锁 |

| 唯一索引范围 | 退化为记录锁 |

| 非唯一索引范围 | next-key lock |

| 唯一索引等值未命中 | 退化为 gap lock |

-- 加锁示例(本示例基于 MySQL 8.0,索引 id: 5, 10, 15)
-- SELECT * FROM t WHERE id = 10 FOR UPDATE
--   → 唯一索引等值命中 → Record Lock 只锁 id=10
-- SELECT * FROM t WHERE id > 8 AND id < 12 FOR UPDATE
--   → Next-Key Lock 锁 (5, 10] 和 (10, 15)
--   → 其他事务无法在这个范围 INSERT 新行

常见锁问题场景

线上锁问题常见场景速查,遇到对照着查。

| 场景 | 现象 | 原因 | 解决 |

|---|---|---|---|

| 慢查询锁等待 | 业务卡住 | 慢 SQL 不走索引锁全表 | 加索引、优化 SQL |

| 批量更新死锁 | 偶发 deadlock | 加锁顺序不一致 | 统一排序后更新 |

| 间隙锁锁范围 | INSERT 被阻塞 | RR 范围查询加间隙锁 | 改 RC、缩小范围 |

| 大事务持锁 | 锁等待超时 | 事务太长 | 拆分事务 |

| DDL 卡住 | ALTER 等锁 | 元数据锁等待 | 低峰期、pt-osc |

| 备份锁 | mysqldump 卡住 | FTWRL 全局锁 | --single-transaction |

| 间隙锁阻塞 | INSERT 报锁等待 | RR 间隙锁 | 改 RC、缩小范围 |

| 大事务阻塞复制 | 从库回放慢 | 大事务 | 拆分事务 |

| 长事务占 undo | undo purge 不掉 | 事务太长 | 缩短事务 |

| 备份锁阻塞 | 备份时业务慢 | FTWRL 全局锁 | --single-transaction |

| 间隙锁阻塞插入 | INSERT 报锁等待 | RR 间隙锁 | 改 RC、缩小范围 |

死锁案例速记

死锁最经典的场景就是两个事务交叉更新,画一遍就懂。

-- 死锁典型场景(本示例基于 MySQL 8.0)
-- 事务 A:
BEGIN;
UPDATE account SET balance=balance-100 WHERE id=1;  -- 锁 id=1
UPDATE account SET balance=balance+100 WHERE id=2;  -- 等 id=2 锁

-- 事务 B:
BEGIN;
UPDATE account SET balance=balance-100 WHERE id=2;  -- 锁 id=2
UPDATE account SET balance=balance+100 WHERE id=1;  -- 等 id=1 锁
-- 死锁!InnoDB 检测到后回滚其中一个事务

避免方法:两个事务都按 id 升序更新,就不会循环等待。

其他避免死锁的方法:

1. 统一加锁顺序(最有效)

2. 大事务拆小,缩短持锁时间

3. 保证 UPDATE/DELETE 走索引,减少锁范围

4. 降低隔离级别(RC 无间隙锁)

业务代码捕获死锁异常后要重试,重试几次成功就过了。

自测:锁与MVCC速查

下面 10 道题覆盖锁与 MVCC 核心考点,先自己想答案,再展开参考答案。

题 1:InnoDB 行锁加在什么上?

<details>

<summary>参考答案</summary>

加在索引记录上。如果 WHERE 条件不走索引,会全表扫描加锁,效果等于表锁。所以慢 SQL 不仅慢,还会锁全表。

</details>

题 2:next-key lock 是什么?

<details>

<summary>参考答案</summary>

记录锁 + 间隙锁,锁定一个左开右闭区间。RR 级别默认用 next-key lock 防止幻读。比如索引有 1,5,10,查 id>3 会锁 (3,5] 和 (5,10) 的间隙。

</details>

题 3:快照读和当前读的区别?

<details>

<summary>参考答案</summary>

快照读是普通 SELECT,走 MVCC 读历史版本,不加锁;当前读是 SELECT FOR UPDATE、UPDATE、DELETE,读最新版本,加 X 锁。

</details>

题 4:死锁的四个必要条件?

<details>

<summary>参考答案</summary>

互斥、持有并等待、不可剥夺、循环等待。破坏任意一个就能避免死锁。实际中最常用的是统一加锁顺序,破坏循环等待。

</details>

题 5:RC 级别为什么不会幻读?

<details>

<summary>参考答案</summary>

RC 其实会幻读。RC 没有间隙锁,范围查询时其他事务可以插入新行,下次查就能看到。InnoDB 在 RR 级别才通过 next-key lock 解决幻读。

</details>

题 6:什么是意向锁?

<details>

<summary>参考答案</summary>

意向锁是表级锁,加行锁前先在表上加意向锁(IS/IX)。作用是:其他事务想加表锁时,快速判断表里有没有行锁,不用逐行检查。意向锁之间不互斥。

</details>

题 7:为什么慢 SQL 会锁全表?

<details>

<summary>参考答案</summary>

InnoDB 行锁加在索引上。如果 UPDATE/DELETE 的 WHERE 不走索引,会全表扫描,每一行都加 X 锁,效果等于表锁。其他事务想写这张表就全堵了。

</details>

题 8:间隙锁什么时候会阻塞 INSERT?

<details>

<summary>参考答案</summary>

RR 级别范围查询加 next-key lock(含间隙)。如果两个事务的间隙锁范围重叠,第二个事务 INSERT 会被阻塞。RC 级别没有间隙锁,不会阻塞。

</details>

题 9:怎么排查锁等待?

<details>

<summary>参考答案</summary>

查 information_schema.innodb_lock_waits 看谁等谁,查 innodb_trx 看事务详情,找到阻塞者的 thread id,KILL 掉。先看是不是有大事务没提交。

</details>

题 10:死锁检测开还是关?

<details>

<summary>参考答案</summary>

默认开。并发高时死锁检测本身耗 CPU(要遍历等待图)。并发极高时可以关,靠锁等待超时兜底。一般业务开着就行。

</details>

题 11:为什么 RC 级别并发更高?

<details>

<summary>参考答案</summary>

RC 没有间隙锁,范围查询不会锁间隙,其他事务可以随便插入。而且 RC 每次 SELECT 生成新 ReadView,锁的粒度更小。代价是会幻读。

</details>

题 12:什么是元数据锁(MDL)?

<details>

<summary>参考答案</summary>

DDL 操作时加的表级锁,保护表结构。如果有长事务在跑,DDL 会等 MDL 锁,后面的 DML 也会被堵住。所以 ALTER 表要低峰期做。

</details>

题 13:什么是插入意向锁?

<details>

<summary>参考答案</summary>

INSERT 语句在插入前,如果插入位置有间隙锁,要等待。插入意向锁是一种特殊的间隙锁,多个事务插入不同位置不互斥。

</details>

题 14:为什么事务里不要做 RPC?

<details>

<summary>参考答案</summary>

RPC 很慢,事务里做 RPC 会拉长事务持锁时间,导致其他事务等锁。应该把 RPC 移到事务外面,事务里只做数据库操作。

</details>

题 15:什么是全局读锁?

<details>

<summary>参考答案</summary>

FTWRL(FLUSH TABLES WITH READ LOCK),整库只读,所有写操作阻塞。逻辑备份时用。推荐用 --single-transaction 代替,不阻塞写。

</details>

自测评分:14 道题答对 12 道以上,锁这块面试基本稳了;答对 7~11 道,建议把 next-key lock 和 MVCC 可见性再画一遍;答对 7 道以下,先把加锁规则背熟。

使用建议:遇到锁问题,第一反应是查 innodb_trx 和 innodb_lock_waits,不要猜。

面试前重点过:next-key lock 三种算法、MVCC 可见性判断、死锁四个必要条件。这三块是必考题。

线上遇到锁问题,按这个顺序查:1)innodb_trx 看长事务;2)innodb_lock_waits 看谁等谁;3)KILL 阻塞者;4)复盘根因。

(本章完)

—— END ——

下一篇见。

祝你面试顺利,线上无锁等待。

(完)

—— END ——

下一篇见。

下一篇预告:第 79 章 速查卡:主从与高可用

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 78 章 速查卡:锁与MVCC》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。