MySQL锁机制全解:行锁、表锁、意向锁与死锁排查
作者:黒漂技术佬
适用读者:知道有锁这回事,但分不清Record/Gap/Next-Key、排查死锁没思路的同学
关联场景:无人售货柜并发扣库存死锁、工控设备状态更新
一、为什么需要锁:并发写-写冲突的最后防线
上一篇讲MVCC时提过:MVCC解决了"读-写"并发的阻塞问题——读走快照,写走新版本,互不打扰。但MVCC解决不了"写-写"冲突。
售货柜最后1瓶可乐:
事务A:UPDATE product SET stock=stock-1 WHERE id=1001
事务B:UPDATE product SET stock=stock-1 WHERE id=1001
如果没锁:A读到stock=1,B读到stock=1,A写stock=0,B写stock=0
→ 实际只卖1瓶,却让两个用户都下单成功 → 超卖
两个事务同时改同一行,必须有一个先来后到——这就是锁要管的事。锁是MySQL保证写-写串行化的最后防线。
二、锁的分类维度
MySQL锁可以从两个维度分类,搞清楚这两个维度,后面的概念都好懂。
2.1 按粒度分
| 粒度 | 锁名 | 说明 |
|---|---|---|
| 表锁 | 表锁 | 锁整张表,粒度大,并发低 |
| 行锁 | 行锁 | 只锁一行/几行,粒度小,并发高 |
2.2 按性质分
| 性质 | 锁名 | 符号 | 兼容性 |
|---|---|---|---|
| 共享锁 | Share Lock | S | S和S兼容(可同时读) |
| 排他锁 | Exclusive Lock | X | 和任何锁互斥(写独占) |
兼容矩阵:
| S | X | |
|---|---|---|
| S | 兼容 | 互斥 |
| X | 互斥 | 互斥 |
简单记忆:S是"读锁",X是"写锁"。读读可共存,一有写就互斥。
三、表锁:读锁和写锁
3.1 表锁的语法
-- 加表读锁(S锁)
LOCK TABLES product READ;
-- 加表写锁(X锁)
LOCK TABLES product WRITE;
-- 释放
UNLOCK TABLES;
加表读锁后:当前会话和其他会话只能读这张表,不能写。
加表写锁后:只有当前会话能读写,其他会话读写都阻塞。
3.2 表锁的实际使用场景
表锁在InnoDB里用得少,因为粒度太粗。但有些场景必须用:
场景:售货柜凌晨批量导入商品数据
- 要做 ALTER TABLE 修改表结构 → 自动加表锁
- 要做大批量数据导入,避免逐行加锁开销
- MyISAM引擎只支持表锁(现在很少用了)
生产忠告:在线DDL(改表结构)要避开高峰期。ALTER TABLE会加表元数据锁,期间所有DML阻塞。大表加字段用pt-online-schema-change或gh-ost工具。
四、行锁:Record、Gap、Next-Key
InnoDB的精华在行锁,行锁又分三种。这是新手最容易搞混的地方,我们慢慢拆。
4.1 Record Lock(记录锁)
锁住索引上的一条具体记录。
-- 假设有唯一索引 product_id=1001
SELECT * FROM product WHERE product_id = 1001 FOR UPDATE;
这条语句会锁住product_id=1001这一行,其他事务想改这一行就阻塞。
索引记录: ... 1000 [1001]锁住 1002 ...
↑ 只有这行被锁
4.2 Gap Lock(间隙锁)
锁住索引记录之间的"间隙",但不锁记录本身。 Gap Lock的作用是防止其他事务在这个间隙里INSERT新数据。
假设product表product_id有值:1000, 1005, 1010
间隙:(-∞,1000)、(1000,1005)、(1005,1010)、(1010,+∞)
事务A执行:SELECT * FROM product WHERE product_id BETWEEN 1001 AND 1004 FOR UPDATE
→ 锁住间隙(1000,1005),其他事务不能往这个间隙插入数据
Gap Lock只在RR隔离级别下生效(RC下关闭)。它解决的经典问题是幻读。
幻读:同一事务里两次按条件查询,第二次查到了第一次没有的新行(别人INSERT进来的)。Gap Lock锁住间隙,让INSERT进不来,就没有幻读了。
4.3 Next-Key Lock
Record Lock + Gap Lock的组合体——既锁记录,又锁记录前面的间隙。 这是InnoDB在RR下行锁的默认形态。
索引记录:... 1000 1005 1010 ...
对1005加Next-Key Lock → 锁住 (1000, 1005]
↑ Record锁住1005这条记录
↑ Gap锁住(1000,1005)这个间隙
| 锁类型 | 锁范围 | 防什么 |
|---|---|---|
| Record Lock | 单条记录 | 防止行被修改/删除 |
| Gap Lock | 记录间间隙 | 防止INSERT新行(幻读) |
| Next-Key Lock | 间隙+记录 | 既防改又防插入 |
一个反直觉点:等值查询命中唯一索引时,Next-Key Lock会退化为Record Lock。因为唯一索引已经保证不会有重复值插入,不需要锁间隙。
五、意向锁:IS和IX
5.1 为什么需要意向锁
假设事务A给product表某一行加了行X锁。现在事务B想给整张表加表锁(比如要改表结构)。B怎么知道表里有没有行锁?总不能扫遍整张表所有行去查。
意向锁(Intent Lock)就是解决"快速判断表里有没有行锁"的问题。
5.2 意向锁的工作方式
事务在给行加S锁前,先给表加IS(意向共享锁);给行加X锁前,先给表加IX(意向排他锁)。
表锁和意向锁的兼容性:
| 表级 | IS | IX |
|---|---|---|
| IS | 兼容 | 兼容 |
| IX | 兼容 | 兼容 |
注意IS和IX之间互相兼容——因为它们只是"意向",不代表真正持锁。真正判断冲突的是表锁和意向锁的组合:
| 表级 | IS | IX |
|---|---|---|
| 表S锁 | 兼容 | 互斥 |
| 表X锁 | 互斥 | 互斥 |
这样事务B加表锁前只要看表的意向锁就够了:有IX说明表里有行X锁,加表S锁会被阻塞。
意向锁是InnoDB自动加的,开发不用手动管理。但要知道它的存在——排查锁等待时经常会看到IS/IX。
六、死锁是什么:两个事务互相等待
6.1 死锁的产生
死锁是两个或多个事务互相持有对方需要的锁,形成循环等待,谁也走不下去。
事务A 事务B
T1: UPDATE product SET stock=stock-1 WHERE id=1001 (持有id=1001的X锁)
T2: UPDATE product SET stock=stock-1 WHERE id=1002 (持有id=1002的X锁)
T3: UPDATE product SET stock=stock-1 WHERE id=1002 (等id=1002的X锁,被B锁住)
T4: UPDATE product SET stock=stock-1 WHERE id=1001 (等id=1001的X锁,被A锁住)
→ A等B,B等A,死锁形成
6.2 死锁的两个解决机制
InnoDB有两种机制应对死锁:
机制1:超时回滚
-- 设置锁等待超时(秒)
SET innodb_lock_wait_timeout = 50;
-- 超过50秒还没拿到锁,直接报错回滚
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
机制2:死锁检测(wait-for-graph算法)
InnoDB默认开启死锁检测innodb_deadlock_detect=ON。当事务请求锁时,系统检测会不会形成等待环。如果检测到死锁,立刻回滚其中一个"代价较小"的事务(一般是undo log量少的那个),让另一个继续执行。
死锁检测报错:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
工程权衡:死锁检测会消耗CPU。高并发写场景下(比如秒杀同一行),大量事务在同一行排队,死锁检测开销暴涨。这种场景要靠业务设计把热点行打散,或者关掉死锁检测+设短超时。
七、实际项目死锁排查
死锁发生时,数据库会回滚一个事务,但不会主动告诉你为什么。要主动查。
7.1 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS;
输出里的LATEST DETECTED DEADLOCK段会显示:
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 8, OS thread handle 12345, query id 100 localhost updating
UPDATE product SET stock=stock-1 WHERE id=1001
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 50 page no 3 n bits 72 index PRIMARY of table `shop`.`product`
trx id 12345 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
UPDATE product SET stock=stock-1 WHERE id=1002
*** (2) HOLDS THE LOCK(S):
... index PRIMARY of table `shop`.`product` trx id 12346 lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
... lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (2)
从这段日志能看出:
- 哪两个事务死锁
- 各自执行了什么SQL
- 在等哪个锁(PRIMARY索引上的X锁)
- 哪个事务被回滚了
7.2 开启完整锁等待日志
-- 开启锁等待详细日志(每次锁等待都记)
SET GLOBAL innodb_status_output = ON;
SET GLOBAL innodb_status_output_locks = ON;
排查死锁三件套:SHOW ENGINE INNODB STATUS看死锁现场 + information_schema.innodb_trx看活跃事务 + information_schema.innodb_locks/innodb_lock_waits看锁等待关系。
八、无人售货柜库存扣减死锁案例分析
8.1 案例背景
某无人售货柜运营平台有2000台柜子,每台柜子100个商品SKU。用户扫码开门取货后,系统根据重量变化算出取了哪些商品,然后扣库存并生成订单。
高峰期经常报死锁,每天上百次。
8.2 复现
业务代码(简化):
@Transactional
public void settleOrder(Long cabinetId, List<OrderItem> items) {
for (OrderItem item : items) {
// 按用户取货顺序逐个扣库存
productMapper.deductStock(item.getProductId(), item.getQty());
}
orderMapper.insert(order);
}
用户A取了[可乐(id=1001), 雪碧(id=1002)],用户B取了[雪碧(id=1002), 可乐(id=1001)]。两个事务按不同顺序扣库存 → 经典死锁。
8.3 解决方案
方案1:统一加锁顺序(最简单有效)
扣库存前,把商品ID排序,按固定顺序扣。这样所有事务拿锁的顺序一致,不会形成环。
@Transactional
public void settleOrder(Long cabinetId, List<OrderItem> items) {
// 按productId升序排序,保证所有事务加锁顺序一致
items.sort(Comparator.comparing(OrderItem::getProductId));
for (OrderItem item : items) {
productMapper.deductStock(item.getProductId(), item.getQty());
}
orderMapper.insert(order);
}
方案2:单条SQL批量扣减
把多次UPDATE合并成一次,只持有一组锁:
-- 用CASE WHEN一次扣多个SKU
UPDATE product
SET stock = CASE product_id
WHEN 1001 THEN stock - 1
WHEN 1002 THEN stock - 2
END
WHERE product_id IN (1001, 1002);
方案3:乐观锁+重试
把扣库存改成"先读后改再条件更新",冲突时重试:
-- 先读
SELECT stock FROM product WHERE product_id=1001; -- 假设读到48
-- 条件更新(带version或stock条件)
UPDATE product SET stock=stock-1
WHERE product_id=1001 AND stock=48;
-- 影响行数=0说明并发改了,重试
死锁排查核心思路:拿到死锁日志后,看两个事务各执行了什么SQL、在等哪条记录的锁。如果加锁顺序相反——就是死锁成因。统一加锁顺序是解决死锁的银弹。
九、总结
| 概念 | 一句话 |
|---|---|
| 表锁 | 锁整张表,粒度粗,并发低 |
| Record Lock | 锁单条索引记录 |
| Gap Lock | 锁索引间隙,防INSERT,只RR下生效 |
| Next-Key Lock | Record+Gap,RR下行锁默认形态 |
| 意向锁IS/IX | 标记表里有没有行锁,加速表锁判断 |
| 死锁 | 事务循环等待对方锁,靠检测或超时打破 |
| 死锁排查 | SHOW ENGINE INNODB STATUS看现场 |
| 死锁根治 | 统一加锁顺序、批量操作、乐观锁 |
锁机制是MySQL并发控制的下半场。MVCC管读-写并发,锁管写-写并发。理解Record/Gap/Next-Key三种行锁,以及死锁的形成和排查,你就能hold住90%的并发数据问题。下一篇我们看主从复制——MySQL走向分布式的第一步。

2640

被折叠的 条评论
为什么被折叠?



