MySQL 锁详解:表锁、行锁、间隙锁、Next-Key、死锁一次看个透
一、为什么写这篇文章
表结构
-- code_shop.t_test_lock definition
CREATE TABLE `t_test_lock` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '名称',
`age` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '年龄',
`trans_id` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '可重复交易流水号',
PRIMARY KEY (`id`),
UNIQUE KEY `t_test_lock_id_IDX` (`id`) USING BTREE,
KEY `t_test_lock_trans_id_IDX` (`trans_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

下面种类型的插入SQL为什么会在并发执行的出现死锁?
INSERT INTO t_test_lock (name, age, trans_id)
select '1', '2', 'trans_id_0010' from dual
where not exists (select 1 from t_test_lock where trans_id = "trans_id_0010")
读完本文章你可以:
1. 把 ACID、隔离级别、锁类型、加锁算法四张知识网织成一张;
2. 预判一条 SQL 在不同隔离级别下到底会加什么锁、会不会死锁;
3. 遇到“幻读”“死锁”报警时,不再只会 `kill thread`,而是能定位、能复现、能改 SQL。
二、热身:ACID 与隔离级别
| 特性 | 英文全称 | 中文含义 | 一句话说明 | MySQL/InnoDB 典型实现 |
|---|---|---|---|---|
| A | Atomicity | 原子性 | 事务要么全部成功,要么全部回滚,不存在中间状态 | Redo/Undo Log:崩溃恢复时利用日志回滚或重做(后面再出文章详解) |
| C | Consistency | 一致性 | 事务执行前后,数据库始终满足所有定义好的约束与业务规则 | 外键、触发器、唯一索引、MVCC 可见性判断等共同保证 |
| I | Isolation | 隔离性 | 并发事务之间互不干扰,如同串行执行 | 锁(行锁、间隙锁、Next-Key)+ MVCC 快照读 |
| D | Durability | 持久性 | 事务一旦提交,数据永久保存,即使系统崩溃也不丢失 | Redo Log 刷盘 + 双写缓冲区(Doublewrite Buffer)(后面再出文章详解) |
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现简述 |
|---|---|---|---|---|
| 读未提交 RU | ✅ | ✅ | ✅ | 只加行级 X 锁,读完立即释放 |
| 读已提交 RC | ❌ | ✅ | ✅ | 行级 X/S 锁,事务提交后释放;无 Gap Lock |
| 可重复读 RR(InnoDB 默认) | ❌ | ❌ | 部分场景 ❌ | Next-Key Lock 解决当前读幻读 |
| 序列化 Serializable | ❌ | ❌ | ❌ | 所有 SELECT 隐式 LOCK IN SHARE MODE,退化成表锁 |
幻读 vs 不可重复读:
不可重复读强调“同一行被修改两次读结果不同”;
幻读强调“同一范围被插入/删除两次读结果不同”。
可重复读 RR(InnoDB 默认)为什么部分解决幻读:
| 场景 | 是否幻读 | 原因 |
|---|---|---|
| 普通 SELECT(快照读) | 可能 | 读历史版本,不锁间隙 |
| SELECT … FOR UPDATE / LOCK IN SHARE MODE(当前读) | 不会 | Next-Key Lock 锁住区间,挡插入 |
| UPDATE / DELETE 范围语句(当前写) | 不会 | 同上 |
| 唯一索引等值查询 | 不会 | 唯一性保证,无需间隙锁也安全 |
三、锁家族一览
- 共享锁 S:允许其他事务再读,不允许写。
- 排他锁 X:读写都不允许。
- 意向共享锁 IS:事务想在某些行加 S 锁,先在表上加 IS。
- 意向排他锁 IX:同理,想在某些行加 X 锁。
- 表级锁:
LOCK TABLES … READ/WRITE才会真正锁整张表;日常UPDATE/DELETE只锁行。 - 意向锁目的:让表锁与行锁快速互斥判断,避免逐行扫描。
四、加锁算法三维图
| 算法 | 锁定范围 | 是否解决幻读 | 典型 SQL |
|---|---|---|---|
| Record Lock(RL) | 单行索引记录 | ❌ | RC 下 WHERE id=1 FOR UPDATE |
| Gap Lock(GL) | 开区间间隙 | ✅ 挡住插入 | RR 下 WHERE num=250(值不存在) |
| Next-Key Lock(NK) | 行 + 左间隙 | ✅ 当前读完全防幻读 | RR 下 WHERE num>200 |
锁最终落在索引上;
无索引时退化成全表 Next-Key Lock,看起来就像表锁,但概念不同。
五、实验表与数据
CREATE TABLE t(
id INT PRIMARY KEY,
name VARCHAR(10),
num INT,
KEY idx_num(num) -- 普通索引
);
INSERT INTO t VALUES
(1,'aaa',100),
(2,'bbb',200),
(3,'bbb',300),
(7,'ccc',200);
间隙划分(按 num 索引排序):
(∞,100) (100,200) (200,300) (300,+∞)
六、RC / RU 隔离级别加锁实测
| 场景 | 是否加锁 | 锁类型 | 锁住的索引项 | 备注 |
|---|---|---|---|---|
SELECT * FROM t WHERE num=200 | 否 | — | — | 快照读 |
SELECT … WHERE num=200 LOCK IN SHARE MODE | 是 | S 行锁 | num=200 的两条记录(pId=2,7) | 当前读 |
SELECT … WHERE num=200 FOR UPDATE | 是 | X 行锁 | 同上 | 同上 |
SELECT … WHERE num>200 FOR UPDATE | 是 | X 行锁 | num=300(pId=3) | 无 Gap Lock |
RC 只要命中行就加单行 Record Lock,无间隙;
若条件列无索引,会先锁全表再逐行释放不符合条件的锁,成本极高——线上禁止。
七、RR / Serializable 隔离级别加锁实测
| 场景 | 锁类型 | 锁住的索引项 | Gap Lock 范围 | 备注 |
|---|---|---|---|---|
SELECT … WHERE num=200 LOCK IN SHARE MODE | S + NK | num=200 两条 | (100,200] (200,300] | 挡插入 num∈(100,300) |
SELECT … WHERE num=250 FOR UPDATE(值不存在) | X Gap Lock | — | (200,300) | 只锁间隙,挡插入 250 |
SELECT … WHERE num>400 FOR UPDATE(无结果) | X Gap Lock | — | (400,+∞) | 挡超大值插入 |
| 唯一索引等值查询 | S/X Record Lock | 唯一索引 + 聚簇索引 | 无 Gap | 自动降级为行锁 |
Serializable 下所有普通 SELECT 也会隐式加
LOCK IN SHARE MODE,快照读消失,直接退化成当前读 + Next-Key Lock,并发最低。
八、死锁案例与排查
经典场景:
A 事务:UPDATE t SET num=250 WHERE id=2;
B 事务:UPDATE t SET num=250 WHERE id=7;
A 接着:SELECT * FROM t WHERE num=250 FOR UPDATE;
B 接着:同样的 SQL
→ 双方各自持有 id=2、7 的 X 行锁,又等待对方在 (200,300) 间隙的插入锁,循环等待→死锁。
排查三板斧:
SHOW ENGINE INNODB STATUS\G看LATEST DETECTED DEADLOCK;- 开
innodb_print_all_deadlocks=1持续打印; - 用
performance_schema.data_locks看事务持有/等待的锁模式。
规避原则:
- 更新必须走索引,减少锁范围;
- 统一事务顺序(先 A 表再 B 表);
- 避免长事务,尽快提交/回滚;
- 不允许更新唯一索引列(业务层幂等校验)。
九、MVCC:快照读的魔法
InnoDB 每行隐藏两个字段:
trx_id:最后一次修改该行的事务 ID;roll_pointer:指向 undo 段,形成版本链。
每个事务启动时拿到一份活跃事务数组,用于判断“对我可见的版本”。
因此快照读无需加锁,读写互不阻塞,大幅提升并发。
但当前读(FOR UPDATE/LOCK IN SHARE MODE)必须加锁,以保证写入安全。
十、一张脑图总结(文字版)
ACID
└─ 隔离级别
├─ RU(无Gap)
├─ RC(无Gap)
├─ RR(Next-Key)
└─ Serializable(全表NK)
锁类型
├─ S / X
├─ IS / IX(表级意向)
└─ 表锁(LOCK TABLES)
加锁算法
├─ Record Lock
├─ Gap Lock
└─ Next-Key Lock
MVCC
└─ 快照读(无锁)
└─ 当前读(S/X + NK)
十一、快问快答
Q1:RC 下 WHERE num=200 FOR UPDATE 锁几行?
A:num 索引上两行,聚簇索引对应两行,共** 4 个 Record Lock**,无 Gap。
Q2:RR 下 WHERE num=250 FOR UPDATE 锁几行?
A:250 不存在,只在 (200,300) 加 Gap Lock,锁** 0 行**但挡插入。
Q3:为什么唯一索引等值查询不加 Gap Lock?
A:唯一性保证不可能插入重复值,无需挡间隙,自动退化为 Record Lock。
十二、写在最后:思维导图
附思维导图一张:
【腾讯文档】MYSQL锁
https://docs.qq.com/mind/DU1ViV3FuWVNRWnpo?mode=mind
更多推荐


所有评论(0)