innodb锁分类
sql
InnoDB 锁
├── 按粒度分
│ ├── 表锁(Table Lock)
│ │ ├── 意向共享锁(IS)
│ │ ├── 意向排他锁(IX)
│ │ ├── 自增锁(AUTO-INC)
│ │ └── 元数据锁(MDL,Server 层)
│ └── 行锁(Row Lock)
│ ├── 记录锁(Record Lock) ← 锁具体行
│ ├── 间隙锁(Gap Lock) ← 锁间隙
│ └── 临键锁(Next-Key Lock) ← 记录锁 + 间隙锁
│
└── 按功能分
├── 共享锁(S Lock):SELECT ... LOCK IN SHARE MODE
└── 排他锁(X Lock):SELECT ... FOR UPDATE / UPDATE / DELETE
一、间隙锁
1.1 间隙锁是什么
间隙锁(gap lock):间隙锁指的是对索引记录之间的间隙 进行加锁
间隙锁、行锁都是innodb悲观锁的代表
sql
索引列 age 的 B+ 树结构:
[10] ──→ [20] ──→ [30] ──→ [50]
↑ ↑ ↑ ↑
记录1 记录2 记录3 记录4
间隙(Gap):
(-∞, 10) (10, 20) (20, 30) (30, +∞)
↑ ↑ ↑ ↑
间隙1 间隙2 间隙3 间隙4
1.2间隙锁的作用
| 作用 | 说明 |
|---|---|
| 防止幻读 | 阻止其他事务在间隙中插入新记录 |
| 防止幻读的前提 | RR 级别 + 当前读 |
| 不锁定具体记录 | 只锁定记录之间的范围 |
二、临键锁(RR的核心)
2.1 临键锁定义
临键锁(next-key lock) = 记录锁(Record Lock)+ 间隙锁(Gap Lock)
sql
锁定范围:(前一个索引记录, 当前索引记录]
↑ ↑
开区间 闭区间
2.2 性能影响
| 问题 | 说明 |
|---|---|
| 降低并发度 | 间隙被锁定,插入操作串行化 |
| 锁范围扩大 | 非唯一索引导致锁定范围不可控 |
| 死锁检测开销 | InnoDB 自动检测死锁,有性能成本 |
三、哪些语句会加锁
以下语句都是当前读,都会加锁
| SQL 类型 | 加的锁 | 说明 |
|---|---|---|
SELECT ... FOR UPDATE |
X 锁(临键锁/间隙锁/记录锁) | 显式当前读 |
SELECT ... LOCK IN SHARE MODE |
S 锁(临键锁/间隙锁/记录锁) | 共享当前读 |
UPDATE ... |
X 锁(临键锁/间隙锁/记录锁) | 隐式当前读 |
DELETE ... |
X 锁(临键锁/间隙锁/记录锁) | 隐式当前读 |
INSERT ... |
插入意向锁 + 记录锁 | 特殊处理 |
关键 :UPDATE 和 DELETE 也是当前读!它们需要先读取最新版本,再加锁修改。
四、什么情况下加什么锁(加锁规则)
4.1 决策流程图(核心规则)
sql
当前读语句(FOR UPDATE / UPDATE / DELETE / LOCK IN SHARE MODE)
│
▼
使用的索引类型?
│
├──► 唯一索引(主键/唯一键)
│ │
│ ├──► 等值查询 AND 记录存在?
│ │ └──► 是 → 退化为【记录锁】
│ │ 例:WHERE id = 1(id=1 存在)
│ │
│ ├──► 等值查询 AND 记录不存在?
│ │ └──► 是 → 【间隙锁】
│ │ 例:WHERE id = 5(id=5 不存在)
│ │ 锁定:(上一个存在的id, 下一个存在的id)
│ │
│ └──► 范围查询?
│ └──► 是 → 【临键锁】
│ 例:WHERE id > 1 AND id < 10
│
└──► 非唯一索引(普通索引)
│ │
│ └──► 任何查询(等值查询/范围查询) → 【临键锁】
│ 例:WHERE age = 20(age 是普通索引)
│ 锁定:(10, 20] 和 (20, 30)
│
└──► 无索引
└──► 全表扫描 → 所有行加【临键锁】(性能极差)
一句话总结
判断加锁类型的关键是:看 WHERE 条件使用的索引类型 + 查询方式。当前读(UPDATE/DELETE/FOR UPDATE)都会加锁,唯一索引等值命中退化为记录锁,其他情况基本都是临键锁或间隙锁。锁只加在访问的索引上,不同索引之间的锁不冲突。
4.2 具体例子对照
| 场景 | SQL | 索引类型 | 查询方式 | 加的锁 | 说明 |
|---|---|---|---|---|---|
| 主键等值命中 | UPDATE t SET ... WHERE id = 1 |
唯一索引 | 等值 | 记录锁 | id=1 存在,只锁这行 |
| 主键等值未命中 | UPDATE t SET ... WHERE id = 5 |
唯一索引 | 等值 | 间隙锁 | id=5 不存在,锁相邻间隙 |
| 主键范围 | UPDATE t SET ... WHERE id > 1 AND id < 10 |
唯一索引 | 范围 | 临键锁 | 锁多行+间隙 |
| 普通索引等值 | UPDATE t SET ... WHERE age = 20 |
非唯一索引 | 等值 | 临键锁 | 锁 (10,20] 和 (20,30) |
| 普通索引范围 | UPDATE t SET ... WHERE age > 15 AND age < 25 |
非唯一索引 | 范围 | 临键锁 | 锁 (10,20] 和 (20,30) |
| 无索引 | UPDATE t SET ... WHERE name = 'a' |
无 | 等值 | 全表临键锁 | 所有行+间隙 |
| 无索引范围 | UPDATE t SET ... WHERE name > 'a' AND name < 'z' |
无 | 范围 | 全表临键锁 | 所有行+间隙 |
| 插入 | INSERT INTO t VALUES (5, 25) |
--- | --- | 插入意向锁 | 检查目标间隙是否有锁 |
注意,这里有个回表的情况,普通索引查到数据后会回表,如果普通索引等值查询对普通索引列加锁了,那么这条记录对应的主键索引也会被锁住
五、无索引时的全表临键锁与全表加锁的区别
无索引时全表临键锁的效果几乎等同于表锁,但技术上不是真正的表锁
核心区别
| 维度 | 无索引时的全表临键锁 | 真正的表锁(Table Lock) |
|---|---|---|
| 实现层级 | 存储引擎层(InnoDB 行锁机制) | Server 层或存储引擎层显式锁表 |
| 锁定对象 | 所有行的记录锁 + 所有间隙的间隙锁 | 整个表的数据和索引 |
| 加锁方式 | 逐行扫描时逐个加临键锁 | 一次性锁住整张表 |
| 锁信息 | information_schema.innodb_locks 显示多行 |
显示为表级锁 |
| 死锁检测 | 参与 InnoDB 死锁检测 | 不参与(或不同机制) |
| 其他事务 | 不能插入、删除、更新任何行 | 不能做任何 DML/DDL |
一句话总结
无索引时的全表临键锁,效果上等同于表锁(所有行+所有间隙都被封锁),但实现机制仍是行锁框架下的逐行加锁。真正的性能灾难在于:不仅锁了现有数据,还锁了所有可能的插入位置,导致任何写入操作都无法并发。
六、举例子
表结构:id PK(主键索引), age INDEX(普通索引)
数据:
├─ 主键索引(id):[1] → [2] → [3]
│
└─ 普通索引(age):[10] → [20] → [30]
↓
指向主键 1, 2, 3
情况 1:事务 A 用普通索引,事务 B 用主键查询同一行
sql
-- 事务 A
BEGIN;
SELECT * FROM t WHERE age = 20 FOR UPDATE;
-- 执行过程:
-- 1. 在 age 索引找到 age=20,对应主键 id=2
-- 2. 对 age 索引的 (10,20] 和 (20,30) 加临键锁
-- 3. **回表**:对主键索引 id=2 的记录加 X 锁(记录锁)
-- 事务 B
SELECT * FROM t WHERE id = 2 FOR UPDATE;
-- 直接访问主键索引 id=2
-- 发现 id=2 已有 X 锁(事务 A 加的)
-- ❌ 阻塞!等待事务 A 释放
结论 :会阻塞! 因为事务 A 回表时也锁了主键索引的记录。
情况 2:事务 A 用普通索引,事务 B 用主键查不同行
sql
-- 事务 A
SELECT * FROM t WHERE age = 20 FOR UPDATE;
-- 锁住:age 索引的 (10,20] + (20,30),以及主键 id=2
-- 事务 B
SELECT * FROM t WHERE id = 1 FOR UPDATE;
-- id=1 未被事务 A 锁住
-- ✅ 不阻塞!
SELECT * FROM t WHERE id = 3 FOR UPDATE;
-- id=3 未被事务 A 锁住
-- ✅ 不阻塞!
情况 3:事务 B 用主键查,但事务 A 的锁不包含该主键
sql
-- 事务 A
SELECT * FROM t WHERE age = 20 FOR UPDATE;
-- 只锁住 age=20 对应的 id=2
-- 事务 B
SELECT * FROM t WHERE id = 1;
-- id=1 对应 age=10,不在事务 A 的锁定范围内
-- 但注意:事务 A 的间隙锁 (10,20] 包含 age=10 吗?
-- (10,20] = 10 开区间,20 闭区间,即 age > 10 AND age ≤ 20
-- age=10 不在 (10,20] 内
-- ✅ 不阻塞!
情况4:插入到间隙(经典幻读防护)
sql
-- 事务 A
BEGIN;
SELECT * FROM t WHERE age > 15 AND age < 25 FOR UPDATE;
-- 临键锁:(10, 20] 和 (20, 30)
-- 事务 B(会被阻塞)
INSERT INTO t VALUES (5, 18); -- ❌ 阻塞!18 在 (10,20]
INSERT INTO t VALUES (6, 22); -- ❌ 阻塞!22 在 (20,30)
INSERT INTO t VALUES (7, 35); -- ✅ 通过!35 不在锁定范围
关键图示
age 索引(普通索引) 主键索引(id)
(10, 20] 临键锁 🔒 id=1 age=10
│ ↑
▼ 无锁 ✅
age=20 ───────回表────────► id=2 age=20 🔒 X锁(事务A持有)
│ ↑
(20, 30) 间隙锁 🔒 事务B: WHERE id=2 FOR UPDATE
│ 需要 id=2 的 X 锁
▼ ❌ 阻塞!
age=30 ───────回表────────► id=3 age=30
↑
无锁 ✅
一句话总结
普通索引的查询会回表锁主键,所以用主键查同一行会被阻塞;查不同行则不会。核心判断标准是:最终是否访问到同一行记录的同一索引。