This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/26-锁机制总览.md
T
2026-05-17 00:06:11 +08:00

393 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, 锁机制, Record Lock, Gap Lock, Next-Key Lock]
create time: 2026-05-16 00:00
---
# 锁机制总览
## 概述
InnoDB 的锁体系是分层次的——从全局到表级到行级,每种锁粒度不同、适用场景不同。理解锁的分类和使用时机是排查锁冲突和设计高并发系统的核心。
## 锁分类全图
```mermaid
graph BT
subgraph "按粒度层次"
GL["全局锁 Global Lock"]
TL["表级锁 Table Lock<br/>MDL / 显式表锁"]
RL["行级锁 Row Lock"]
end
subgraph "行锁三大子类"
IL["意向锁 Intention<br/>IS / IX(自动)"]
RLock["记录锁 Record Lock<br/>只锁具体索引行"]
GLock["间隙锁 Gap Lock<br/>锁区间(不含记录)"]
NKLock["临键锁 Next-Key Lock<br/>Record Lock + Gap Lock"]
end
GL --> TL
TL --> RL
RL --> IL
RL --> RLock
RL --> GLock
RLock & GLock --> NKLock
style GL fill:#C44569,color:#fff
style TL fill:#FF9F43,color:#000
style NKLock fill:#00B6BC,color:#fff
```
> [!NOTE] 为什么需要三张图?
>
> - **第一张**展示锁的粒度层次:从全局→表→行
> - **第二张**展示行锁的子类关系:三种基础锁如何组合成临键锁
> - **第三张**(下方场景矩阵)展示不同查询条件下 InnoDB 实际选择哪种锁
>
> 三者互为补充,共同构成完整的锁选型全景。带着这三张图,你可以回答面试中「InnoDB 锁到底有哪些类型」的问题了。
## 全局锁
```sql
-- 对整个数据库加锁,不允许任何写入
FLUSH TABLES WITH READ LOCK;
-- 释放锁
UNLOCK TABLES;
```
**典型场景**:逻辑备份(mysqldump)时确保数据一致,期间不允许任何写操作。
> [!QUESTION] ❓ 思考:如果备份期间有人在做 DDL 怎么办?
>
> 答案是不影响 —— `FLUSH TABLES WITH READ LOCK` 只阻止写入,DDL 会阻塞等待全局锁释放。不过线上一般避免在备份窗口内做 DDL,因为全库锁住后所有请求都会排队。
```bash
# 使用 --single-transaction 可以避免全局锁
mysqldump --single-transaction --routines db_name > backup.sql
# 基于 MVCC,dump 过程中的写操作不会影响备份一致性
```
## 元数据锁(MDL, Metadata Lock)
MDL 是自动管理的,用户无法手动控制。目的是防止「一边有人查表结构,另一边有人在改表结构」。
> [!QUESTION] ❓ 为什么 MySQL 5.5+ 才引入 MDL?
>
> 在此之前,执行 `ALTER TABLE` 时其他会话的 `SELECT` 会被直接中断(抛出错误)。MDL 让 MySQL 改为「等待」而非「取消」,提升了生产环境稳定性。代价是可能出现排队现象——这就是下面要讲的雪崩问题。
```sql
-- 会话 A:执行查询,持有表的 MDL-SHARED 锁
SELECT * FROM users;
-- 持有 S 锁直到语句结束
-- 会话 B:尝试 ALTER TABLE
ALTER TABLE users ADD COLUMN bio TEXT;
-- 需要 MDL-EXCLUSIVE 锁
-- ⚠️ 阻塞!等待会话 A 释放 S 锁
```
> [!WARNING] MDL 锁导致的雪崩
> 大量长事务或未释放的连接持有 MDL-S 锁,可能导致 DDL 长期排队。这是 MySQL 线上最常见的隐性故障之一。
> ```sql
> -- 查看当前 MDL 等待情况
> SELECT * FROM performance_schema.metadata_locks;
> ```
## 表级锁
### 自研工具锁
```sql
LOCK TABLES users WRITE, orders READ;
-- 当前会话可以操作 users(写)和 orders(读)
-- 其他会话对这两个表的任何操作都被阻塞
UNLOCK TABLES; -- 显式释放
```
### MyISAM 的自动表锁
MyISAM 在执行每条语句前自动申请表锁,执行完毕后自动释放。InnoDB 则几乎不使用表锁——除了某些 DDL 操作。
## 行级锁 — 核心中的核心
InnoDB 的行锁都是**加在索引 record 上**的。没有索引 = 退化为表锁。
> [!WARNING] ⚠️ 面试高频坑点:无索引 UPDATE
>
> ```sql
> -- ❌ 大忌!没有索引会锁全表
> UPDATE orders SET status = 'shipped' WHERE user_id = 42;
>
> -- ✅ 正确做法:确保 user_id 有索引,或者用主键查询
> UPDATE orders SET status = 'shipped' WHERE id = 1001;
> ```
### 三种基本锁类型
| 名称 | 符号 | 兼容 | 说明 |
|------|------|------|------|
| **共享锁(S 锁)** | `LOCK IN SHARE MODE` | S+S ✅, S+X ❌ | 读锁,多个事务可同时持有 |
| **排他锁(X 锁)** | `FOR UPDATE` | X+X ❌ | 写锁,独占 |
| **意向锁(IS/IX)** | 自动加 | 用于表级兼容检测 | 表明事务想在某行上加 S/X |
```sql
-- 显式加共享锁
BEGIN;
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- 其他事务可以也加 S 锁,但不能加 X 锁
-- 不能 UPDATE 这行
-- 显式加排他锁
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 其他事务既不能加 S 也不能加 X
-- 别人 UPDATE 这行会被阻塞
```
### 意向锁的作用
```mermaid
flowchart TD
A["事务 A 想在行级加 X 锁"] --> B{"检查表级意向锁"}
B --> C{"表中是否有其他事务的<br/>意向共享锁 IS?"}
C -->|有| D["冲突!不能同时拥有 IX + IS"]
B -->|无| E{"表中是否有其他事务的<br/>意向排他锁 IX?"}
E -->|有| F["冲突!表级不支持多个 IX"]
E -->|无| G["✅ 放行,加行级 X 锁"]
style G fill:#00D866,color:#fff
style D fill:#EE5A24,color:#fff
style F fill:#EE5A24,color:#fff
```
> [!NOTE] 意向锁不是用户能控制的
> 当事务准备对某行加 S 锁时,InnoDB 自动在表级加 IS;加 X 锁时自动加 IX。它的作用是快速判断「这张表有没有人在用」,而不必逐行扫描。
## 记录锁、间隙锁、临键锁
这是 InnoDB 最精妙的设计,也是理解并发控制的关键。
### Record Lock(记录锁)
锁住**具体的索引记录**。
```sql
-- idx_status 上有唯一索引
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 只锁住 status='pending' 的那些具体记录
-- 不影响 status='shipped' 的记录
```
### Gap Lock(间隙锁)
锁住**索引记录之间的间隙**,不包含记录本身。目的是阻止其他事务在间隙中插入新记录。
```sql
-- 假设索引上有以下值: 10, 20, 30, 40, 50
-- 锁定 (20, 30) 这个开区间
SELECT * FROM t WHERE id = 25 FOR UPDATE;
-- id=25 不存在 → 不锁记录,锁住包含 25 的间隙 (20, 30)
-- 其他事务不能在 (20, 30) 区间内插入任何值
```
### Next-Key Lock = Record Lock + Gap Lock
**左开右闭区间** `(prev_value, value]`,是 RR 模式下默认的锁算法。
### 一图理解 Next-Key Lock
假设索引上有值 **10, 20, 30, 40, 50**,执行 `SELECT ... WHERE id = 20 FOR UPDATE;`:
```mermaid
graph LR
subgraph Data["📊 数据分布"]
direction TB
V1["10"] --- G1["Gap: (-∞, 10)"]
V1 --> NK["(10, 20] ⬅️ 锁定区域"]
NK --> V2["20"]
V2 --> G2["Gap: (20, 30)"]
V2 --> V3["30"]
end
subgraph Test["🧪 INSERT 测试"]
direction TB
T1["INSERT id=15"] --> B1["❌ 阻塞<br/>落入 (10, 20) 间隙"]
T2["INSERT id=20"] --> B2["❌ 阻塞<br/>记录已被锁定"]
T3["INSERT id=25"] --> OK["✅ 允许<br/>落在 (20, 30) 间隙外"]
end
V1 -.-> T1
V2 -.-> T2
V3 -.-> T3
style NK fill:#C44569,color:#fff,stroke-width:3px
style B1 fill:#EE5A24,color:#fff
style B2 fill:#EE5A24,color:#fff
style OK fill:#00D866,color:#fff
```
> [!TIP] 记忆口诀
>
> **「左开右闭」**:Next-Key Lock 锁定的是 `(前一个值, 当前值]`。
> - 插入 **等于** 锁定值的记录 → ❌ 阻塞(Record Lock 生效)
> - 插入 **落在开区间内** 的值 → ❌ 阻塞(Gap Lock 生效)
> - 插入 **落在右侧间隙外** 的值 → ✅ 允许
### RC vs RR:隔离级别对锁的影响
| 特性 | RR(默认) | RC(Read Committed) |
|------|-----------|---------------------|
| Record Lock | ✅ 有 | ✅ 有 |
| **Gap Lock** | ✅ 有 | ❌ **无** |
| **Next-Key Lock** | ✅ 默认使用 | ❌ 退化为 Record Lock |
| 幻读防护 | ✅ Gap Lock 阻止插入 | ❌ 可能发生幻读 |
| 并发度 | 较低(锁范围大) | 较高(不锁间隙) |
> [!IMPORTANT] 为什么 RC 能提升并发?
>
> Gap Lock 的存在意义是防止「幻读」——在同一个事务中两次 `SELECT` 读到不同行数。RC 模式下每次读取都生成快照(Snapshot Isolation),天然不需要 Gap Lock。代价是可能看到其他事务未提交的写入(不过 Read Committed 保证不会读到未提交的事务本身)。
>
> ```sql
> -- 显式设置会话隔离级别
> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
> ```
### Next-Key Lock 的特殊情形
| 场景 | 加锁方式 | 原因 |
|------|---------|------|
| **唯一索引等值查询命中** | 退化为 Record Lock | 已知不存在间隙中的冲突记录 |
| **唯一索引等值查询未命中** | Gap Lock | 锁住该值会落入的间隙(防止幻读) |
| **非唯一索引等值查询** | Next-Key Lock | 多个相同值,需保护左侧间隙 |
| **范围查询** | 每个匹配记录的 Next-Key Lock + 最大值的右边间隙 | 保护整个范围 |
| **INSERT** | Gap Lock on gap | 只锁插入位置的间隙,防冲突 |
| **主键等值更新且命中** | Record Lock | 等同于唯一索引精确查询 |
```mermaid
sequenceDiagram
participant T as 事务
participant DB as InnoDB
T->>DB: SELECT ... WHERE pk = 5 FOR UPDATE
Note over T,DB: 唯一索引、恰好命中
DB-->>T: 📌 Record Lock on pk=5
rect rgb(200, 255, 200)
Note right of DB: ✅ 不加 Gap Lock<br/>因为唯一索引已排除<br/>间隙冲突的可能
end
T->>DB: SELECT ... WHERE uk = 99 FOR UPDATE
Note over T,DB: 唯一索引、**未命中**
DB-->>T: 🔒 Gap Lock on (90, 100)
rect rgb(255, 230, 200)
Note right of DB: ⚠️ 加上 Gap Lock<br/>防止别人在 (90,100) 之间<br/>插入 uk=99 的记录
end
```
### ⚡ 当前读 vs 一致性读:谁触发了锁?
理解这一组概念是掌握锁机制的最终一步——**并不是所有 SELECT 都会加锁**。
| 读取方式 | SQL 示例 | 是否加锁 | 数据来源 |
|---------|---------|---------|---------|
| **一致性读(快照读)** | `SELECT * FROM t WHERE ...`(默认) | ❌ 不加锁 | Undo Log 历史版本(MVCC) |
| **当前读** | `SELECT ... FOR UPDATE/SHARE MODE` | ✅ 加锁 | 磁盘/缓冲池,读取最新提交数据 |
| **当前读** | `UPDATE / DELETE / INSERT` | ✅ 加锁 | 磁盘/缓冲池,读取并修改最新数据 |
> [!IMPORTANT] 为什么区分两种读?
>
> 想象以下场景:
> ```
> 事务 A: BEGIN; SELECT count(*) FROM orders; -- 读取了 100 条
> 事务 B: BEGIN; INSERT INTO orders ... COMMIT; -- 新增了 1 条
> 事务 A: SELECT count(*) FROM orders; -- 还是 100 条!🤔
> ```
>
> 第二次 `SELECT` 仍然看到 100 条,是因为一致性读使用的是事务启动时的快照。如果业务确实需要最新的计数,必须使用当前读:
> ```sql
> SELECT COUNT(*) FROM orders LOCK IN SHARE MODE;
> ```
>
> > [!NOTE] 深入阅读
> > 想进一步了解 MVCC 与锁的配合原理,参见 [[hhs/MySQL/25-MVCC 原理]] — 其中详细解释了 Read View 如何与锁协同工作。
## 锁的场景矩阵
面试和实际排障中,**「这条 SQL 到底加了什么锁?」**是最常见的问题。下面这张图按查询条件分类,帮你快速判断 InnoDB 的选择:
```mermaid
flowchart TD
Q{"查询方式"}
Q -->|"精确等值(unique index)"| R1["Record Lock<br/>仅锁那条记录"]
Q -->|"精确等值(non-unique index)"| R2["Next-Key Lock<br/>记录 + 左侧间隙"]
Q -->|"范围查询"<br/>WHERE id > 10"> R3["每个匹配记录的<br/>Next-Key Lock + 最大值右侧间隙"]
Q -->|"无索引条件"| R4["全表记录的 Next-Key Lock<br/>退化为表锁效果 ⚠️"]
Q -->|"DELETE | UPDATE | INSERT"| R5["INSERT: Gap Lock on gap<br/>DELETE/UPDATE: Record Lock"]
style R1 fill:#00B6BC,color:#fff
style R4 fill:#EE5A24,color:#fff
style R5 fill:#FF9F43,color:#000
```
> [!TIP] 实用记忆法
>
> 看到一条 SQL,依次回答三个问题就能判断加锁类型:
> 1. **有索引吗?** → 无索引 = 全表锁(❌)
> 2. **是等值查询吗?** → 是 → 再看是否有唯一索引
> 3. **是范围查询吗?** → 是 → 每个命中记录都加 Next-Key Lock
## 死锁与排查
在生产环境中,死锁不是「会不会发生」的问题,而是「什么时候发生」。理解死锁的形成过程比避免死锁更重要。
```sql
-- 查看最近的死锁信息
SHOW ENGINE INNODB STATUS\G
-- 在 LATEST DETECTED DEADLOCK 部分查看详细过程
-- 查看当前锁等待
SELECT * FROM sys.innodb_lock_waits;
-- MySQL 8.0+: 更直观的视图
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
```
```mermaid
sequenceDiagram
participant T1 as 事务 A
participant T2 as 事务 B
participant DB as InnoDB
T1->>DB: LOCK x (X)
DB-->>T1: ✅ 获得 x 锁
T2->>DB: LOCK y (X)
DB-->>T2: ✅ 获得 y 锁
T1->>DB: LOCK y (X)
DB-->>T1: ⏳ 等待 T2 释放 y
T2->>DB: LOCK x (X)
DB-->>T2: ⏳ 等待 T1 释放 x
Note over DB: 检测到环路!
DB->>T1: 💀 死锁! 回滚 T1
DB-->>T2: y 锁可用
T2->>DB: 获得 y 锁 ✅
```
> [!TIP] 避免死锁的策略
> 1. **固定顺序访问资源**:所有事务按相同的顺序加锁(如先用户表再订单表)
> 2. **一次性加所有锁**:减少持锁时间
> 3. **短事务原则**:尽可能少地参与竞争
> 4. **降低隔离级别**:RC 模式不使用 Gap Lock,大幅降低死锁概率
## 关联笔记
- [[hhs/MySQL/23-ACID 与原子性实现]] — Redo Log 和 Undo Log 的基础知识
- [[hhs/MySQL/25-MVCC 原理]] — MVCC 与锁的配合(一致性读 vs 当前读)
- [[hhs/MySQL/26-死锁与排查]] — 完整的死锁诊断流程
- [[hhs/GORM/08-事务管理]] — GORM 事务中的锁获取时机