393 lines
14 KiB
Markdown
393 lines
14 KiB
Markdown
---
|
||
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 事务中的锁获取时机
|