08-MySQL锁机制全解:行锁、表锁、意向锁与死锁排查

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 LockSS和S兼容(可同时读)
排他锁Exclusive LockX和任何锁互斥(写独占)

兼容矩阵:

SX
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(意向排他锁)。

表锁和意向锁的兼容性:

表级ISIX
IS兼容兼容
IX兼容兼容

注意IS和IX之间互相兼容——因为它们只是"意向",不代表真正持锁。真正判断冲突的是表锁和意向锁的组合:

表级ISIX
表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 LockRecord+Gap,RR下行锁默认形态
意向锁IS/IX标记表里有没有行锁,加速表锁判断
死锁事务循环等待对方锁,靠检测或超时打破
死锁排查SHOW ENGINE INNODB STATUS看现场
死锁根治统一加锁顺序、批量操作、乐观锁

锁机制是MySQL并发控制的下半场。MVCC管读-写并发,锁管写-写并发。理解Record/Gap/Next-Key三种行锁,以及死锁的形成和排查,你就能hold住90%的并发数据问题。下一篇我们看主从复制——MySQL走向分布式的第一步。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值