MySQL 锁机制 —— 从入门到入土
本文涉及 InnoDB 底层实现。建议收藏后反复食用。
适用版本:MySQL 5.7 / 8.0 / 8.4,以 InnoDB 引擎为主。
目录
一、为什么需要锁?—— 不加锁会怎样?
1.1 经典问题:丢失更新
想象一下:你和室友同时往同一个支付宝余额里存钱。你存 100,他存 200,如果系统读到余额是 500,那最终结果可能是 600 而不是 700。这就是经典的 丢失更新(Lost Update) 问题。
时间线: T1: 事务A 读取 balance = 500 T2: 事务B 读取 balance = 500 T3: 事务A 更新 balance = 500 + 100 = 600 T4: 事务B 更新 balance = 500 + 200 = 700 ← 事务A的100被覆盖了!
锁的本质就是:在你动数据的时候,告诉别人"别碰,我在用"。
1.2 数据库并发的三大异常
| 异常类型 | 描述 | 例子 |
|---|---|---|
| 脏读(Dirty Read) | 读到别人还没提交的数据 | 事务A改了 balance=1000(未提交),事务B读到1000,事务A回滚了 → B读到的是"脏"数据 |
| 不可重复读(Non-repeatable Read) | 同一事务内,两次读同一行数据结果不一样 | 事务A第一次读 balance=500,事务B把它改成600并提交了,事务A再读变成600 |
| 幻读(Phantom Read) | 同一事务内,两次范围查询结果行数不一样 | 事务A查 age>20 有5条,事务B插入了一条 age=25 的记录并提交,事务A再查变成6条 |
1.3 四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
|---|---|---|---|---|
| READ UNCOMMITTED(读未提交) | ✅ 会 | ✅ 会 | ✅ 会 | 😎 最快 |
| READ COMMITTED(RC,读提交) | ❌ 不会 | ✅ 会 | ✅ 会 | 🙂 快 |
| REPEATABLE READ(RR,可重复读) ⭐ | ❌ 不会 | ❌ 不会 | ❌ 基本不会* | 😐 中等 |
| SERIALIZABLE(串行化) | ❌ 不会 | ❌ 不会 | ❌ 不会 | 💀 最慢 |
⭐ InnoDB 默认隔离级别是 RR(可重复读)。
*注:InnoDB 的 RR 级别通过 MVCC + Next-Key Lock 在很大程度上解决了幻读问题,但并非 100% 消除(当前读场景下仍可能出现)。
-- 查看当前隔离级别 SELECT @@transaction_isolation; -- MySQL 8.0+ SELECT @@tx_isolation; -- MySQL 5.7 -- 设置隔离级别 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
1.4 当前读 vs 快照读
这是理解 InnoDB 锁机制的关键前提:
| 类型 | 描述 | 是否加锁 | 典型 SQL |
|---|---|---|---|
| 快照读(Snapshot Read) | 读取的是 MVCC 版本链中的历史版本 | ❌ 不加锁 | 普通 SELECT(不带 FOR UPDATE / LOCK IN SHARE MODE) |
| 当前读(Current Read) | 读取的是数据的最新版本 | ✅ 加锁 | SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT、UPDATE、DELETE |
核心认知:只有"当前读"才会加行锁!普通 SELECT 走 MVCC 快照读,根本不加锁,这就是为什么 InnoDB 的读写并发性能这么好。
二、锁的分类全景图
MySQL InnoDB 的锁体系是一个多层嵌套的结构,从粗到细,从表到行,一图胜千言:
2.1 按粒度分:全局锁 → 表锁 → 行锁
| 粒度 | 锁类型 | 影响范围 | 并发性能 | 典型场景 |
|---|---|---|---|---|
| 全局锁 | FTWRL(Flush Tables With Read Lock) |
整个数据库实例 | 💀 灾难级 | 全库逻辑备份 |
| 表级锁 | 表锁、MDL 锁、意向锁、AUTO_INC 锁 | 整张表 | 😐 一般 | DDL 操作 |
| 行级锁 | Record Lock、Gap Lock、Next-Key Lock | 单行或范围 | 😎 优秀 | OLTP 事务 |
2.2 全局锁(Global Lock)
-- 加全局读锁(整个数据库变只读) FLUSH TABLES WITH READ LOCK; -- 释放 UNLOCK TABLES;
用途:做全库逻辑备份。
两种备份方式对比:
| 方式 | 原理 | 影响 |
|---|---|---|
FLUSH TABLES WITH READ LOCK |
全库只读 | 💀 所有写操作阻塞,线上慎用 |
mysqldump --single-transaction |
利用 MVCC 创建一致性快照 | 😎 不阻塞读写,推荐 |
灵魂吐槽:全局锁就像把整个图书馆锁了就为了抄一本书。生产环境请用 --single-transaction!
2.3 表级锁(Table Lock)
2.3.1 表锁(Table Lock)
-- 加读锁(共享锁):当前会话只能读不能写,其他会话可以读 LOCK TABLES t READ; -- 加写锁(排他锁):当前会话可以读写,其他会话什么都不能做 LOCK TABLES t WRITE; -- 释放 UNLOCK TABLES;
面试题:
LOCK TABLES t READ后,当前会话能写吗? 答:不能。加了 READ 锁后,当前会话也只能读,不能写。会报Table 't' was locked with a READ lock and can't be updated。
2.3.2 元数据锁(MDL - Metadata Lock)
这个锁你可能没听过,但它可能正在阻塞你的 DDL!
MDL 是 MySQL 5.5 引入的,自动加、自动释放,不需要你手动操作:
-
DML(增删改查) → 自动加
SHARED_READ或SHARED_WRITE -
DDL(ALTER TABLE 等) → 自动加
EXCLUSIVE
MDL 锁类型与兼容性:
| MDL 锁类型 | 加锁场景 | 兼容关系 |
|---|---|---|
SHARED_READ |
SELECT | 与 SHARED_READ、SHARED_WRITE 兼容 |
SHARED_WRITE |
INSERT/UPDATE/DELETE | 与 SHARED_READ、SHARED_WRITE 兼容 |
SHARED_UPGRADABLE |
ALTER TABLE(准备阶段) | 可升级为 EXCLUSIVE |
EXCLUSIVE |
ALTER TABLE(执行阶段) | 与所有锁互斥 |
经典翻车现场(必背!):
时间线: T1: 事务A 开启,执行 SELECT * FROM t(持有 SHARED_READ) T2: DBA 执行 ALTER TABLE t ADD COLUMN ...(需要 EXCLUSIVE)→ 被 T1 阻塞,排队等待 T3: 事务B 执行 SELECT * FROM t(需要 SHARED_READ)→ 被 DDL 排队挡住 → 阻塞! T4: 事务C、D、E... 全部阻塞 → 表完全不可用 → 💀这就是为什么线上
ALTER TABLE能把整个表堵死的原因!解法:使用
pt-online-schema-change或 MySQL 8.0 的 Instant DDL。
2.3.3 意向锁(Intention Lock)
意向锁是表级锁,作用是:让表锁快速判断"这个表里有没有行锁",而不用逐行扫描。
-
IS 锁(Intention Shared):事务打算给某些行加 S 锁之前,先在表上加 IS
-
IX 锁(Intention Exclusive):事务打算给某些行加 X 锁之前,先在表上加 IX
为什么需要意向锁?
假设没有意向锁,当有人想给整张表加 X 锁时,需要逐行检查有没有行锁。如果有 100 万行,就要检查 100 万次——这显然不现实。
有了意向锁后:
-
事务要加行锁前,先在表上加意向锁(IS 或 IX)
-
表锁只需要检查"有没有意向锁"就能判断是否冲突
-
O(1) 复杂度代替 O(n) 复杂度
生活比喻:你去图书馆借书(行锁),管理员不需要检查每本书是否被借出,只需要看门口的指示灯(意向锁)就知道有没有人在用。
2.3.4 AUTO_INCREMENT 锁
自增列插入时需要保证 ID 唯一,InnoDB 提供了三种自增锁模式:
| 模式 | 行为 | 性能 | 安全性 |
|---|---|---|---|
innodb_autoinc_lock_mode=0 |
语句级别锁(整个 INSERT 期间持有表锁) | 😐 | 最安全(语句级复制安全) |
innodb_autoinc_lock_mode=1(默认) |
批量插入(如 INSERT ... SELECT)用表锁,简单插入用轻量互斥锁 | 🙂 | 兼顾性能与安全 |
innodb_autoinc_lock_mode=2 |
全部用轻量锁(自增值可能不连续) | 😎 | 主从复制可能不安全(statement-based) |
面试题:为什么
innodb_autoinc_lock_mode=2时自增值可能不连续? 答:因为多个事务并发插入时,自增锁提前释放,自增值是交错分配的。如果某个事务回滚,已分配的自增值不会回收,就会出现"空洞"。
三、锁兼容性矩阵 —— 谁跟谁是死对头?

背下来(面试高频):
| 持有 \ 请求 | IS | IX | S | X | AI |
|---|---|---|---|---|---|
| IS | ✅ | ✅ | ✅ | ❌ | ✅ |
| IX | ✅ | ✅ | ❌ | ❌ | ✅ |
| S | ✅ | ❌ | ✅ | ❌ | ❌ |
| X | ❌ | ❌ | ❌ | ❌ | ❌ |
| AI | ✅ | ✅ | ❌ | ❌ | ❌ |
规律总结:
-
X 锁是孤独王者:跟谁都不兼容(除了自己)
-
S 锁和 IX 锁互斥:你要读(S)和你要写(IX)不能同时进行
-
IS/IX 之间永远兼容:意向锁之间从不冲突(它们只是"声明意图")
-
AI 锁只跟 IS/IX 兼容:自增锁比较专一
记忆技巧:X 锁是"霸道总裁",谁来都拒绝;S 锁是"温柔读者",跟其他读者友好,但跟写者互斥。
四、行级锁 —— 真正决定并发性能的核心
4.1 三大行锁:Record Lock / Gap Lock / Next-Key Lock
这是 InnoDB 行锁的三驾马车,面试必考,必须吃透:

Record Lock(记录锁)
-- 锁住 id=5 这一行的索引记录 SELECT * FROM t WHERE id = 5 FOR UPDATE;
-
只锁住索引记录本身
-
如果
id是主键,锁主键索引(聚簇索引) -
如果
id是普通索引,锁普通索引(二级索引)+ 回表时锁主键索引 -
在 RC 和 RR 隔离级别下都存在
底层细节:Record Lock 锁的是 B+Tree 中的索引记录,通过 (space_id, page_no, heap_no) 三元组定位。即使表没有主键,InnoDB 也会生成一个隐藏的 rowid 作为聚簇索引。
Gap Lock(间隙锁)
-- 假设表中 id 值为:1, 5, 10, 15, 20 -- id=7 不存在,锁住 (5, 10) 这个间隙 SELECT * FROM t WHERE id = 7 FOR UPDATE;
-
锁住索引记录之间的"空隙"
-
只在 RR(可重复读)隔离级别下存在,RC 下没有 Gap Lock
-
目的:防止幻读(阻止其他事务在这个间隙里插入新记录)
-
Gap Lock 之间互不冲突(多个事务可以同时锁同一个间隙)
-
Gap Lock 只锁"间隙",不锁已存在的记录
灵魂比喻:Gap Lock 就像在电影院座位之间的过道上放了个"禁止加座"的牌子。你不能阻止别人坐已有的座位(Record Lock 管这个),但能阻止别人在过道里加椅子。
Next-Key Lock(临键锁)
-- 假设表中 id 值为:1, 5, 10, 15, 20 -- 范围查询,锁住 (5, 10] 区间 SELECT * FROM t WHERE id >= 5 AND id < 10 FOR UPDATE;
-
Next-Key Lock = Record Lock + Gap Lock
-
是 InnoDB 在 RR 级别下的默认行锁算法
-
左开右闭区间:
(a, b] -
当查询条件命中索引时,InnoDB 会对扫描到的每一条索引记录加 Next-Key Lock
Next-Key Lock 加锁过程详解:
假设索引值为 [1, 5, 10, 15, 20],执行 SELECT * FROM t WHERE id >= 5 AND id < 15 FOR UPDATE;:
扫描到 id=5: → Next-Key Lock on (1, 5] → 即 Gap(1,5) + Record(5) 扫描到 id=10: → Next-Key Lock on (5, 10] → 即 Gap(5,10) + Record(10) 扫描到 id=15: → Next-Key Lock on (10, 15] → 即 Gap(10,15) + Record(15) → 但 id=15 不满足 id < 15,退化为 Gap Lock on (10, 15) 最终锁住的范围:(1, 15) 其中 Record Lock 在:5, 10 其中 Gap Lock 在:(1,5), (5,10), (10,15)
Insert Intention Lock(插入意向锁)
-- 事务 A INSERT INTO t VALUES (8); -- 需要插入到 (5, 10) 间隙 -- 事务 B(同时) INSERT INTO t VALUES (9); -- 也需要插入到 (5, 10) 间隙
-
插入意向锁是一种特殊的 Gap Lock
-
多个事务插入同一个间隙的不同位置时,不会互相阻塞
-
这是 InnoDB 对并发插入的优化
-
只有当插入位置已被其他事务的 Gap Lock 锁住时,才会阻塞
面试题:Insert Intention Lock 和 Gap Lock 的关系? 答:Insert Intention Lock 是 Gap Lock 的一种特殊形式。它表示"我打算在这个间隙的某个位置插入一条记录"。多个事务可以同时持有同一个间隙的 Insert Intention Lock(只要插入位置不同),但它们与普通的 Gap Lock 互斥。
4.2 底层加锁规则(面试终极背诵版)
| 场景 | 索引类型 | 隔离级别 | 加锁结果 | 说明 |
|---|---|---|---|---|
| 等值查询,记录存在 | 唯一索引 | RR/RC | Record Lock | 精准命中,不需要锁间隙 |
| 等值查询,记录不存在 | 唯一索引 | RR | Gap Lock | 锁住"你要插入的位置" |
| 等值查询,记录存在 | 非唯一索引 | RR | Next-Key Lock + 额外 Gap Lock | 锁住记录 + 后面的间隙(防止幻读) |
| 等值查询,记录不存在 | 非唯一索引 | RR | Gap Lock | 锁住目标间隙 |
| 范围查询 | 唯一索引 | RR | Next-Key Lock(向后扫描直到不满足) | 范围内所有记录+间隙都锁住 |
| 范围查询 | 非唯一索引 | RR | Next-Key Lock + 额外 Gap Lock | 同上,但更保守 |
| 无索引 | - | RR/RC | 全表扫描,所有行加锁 | 退化为表锁! |
| ORDER BY + LIMIT | 唯一索引 | RR | Next-Key Lock(扫描到满足条件为止) | 比全范围查询锁更少 |
⚡ 核心认知:锁是加在索引上的,不是加在数据行上的!
如果你的
WHERE条件没有命中索引,InnoDB 会扫描全表,给每一行都加上 Next-Key Lock。这就是所谓的"行锁升级为表锁"——不是真的升级,而是你没有索引可用。
非唯一索引加锁的额外 Gap Lock:
当使用非唯一索引进行等值查询且记录存在时,InnoDB 会在匹配记录的下一个间隙加 Gap Lock。这是为了防止其他事务在该记录后面插入一条"相同索引值"的记录(幻读)。
-- 假设 age 是非唯一索引,表中 age 值为:10, 20, 20, 30 SELECT * FROM t WHERE age = 20 FOR UPDATE; -- 加锁情况: -- Record Lock on 第一个 age=20 的记录 -- Record Lock on 第二个 age=20 的记录 -- Gap Lock on (20, 30) ← 额外的!防止插入 age=20 的新记录
4.3 锁的升级与降级
InnoDB 支持锁的升级(Escalation) 和降级(De-escalation):
-
升级:从较弱的锁升级为较强的锁(如 S → X)
-
降级:从较强的锁降级为较弱的锁(如 X → S)
注意:InnoDB 不会像 SQL Server 那样自动进行行锁→表锁的升级。InnoDB 的锁管理是基于每行的,通过哈希表高效管理,即使锁住 100 万行也不会升级为表锁。
五、死锁 —— 锁的终极 BOSS
5.1 什么是死锁?

经典场景:
-- 事务 A BEGIN; UPDATE account SET balance=100 WHERE user_id=1; -- 拿到 id=1 的 X 锁 UPDATE account SET balance=200 WHERE user_id=2; -- 等待 id=2 的 X 锁... 💀 -- 事务 B BEGIN; UPDATE account SET balance=300 WHERE user_id=2; -- 拿到 id=2 的 X 锁 UPDATE account SET balance=400 WHERE user_id=1; -- 等待 id=1 的 X 锁... 💀
A 等 B 释放锁,B 等 A 释放锁 → 环形等待 = 死锁。
5.2 常见死锁场景
场景 1:交叉加锁(最经典)
-- 如上所述,两个事务交叉访问相同资源 -- 解法:固定加锁顺序,如 always 按 id 升序访问
场景 2:Gap Lock 导致的死锁
-- 假设表中 id 值为:1, 5, 10, 15 -- 事务 A BEGIN; SELECT * FROM t WHERE id = 7 FOR UPDATE; -- Gap Lock (5, 10) -- 事务 B BEGIN; SELECT * FROM t WHERE id = 8 FOR UPDATE; -- Gap Lock (5, 10),不冲突 -- 事务 A INSERT INTO t VALUES (7); -- 需要 Insert Intention Lock,被 B 的 Gap Lock 阻塞 -- 事务 B INSERT INTO t VALUES (8); -- 需要 Insert Intention Lock,被 A 的 Gap Lock 阻塞 -- 💀 死锁!A 等 B 释放 Gap Lock,B 等 A 释放 Gap Lock
场景 3:唯一索引冲突导致的死锁
-- 事务 A BEGIN; INSERT INTO t VALUES (5); -- 检查唯一性,加 S 锁等待... -- 事务 B BEGIN; INSERT INTO t VALUES (5); -- 也检查唯一性,也加 S 锁等待... -- 两个事务都想插入 id=5,都拿到了 S 锁 -- 然后都想升级为 X 锁(插入需要 X 锁)→ 死锁!
场景 4:大事务 + 热点行
-- 多个事务同时更新同一行(如库存扣减) -- 事务 A: UPDATE inventory SET stock = stock - 1 WHERE product_id = 100; -- 事务 B: UPDATE inventory SET stock = stock - 1 WHERE product_id = 100; -- 如果还有其他资源竞争,容易形成死锁
5.3 InnoDB 的死锁检测机制
InnoDB 不是傻等,它有一套主动检测机制:
-
等待图(Wait-for Graph)算法:每 10ms 扫描一次锁依赖关系
-
如果检测到环形依赖,立即判定为死锁
-
选择回滚代价最小的事务(undo log 量最少的那个)
-
被回滚的事务收到错误:
ERROR 1213 (40001): Deadlock found when trying to get lock
等待图示例: 事务A --等待锁--> 事务B --等待锁--> 事务A ↑_________________________↓ 环形依赖 = 死锁!
死锁检测的代价:
在高并发系统中,死锁检测本身可能成为瓶颈。当大量线程等待同一把锁时,死锁检测的复杂度是 O(n²)(n 为等待线程数)。
-- 高并发下可以关闭死锁检测,依赖超时回滚 SET GLOBAL innodb_deadlock_detect = OFF; SET GLOBAL innodb_lock_wait_timeout = 10; -- 设置较短的超时时间
-- 查看最近一次死锁信息 SHOW ENGINE INNODB STATUS\G -- 开启死锁日志(记录到错误日志) SET GLOBAL innodb_print_all_deadlocks = 1;
5.4 破解死锁的六大招式
| 招式 | 说明 | 效果 |
|---|---|---|
| ① 固定加锁顺序 | 所有事务按相同顺序访问资源(如 always 按 id 升序) | 从根本上消除死锁 |
| ② 减小事务粒度 | 事务越短,持锁时间越短;把非 DB 操作移到事务外 | 降低死锁概率 |
| ③ 使用低隔离级别 | RC 替代 RR,减少 Gap Lock | 减少锁冲突 |
| ④ 为 WHERE 加索引 | 避免全表扫描导致的锁升级 | 减少锁范围 |
| ⑤ 使用 NOWAIT/SKIPPED LOCK | SELECT ... FOR UPDATE NOWAIT / SKIP LOCKED |
锁冲突时快速失败 |
| ⑥ 监控死锁日志 | innodb_print_all_deadlocks=1,配合 pt-deadlock-logger |
及时发现并分析 |
六、MVCC —— 读不加锁的秘密武器
6.1 MVCC 是什么?
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现读写不阻塞的核心机制。
没有 MVCC:读要加 S 锁,写要加 X 锁 → 读写互斥 → 并发性能暴跌
有了 MVCC:读操作读的是"历史快照",不需要加锁 → 读写互不阻塞 → 并发起飞 🚀

6.2 底层实现三件套
① Undo Log(版本链)
每次修改数据时,InnoDB 不会直接覆盖旧值,而是:
-
把旧版本写入 Undo Log
-
新版本的
roll_pointer指向这条 Undo Log -
多次修改后,通过
roll_pointer串联成版本链
每行数据隐藏的三个字段:
| 隐藏字段 | 含义 |
|---|---|
DB_TRX_ID |
最近修改(插入/更新/删除)该行的事务 ID |
DB_ROLL_PTR |
回滚指针,指向这条记录的上一个版本(Undo Log) |
DB_ROW_ID |
隐藏主键(如果表没有显式主键,InnoDB 自动生成) |
版本链结构: 当前行 (trx_id=300, amount=800) │ roll_pointer ↓ Undo Log v1 (trx_id=250, amount=600) │ roll_pointer ↓ Undo Log v2 (trx_id=200, amount=500) │ roll_pointer ↓ Undo Log v3 (trx_id=100, amount=300) ↓ NULL (链尾)
Undo Log 的两种类型:
| 类型 | 用途 | 何时清理 |
|---|---|---|
| Insert Undo Log | INSERT 操作产生,仅用于事务回滚 | 事务提交后立即清理 |
| Update Undo Log | UPDATE/DELETE 操作产生,用于回滚和 MVCC | 由 purge 线程在所有事务都不再需要时清理 |
② Read View(读视图)
Read View 是事务在某个时刻创建的"快照",包含四个关键字段:
| 字段 | 含义 |
|---|---|
m_ids |
创建 Read View 时,系统中所有活跃(未提交)事务的 ID 列表 |
min_trx_id |
m_ids 中最小的事务 ID |
max_trx_id |
系统应该分配给下一个新事务的 ID(当前最大 ID + 1) |
creator_trx_id |
创建这个 Read View 的事务自身的 ID |
③ 可见性判断规则
读到某行数据时,取其 DB_TRX_ID,按以下规则判断:
Step 1: if (trx_id == creator_trx_id) → ✅ 可见(自己修改的,当然看得到) Step 2: if (trx_id < min_trx_id) → ✅ 可见(事务在 Read View 创建前就已提交) Step 3: if (trx_id >= max_trx_id) → ❌ 不可见(事务在 Read View 创建后才启动) Step 4: if (trx_id in m_ids) → ❌ 不可见(事务还活跃,未提交) Step 5: if (trx_id not in m_ids) → ✅ 可见(事务已提交)
完整可见性判断流程图:
读取一行数据,获取 DB_TRX_ID │ ▼ trx_id == creator_trx_id? ──是──→ ✅ 可见(自己改的) │否 ▼ trx_id < min_trx_id? ──是──→ ✅ 可见(快照前已提交) │否 ▼ trx_id >= max_trx_id? ──是──→ ❌ 不可见(快照后才启动) │否 ▼ trx_id 在 m_ids 中? ──是──→ ❌ 不可见(还在活跃) │否 ▼ ✅ 可见(已提交,但不在活跃列表中)
如果当前版本不可见怎么办?
沿着版本链(roll_pointer)向前找,直到找到一个可见的版本。如果遍历完所有版本都不可见,说明该行对当前事务不存在(被删除了或从未插入)。
6.3 RC vs RR 的本质区别
| 隔离级别 | Read View 创建时机 | 效果 |
|---|---|---|
| RC(读提交) | 每次 SELECT 都创建新的 Read View | 能读到其他事务已提交的最新数据 |
| RR(可重复读) | 事务中第一次 SELECT 创建 Read View,整个事务复用 | 保证同一事务内多次读结果一致 |
一句话总结:RC 是"每次拍照都看最新画面",RR 是"用第一次拍的照片看到事务结束"。
RC 下的不可重复读示例:
-- 事务 A(RC 级别) BEGIN; SELECT balance FROM account WHERE id = 1; -- 读到 500,创建 Read View 1 -- 事务 B BEGIN; UPDATE account SET balance = 600 WHERE id = 1; COMMIT; -- 事务 A SELECT balance FROM account WHERE id = 1; -- 读到 600!(新建 Read View 2,能看到 B 的提交) -- 这就是"不可重复读" COMMIT;
RR 下的可重复读示例:
-- 事务 A(RR 级别) BEGIN; SELECT balance FROM account WHERE id = 1; -- 读到 500,创建 Read View(复用到事务结束) -- 事务 B BEGIN; UPDATE account SET balance = 600 WHERE id = 1; COMMIT; -- 事务 A SELECT balance FROM account WHERE id = 1; -- 还是 500!(复用同一个 Read View,看不到 B 的提交) COMMIT;
6.4 MVCC 与锁的关系
| 操作 | 是否走 MVCC | 是否加锁 |
|---|---|---|
| 普通 SELECT(快照读) | ✅ 是 | ❌ 不加锁 |
| SELECT ... FOR UPDATE | ❌ 否 | ✅ 加 X 锁 |
| SELECT ... LOCK IN SHARE MODE | ❌ 否 | ✅ 加 S 锁 |
| INSERT | ❌ 否 | ✅ 加 Insert Intention Lock |
| UPDATE | ❌ 否 | ✅ 加 X 锁(先读后写) |
| DELETE | ❌ 否 | ✅ 加 X 锁 |
七、一条 SQL 语句的锁之旅

7.1 UPDATE 语句的完整流程
UPDATE t SET name='X' WHERE id=5;
① SQL 到达 InnoDB 存储引擎 ↓ ② 判断隔离级别(决定是否需要 Gap Lock) ├─ RC → 只加 Record Lock └─ RR → 可能加 Next-Key Lock ↓ ③ B+Tree 查找,定位 id=5 的索引记录 ├─ 走主键索引 → 直接定位 └─ 走二级索引 → 先锁二级索引,再回表锁主键索引 ↓ ④ Lock Manager 检查: ├─ 无冲突 → 加 X 锁成功 ├─ 有冲突 → 进入锁等待队列,阻塞当前事务 └─ 检测到死锁 → 回滚代价小的事务 ↓ ⑤ 执行修改: ├─ 写 Undo Log(保存旧版本,用于 MVCC 和回滚) ├─ 更新 Buffer Pool 中的数据页(内存中修改,标记为脏页) └─ 写 Redo Log(WAL,保证持久性) ↓ ⑥ 事务 COMMIT: ├─ 释放当前事务持有的所有锁 ├─ Redo Log 刷盘(prepare → commit 两阶段) ├─ 写 Binlog └─ 唤醒锁等待队列中的事务
7.2 DELETE 语句的加锁过程
DELETE 的加锁过程与 UPDATE 类似,但有一个关键区别:
DELETE FROM t WHERE id = 5;
-
先对 id=5 的记录加 X 锁(Record Lock 或 Next-Key Lock)
-
标记记录为 delete-marked(软删除,不是立即物理删除)
-
事务提交后,由 purge 线程异步物理删除
7.3 INSERT 语句的加锁过程
INSERT INTO t VALUES (5, 'test');
-
检查唯一性约束(如果是唯一索引,加 S 锁检查)
-
在目标间隙加 Insert Intention Lock
-
插入记录,加 X 锁
-
写 Undo Log(Insert Undo Log,仅用于回滚)
7.4 SELECT ... FOR UPDATE vs SELECT ... LOCK IN SHARE MODE
| 语法 | 加锁类型 | 其他事务能读? | 其他事务能写? | 典型场景 |
|---|---|---|---|---|
SELECT ... FOR UPDATE |
X 锁 | ✅ 能(快照读) | ❌ 不能 | 先查再改,防止并发修改 |
SELECT ... LOCK IN SHARE MODE |
S 锁 | ✅ 能(快照读) | ❌ 不能 | 只读但需要保证数据不被修改 |
| 普通 SELECT | 无锁 | ✅ 能 | ✅ 能 | MVCC 快照读 |
-- 典型用法:先查再改(防止并发修改导致丢失更新) BEGIN; SELECT balance FROM account WHERE id = 1 FOR UPDATE; -- 加 X 锁 -- 在应用层计算新余额 UPDATE account SET balance = 600 WHERE id = 1; COMMIT; -- MySQL 8.0.1+ 支持 NOWAIT 和 SKIP LOCKED SELECT * FROM t WHERE id = 5 FOR UPDATE NOWAIT; -- 锁冲突时立即报错 SELECT * FROM t WHERE id = 5 FOR UPDATE SKIP LOCKED; -- 跳过已锁的行
八、悲观锁与乐观锁
8.1 悲观锁(Pessimistic Lock)
思想:假设一定会冲突,先加锁再操作。
-- 使用数据库的行锁实现 BEGIN; SELECT * FROM inventory WHERE product_id = 100 FOR UPDATE; -- 加 X 锁 UPDATE inventory SET stock = stock - 1 WHERE product_id = 100; COMMIT;
优点:安全,不会出现并发冲突 缺点:并发性能差,锁等待多
8.2 乐观锁(Optimistic Lock)
乐观锁不是数据库层面的锁,而是应用层的并发控制策略:
-- 方式 1:版本号(推荐) -- 表结构:id, amount, version BEGIN; SELECT amount, version FROM t WHERE id = 1; -- 读到 amount=500, version=1 -- 应用层计算 UPDATE t SET amount=600, version=version+1 WHERE id=1 AND version=1; -- 如果 affected_rows = 0,说明被其他事务修改了,需要重试 COMMIT; -- 方式 2:时间戳 UPDATE t SET amount=600 WHERE id=1 AND update_time='2026-01-01 12:00:00'; -- 方式 3:CAS(Compare And Swap) UPDATE t SET amount=600 WHERE id=1 AND amount=500;
优点:不加锁,并发性能好 缺点:冲突多时重试开销大
8.3 悲观锁 vs 乐观锁选型
| 场景 | 推荐 | 原因 |
|---|---|---|
| 读多写少 | 乐观锁 | 冲突概率低,不加锁性能好 |
| 写多读少 | 悲观锁 | 冲突概率高,乐观锁重试成本高 |
| 库存扣减 | 悲观锁 | 不能超卖,必须保证一致性 |
| 用户信息更新 | 乐观锁 | 冲突概率低,用版本号即可 |
九、底层源码级认知(加分项)
9.1 锁在内存中的数据结构
// InnoDB 锁结构(简化版,源码位于 storage/innobase/include/lock0priv.h)
struct lock_t {
trx_t* trx; // 所属事务
lock_type_t type; // LOCK_TABLE 或 LOCK_REC
lock_mode_t mode; // IS/IX/S/X/AI
union {
lock_rec_t rec_lock; // 行锁信息
lock_table_t tab_lock; // 表锁信息
};
UT_LIST_NODE_T(lock_t) trx_locks; // 事务的锁链表(一个事务可以持有多把锁)
};
// 行锁定位信息
struct lock_rec_t {
ulint space_id; // 表空间 ID
ulint page_no; // 数据页号
ulint heap_no; // 记录在页中的堆号(物理位置)
};
// 锁模式枚举
enum lock_mode {
LOCK_IS = 0, // Intention Shared
LOCK_IX = 1, // Intention Exclusive
LOCK_S = 2, // Shared
LOCK_X = 3, // Exclusive
LOCK_AUTO_INC = 4, // Auto-Increment
LOCK_NONE = 5 // 无锁
};
9.2 锁兼容性矩阵(源码版)
// 源码中的兼容性矩阵定义(storage/innobase/lock/lock0lock.cc)
static const byte lock_compatibility_matrix[5][5] = {
/* IS IX S X AI */
/* IS */ { TRUE, TRUE, TRUE, FALSE, TRUE },
/* IX */ { TRUE, TRUE, FALSE,FALSE, TRUE },
/* S */ { TRUE, FALSE,TRUE, FALSE, FALSE},
/* X */ { FALSE,FALSE,FALSE,FALSE, FALSE},
/* AI */ { TRUE, TRUE, FALSE,FALSE, FALSE}
};
// 锁强度矩阵(用于判断哪种锁更强)
static const byte lock_strength_matrix[5][5] = {
/* IS IX S X AI */
/* IS */ { TRUE, FALSE,FALSE,FALSE, FALSE},
/* IX */ { TRUE, TRUE, FALSE,FALSE, FALSE},
/* S */ { TRUE, FALSE,TRUE, FALSE, FALSE},
/* X */ { TRUE, TRUE, TRUE, TRUE, FALSE},
/* AI */ { FALSE,FALSE,FALSE,FALSE, TRUE}
};
9.3 锁的存储方式
行锁并不是存储在每行数据中的,而是通过哈希表管理:
Lock Hash Table (lock_sys->rec_hash) ├── key = hash(space_id, page_no) │ ├── bucket[0] → [lock_t(page=3, heap_no=5), lock_t(page=3, heap_no=8)] → NULL ├── bucket[1] → NULL ├── bucket[2] → [lock_t(page=7, heap_no=2)] → NULL ├── ... └── bucket[n] → [lock_t(page=100, heap_no=1)] → NULL
关键设计:
-
一个数据页上的所有行锁共享同一个哈希桶
-
通过
(space_id, page_no, heap_no)精确定位到具体的行 -
这也是为什么行锁的开销与总行数无关,而是与"被锁住的行数"有关
9.4 Mini-Transaction 与锁
InnoDB 的底层操作通过 Mini-Transaction(mtr) 来保证页级操作的原子性:
-
mtr 是比事务更小的原子操作单位
-
一个事务包含多个 mtr
-
锁的获取和释放在 mtr 的边界上进行
-
mtr commit 时会释放页级的 latches(但不释放行锁)
9.5 Predicate Lock(谓词锁)— SPATIAL INDEX
对于空间索引(SPATIAL INDEX),InnoDB 使用 Predicate Lock 而不是 Gap Lock:
-
Predicate Lock 锁住的是一个空间范围,而不是具体的索引记录
-
用于防止空间索引的幻读
-
实际实现中,Predicate Lock 可能退化为页级锁(精度有限)
十、锁的监控与调优
10.1 查看当前锁等待
-- MySQL 8.0+(推荐) -- 查看当前所有锁 SELECT * FROM performance_schema.data_locks; -- 查看锁等待关系 SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query 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; -- MySQL 5.7 SELECT * FROM information_schema.INNODB_LOCK_WAITS; SELECT * FROM information_schema.INNODB_LOCKS; SELECT * FROM information_schema.INNODB_TRX;
10.2 查看 InnoDB 引擎状态
SHOW ENGINE INNODB STATUS\G
重点看这几个部分:
| 部分 | 内容 |
|---|---|
SEMAPHORES |
互斥锁/读写锁等待信息 |
TRANSACTIONS |
当前活跃事务和锁等待 |
LATEST DETECTED DEADLOCK |
最近一次死锁的详细信息 |
BUFFER POOL AND MEMORY |
Buffer Pool 使用情况 |
死锁日志解读示例:
*** (1) TRANSACTION: ← 事务 1 TRANSACTION 12345, ACTIVE 2 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) *** (1) WAITING FOR THIS LOCK TO BE GRANTED: ← 等待的锁 RECORD LOCKS space id 0 page no 12 n bits 72 index PRIMARY of table `test`.`t` ← 等待主键索引上的行锁 *** (2) TRANSACTION: ← 事务 2 TRANSACTION 12346, ACTIVE 1 sec starting index read 2 lock struct(s), heap size 1136, 1 row lock(s) *** (2) HOLDS THE LOCK(S): ← 持有的锁 RECORD LOCKS space id 0 page no 12 n bits 72 index PRIMARY of table `test`.`t` *** (2) WAITING FOR THIS LOCK TO BE GRANTED: ← 也在等待锁 RECORD LOCKS space id 0 page no 12 n bits 72 index PRIMARY of table `test`.`t` *** WE ROLL BACK TRANSACTION (1) ← 回滚了事务 1
10.3 关键参数调优
-- 锁等待超时(默认 50 秒,生产环境建议 5-10 秒) SET GLOBAL innodb_lock_wait_timeout = 10; -- 死锁检测开关(高并发下可以关闭,依赖超时回滚) SET GLOBAL innodb_deadlock_detect = ON; -- 记录所有死锁到错误日志(强烈建议开启) SET GLOBAL innodb_print_all_deadlocks = 1; -- 锁监控(MySQL 8.0+) UPDATE performance_schema.setup_instruments SET ENABLED = 'YES' WHERE NAME LIKE 'wait/lock/%';
10.4 使用 pt-deadlock-logger 持续监控
# Percona Toolkit 的死锁监控工具 pt-deadlock-logger \ --user=root --password=xxx \ --host=localhost \ --create-dest-table \ --dest D=monitor,t=deadlocks # 查看死锁历史 SELECT * FROM monitor.deadlocks ORDER BY ts DESC LIMIT 10;
十一、实战避坑指南
🕳️ 坑 1:没索引导致锁表
-- name 没有索引! UPDATE t SET status=1 WHERE name='zhangsan'; -- 结果:全表扫描,每一行都加 Next-Key Lock → 等于表锁
解法:给 WHERE 条件加索引。永远确保 UPDATE/DELETE 的 WHERE 条件命中索引!
🕳️ 坑 2:范围查询锁太多
-- 如果 id 从 1 到 1000000 都满足条件 UPDATE t SET status=1 WHERE id > 0; -- 结果:锁住整个表的所有行
解法:缩小查询范围,分批处理:
-- 分批更新,每批 1000 行 UPDATE t SET status=1 WHERE id > 0 AND id <= 1000; UPDATE t SET status=1 WHERE id > 1000 AND id <= 2000; -- ...
🕳️ 坑 3:RR 级别下的 Gap Lock 导致死锁
-- 事务 A INSERT INTO t VALUES (6); -- Gap Lock (5, 10) -- 事务 B INSERT INTO t VALUES (7); -- Gap Lock (5, 10) -- 两个事务都想在同一个间隙插入,但 Gap Lock 之间不冲突 -- 然后都想升级为 Insert Intention Lock → 死锁!
解法:使用 RC 隔离级别,或确保插入顺序一致(如使用自增主键)。
🕳️ 坑 4:大事务长时间持锁
BEGIN; UPDATE t SET status=1 WHERE id=1; -- ... 还有一堆业务逻辑要处理(可能几十秒)... -- ... 还要调外部 API ... COMMIT;
解法:把非数据库操作移到事务外面,缩短持锁时间:
-- 先调外部 API,拿到结果后再开事务 result = call_external_api(); BEGIN; UPDATE t SET status=1 WHERE id=1; INSERT INTO log VALUES (result); COMMIT;
🕳️ 坑 5:SELECT ... FOR UPDATE 锁太多行
-- 查询返回 10000 行,全部加 X 锁 SELECT * FROM t WHERE status=0 FOR UPDATE;
解法:如果只需要处理部分数据,加 LIMIT:
-- 只锁 100 行 SELECT * FROM t WHERE status=0 LIMIT 100 FOR UPDATE;
🕳️ 坑 6:二级索引 + 回表导致锁范围扩大
-- age 是普通索引 UPDATE t SET name='X' WHERE age=25; -- 实际加锁:二级索引 age=25 的记录 + 回表后主键索引的记录 -- 如果 age=25 有 1000 行,就锁 2000 个索引记录!
解法:尽量使用主键或唯一索引进行更新操作。
🕳️ 坑 7:隔离级别不一致导致的问题
-- 如果应用和数据库的隔离级别不一致 -- 应用以为是 RR,实际被改成了 RC -- → 出现不可重复读问题
解法:在连接初始化时显式设置隔离级别:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
十二、面试速查表
高频面试题
| 问题 | 一句话答案 |
|---|---|
| InnoDB 有哪些锁? | 全局锁、表锁(MDL/意向/AUTO_INC)、行锁(Record/Gap/Next-Key) |
| 什么是 Gap Lock? | 锁住索引记录之间的间隙,防止幻读,只在 RR 级别下存在 |
| Next-Key Lock 是什么? | Record Lock + Gap Lock,左开右闭区间,InnoDB 默认行锁算法 |
| 锁加在哪里? | 索引上!没索引 = 全表扫描 = 表锁 |
| 死锁怎么检测? | Wait-for Graph 算法,每 10ms 扫描,检测到环形依赖就回滚代价小的事务 |
| MVCC 怎么实现读不加锁? | Undo Log 版本链 + Read View 可见性判断 |
| RC 和 RR 的区别? | RC 每次 SELECT 新建 Read View,RR 事务首次 SELECT 后复用 |
| 怎么避免死锁? | 固定加锁顺序、减小事务粒度、加索引、用低隔离级别 |
| 意向锁的作用? | 快速判断表中是否有行锁,O(1) 代替 O(n) 检查 |
| 什么是当前读和快照读? | 当前读加锁读最新数据,快照读走 MVCC 不加锁 |
| FOR UPDATE 和 LOCK IN SHARE MODE 的区别? | 前者加 X 锁(排他),后者加 S 锁(共享) |
| 乐观锁和悲观锁怎么选? | 读多写少用乐观锁(版本号),写多冲突多用悲观锁(FOR UPDATE) |
一句话速记
锁的粒度:全局 > 表 > 行(越细并发越好) 行锁三兄弟:Record 锁记录,Gap 锁间隙,Next-Key 锁两者 死锁本质:环形等待,等图检测,代价小的滚 MVCC 三件套:Undo Log 存历史,Read View 判可见,版本链串起来 RC vs RR:RC 每次拍照,RR 第一次拍照看到底 锁加在哪:索引上!没索引就锁全表!
最后的最后:锁不是万能的,但不懂锁是万万不能的。 🎓
更多推荐



所有评论(0)