本文涉及 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 UPDATESELECT ... LOCK IN SHARE MODEINSERTUPDATEDELETE

核心认知:只有"当前读"才会加行锁!普通 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_READSHARED_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 万次——这显然不现实。

有了意向锁后:

  1. 事务要加行锁前,先在表上加意向锁(IS 或 IX)

  2. 表锁只需要检查"有没有意向锁"就能判断是否冲突

  3. 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 不是傻等,它有一套主动检测机制:

  1. 等待图(Wait-for Graph)算法:每 10ms 扫描一次锁依赖关系

  2. 如果检测到环形依赖,立即判定为死锁

  3. 选择回滚代价最小的事务(undo log 量最少的那个)

  4. 被回滚的事务收到错误: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 不会直接覆盖旧值,而是:

  1. 旧版本写入 Undo Log

  2. 新版本的 roll_pointer 指向这条 Undo Log

  3. 多次修改后,通过 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;
  1. 先对 id=5 的记录加 X 锁(Record Lock 或 Next-Key Lock)

  2. 标记记录为 delete-marked(软删除,不是立即物理删除)

  3. 事务提交后,由 purge 线程异步物理删除

7.3 INSERT 语句的加锁过程

INSERT INTO t VALUES (5, 'test');
  1. 检查唯一性约束(如果是唯一索引,加 S 锁检查)

  2. 在目标间隙加 Insert Intention Lock

  3. 插入记录,加 X 锁

  4. 写 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 第一次拍照看到底
锁加在哪:索引上!没索引就锁全表!

最后的最后:锁不是万能的,但不懂锁是万万不能的。 🎓

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐