锁机制
本教程共 46 篇 · 第 39 篇 · 更新于 2026-07-30 · 约 14 分钟阅读
39. 锁机制
本节目标:搞清楚 InnoDB 中表锁和行锁的区别,理解共享锁/排他锁、记录锁/间隙锁/临键锁的作用,掌握死锁的产生原因和排查方法,了解乐观锁与悲观锁的思想。
39.1 为什么需要锁
多个事务同时修改同一份数据,如果不加控制就会互相覆盖、数据错乱。锁就是用来协调并发访问、保证数据一致性的机制。
打个比方:锁就像公共厕所的门锁。有人进去了把门锁上(加锁),其他人就得在外面等(阻塞),等里面的人出来(释放锁)才能进。
39.2 表锁 vs 行锁
按锁定粒度,MySQL 的锁分为两大类:
| 特性 | 表锁(Table Lock) | 行锁(Row Lock) |
|---|---|---|
| 锁定范围 | 整张表 | 单行或多行 |
| 并发度 | 低 | 高 |
| 开销 | 小 | 大 |
| 引擎支持 | MyISAM、InnoDB | 仅 InnoDB |
表锁
MyISAM 引擎只支持表锁。LOCK TABLES 可以手动加表锁:
-- 加读锁
LOCK TABLES users READ;
-- 加写锁
LOCK TABLES users WRITE;
-- 释放
UNLOCK TABLES;
InnoDB 也支持表锁,但一般不用。InnoDB 的优势在于行锁。
NoteInnoDB 在执行某些 DDL 操作(如 ALTER TABLE)时会加表锁。普通 DML 操作默认走行锁。
行锁
InnoDB 的行锁是基于索引实现的。这意味着:只有通过索引条件检索数据时,才使用行锁;否则会退化为表锁。
-- 有索引的情况:行锁
-- 假设 id 是主键
BEGIN;
UPDATE users SET age = 30 WHERE id = 1; -- 只锁 id=1 这一行
COMMIT;
-- 没有索引的情况:退化为表锁
-- 假设 age 列没有索引
BEGIN;
UPDATE users SET age = 30 WHERE age = 25; -- 锁住整张表!
COMMIT;
Warning这是个很隐蔽的坑。WHERE 条件没有走索引的 UPDATE/DELETE,InnoDB 会对整张表加锁,把并发完全堵死。确保更新和删除操作的 WHERE 条件走索引。
39.3 共享锁与排他锁
按锁的兼容性,分为两种:
| 锁类型 | 别名 | 加锁方式 | 兼容性 |
|---|---|---|---|
| 共享锁(S Lock) | 读锁 | SELECT ... LOCK IN SHARE MODE | 与共享锁兼容 |
| 排他锁(X Lock) | 写锁 | SELECT ... FOR UPDATE | 与任何锁不兼容 |
共享锁(Shared Lock)
多个事务可以同时持有同一行的共享锁,但持有期间不能被修改。
-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE; -- 加共享锁
-- 事务 B(另一个会话)
BEGIN;
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE; -- OK,共享锁兼容
UPDATE users SET age = 30 WHERE id = 1; -- 阻塞!等事务 A 释放锁
COMMIT;
排他锁(Exclusive Lock)
只有持有排他锁的事务能修改数据,其他事务既不能读(当前读)也不能写。
-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 加排他锁
-- 事务 B
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 阻塞!等事务 A 释放
COMMIT;
Tip
FOR UPDATE是实际开发中最常用的加锁方式。比如扣库存场景:先SELECT ... FOR UPDATE锁住库存行,再 UPDATE 扣减,保证不会超卖。
锁兼容矩阵
| 共享锁(S) | 排他锁(X) | |
|---|---|---|
| 共享锁(S) | 兼容 | 不兼容 |
| 排他锁(X) | 不兼容 | 不兼容 |
普通 SELECT(快照读)不加任何锁,不受影响。
39.4 意向锁(Intention Lock)
InnoDB 还有意向锁,是表级别的锁,用来表示”我打算在这张表的某些行上加锁”。
两种意向锁:
- 意向共享锁(IS):打算在某些行上加共享锁;
- 意向排他锁(IX):打算在某些行上加排他锁。
意向锁是 InnoDB 自动加的,不需要手动操作。它的作用是:当某个事务想加表锁时,快速判断表里有没有行锁,不用逐行检查。
事务 A 要给 id=1 加行级排他锁
-> 先在表上加意向排他锁(IX)
-> 再在行上加排他锁(X)
事务 B 要给整张表加表锁
-> 检查表上有没有 IX 锁
-> 有 IX 锁 -> 说明有行被锁了 -> 等待
Note意向锁之间互相兼容,意向锁和行锁也兼容(不同行)。意向锁只和表锁冲突。这是 InnoDB 内部优化,开发者一般不用直接关心。
39.5 行锁的三种算法
InnoDB 的行锁有三种具体实现,这是理解锁机制的关键。
记录锁(Record Lock)
锁定索引上的一条记录。最简单的行锁。
-- 精确等值查询命中记录
-- 假设 id=1 存在
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 只锁 id=1 这条记录
间隙锁(Gap Lock)
锁定索引记录之间的”间隙”,防止其他事务在间隙中插入新记录。只在 REPEATABLE READ 及以上隔离级别生效。
假设 users 表 id 有:1, 5, 10
间隙为:(-∞, 1), (1, 5), (5, 10), (10, +∞)
-- 等值查询不命中记录时,加间隙锁
SELECT * FROM users WHERE id = 7 FOR UPDATE;
-- 锁住间隙 (5, 10),防止其他事务插入 id=6,7,8,9
Note间隙锁的唯一目的是防止幻读。它阻止其他事务在间隙中插入新数据。间隙锁之间不冲突(多个事务可以同时持有同一间隙的间隙锁),但间隙锁会阻止 INSERT。
临键锁(Next-key Lock)
记录锁 + 间隙锁的组合,锁定一个左开右闭区间。这是 InnoDB 在 REPEATABLE READ 下默认的行锁算法。
假设 users 表 id 有:1, 5, 10
临键锁区间为:(-∞, 1], (1, 5], (5, 10], (10, +∞)
SELECT * FROM users WHERE id BETWEEN 5 AND 10 FOR UPDATE;
-- 锁住 (1, 5] 和 (5, 10],也就是锁住 id=5 和 id=10 的记录,
-- 以及它们之间的间隙,防止插入 id=6,7,8,9
三种行锁对比
| 锁类型 | 锁定范围 | 防止什么 | 触发条件 |
|---|---|---|---|
| 记录锁 | 单条记录 | 修改/删除冲突 | 等值查询命中记录 |
| 间隙锁 | 记录之间的间隙 | 插入冲突 | 等值查询不命中记录 |
| 临键锁 | 记录 + 前面的间隙 | 修改 + 插入冲突 | 范围查询 |
Tip临键锁是 InnoDB 防幻读的关键。在 REPEATABLE READ 下,范围查询会用临键锁锁住查询范围内的所有记录和间隙,其他事务无法在这个范围内插入新行,从而防止幻读。
临键锁退化为记录锁
当等值查询命中唯一索引(如主键)时,临键锁会退化为记录锁,不需要锁间隙(因为唯一值不可能重复插入)。
-- id 是主键,等值命中
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 只加记录锁,锁 id=5,不加间隙锁
39.6 死锁
死锁(Deadlock) 是两个或多个事务互相等待对方释放锁,形成循环等待,谁也动不了。
事务 A:锁了 id=1,等 id=2 的锁
事务 B:锁了 id=2,等 id=1 的锁
-> A 等 B,B 等 A,死锁
死锁演示
-- 事务 A
BEGIN;
UPDATE users SET age = 25 WHERE id = 1; -- 锁 id=1
-- 事务 B(另一个会话)
BEGIN;
UPDATE users SET age = 30 WHERE id = 2; -- 锁 id=2
-- 事务 A
UPDATE users SET age = 35 WHERE id = 2; -- 等 B 释放 id=2 的锁
-- 事务 B
UPDATE users SET age = 40 WHERE id = 1; -- 等 A 释放 id=1 的锁
-- 死锁!InnoDB 检测到后,回滚其中一个事务
-- ERROR 1213 (40001): Deadlock found when trying to get lock;
-- try restarting transaction
InnoDB 的死锁处理
InnoDB 有死锁检测机制:发现死锁后,自动选择一个”回滚代价最小”的事务进行回滚,让另一个事务继续执行。
-- 查看死锁日志
SHOW ENGINE INNODB STATUS\G
-- 找到 LATEST DETECTED DEADLOCK 部分
预防死锁
死锁不能完全避免,但可以减少发生概率:
1. 按固定顺序加锁
多张表或多行操作时,所有事务按相同顺序加锁,就不会形成循环等待。
-- 好的做法:所有事务都先锁 id 小的
-- 事务 A:先 id=1,再 id=2
-- 事务 B:先 id=1,再 id=2
-- 不会死锁
2. 大事务拆小
事务越长,持锁时间越长,死锁概率越高。把大事务拆成多个小事务。
3. 降低隔离级别
READ COMMITTED 没有间隙锁,锁的范围更小,死锁概率更低。
4. 加超时等待
-- 设置锁等待超时(秒),超时后报错而非一直等
SET innodb_lock_wait_timeout = 10; -- 默认 50 秒
Warning死锁被 InnoDB 自动回滚后,应用层应该捕获错误并重试。不要认为死锁是致命错误,在高并发系统中死锁是正常现象,重试通常就能成功。
39.7 乐观锁与悲观锁
这不是具体的锁实现,而是并发控制的两种思想。
悲观锁(Pessimistic Lock)
假设冲突一定会发生,操作前先加锁。
-- 悲观锁实现扣库存
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 加排他锁
-- 应用层判断 stock > 0
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
特点:数据安全性高,但并发度低(其他事务要等)。
乐观锁(Optimistic Lock)
假设冲突很少发生,不加锁,更新时检查数据是否被改过。通常用版本号实现。
-- 乐观锁实现扣库存
-- products 表有 version 列
-- 第一步:读当前版本(不加锁)
SELECT stock, version FROM products WHERE id = 1;
-- 假设读到 stock=10, version=3
-- 第二步:更新时检查版本是否变化
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 3;
-- 如果 affected_rows = 1,说明没被别人改过,更新成功
-- 如果 affected_rows = 0,说明被别人改过了,重试
特点:并发度高,但冲突多时重试开销大。
| 对比 | 悲观锁 | 乐观锁 |
|---|---|---|
| 假设 | 冲突频繁 | 冲突稀少 |
| 实现 | FOR UPDATE | 版本号/CAS |
| 并发度 | 低 | 高 |
| 适用场景 | 写多读少 | 读多写少 |
Tip乐观锁适合读多写少的场景(如商品详情页),悲观锁适合写多冲突多的场景(如秒杀扣库存)。实际项目中两种经常混用。
39.8 查看锁信息
-- MySQL 8.0+ 查看当前锁
SELECT * FROM performance_schema.data_locks;
-- 查看锁等待
SELECT * FROM performance_schema.data_lock_waits;
-- 查看事务
SELECT * FROM information_schema.INNODB_TRX;
NoteMySQL 8.0 之前用
SHOW ENGINE INNODB STATUS查看锁信息,格式不太友好。8.0+ 的performance_schema.data_locks表更清晰。
39.9 小结
- InnoDB 行锁基于索引实现,无索引退化为表锁;
- 共享锁(读锁)兼容,排他锁(写锁)独占;
- 意向锁是表级锁,用于快速判断表上是否有行锁;
- 行锁三种算法:记录锁(锁单行)、间隙锁(锁间隙防插入)、临键锁(记录+间隙,防幻读);
- 唯一索引等值命中时临键锁退化为记录锁;
- 死锁由 InnoDB 自动检测和回滚,按固定顺序加锁可预防;
- 悲观锁先加锁,乐观锁用版本号检测,适合不同并发场景。
下一节进入索引世界,学习 B+ 树、聚簇索引和各类索引的原理。