
上月,本来想着 SpringBoot + MyBatis-Plus + MySQL 这套"老三位"稳稳当当,能跑就行。结果老板上周突然丢了句:"你这系统只能你自己用,上线就炸。"然后丢过来一个 JMeter 脚本,50 线程循环跑,10 分钟内必报 DeadlockFoundException。我熬了两个通宵翻日志,总算把 MySQL 的间隙锁(Gap Lock)这玩意整明白了。记录一下,纯当给自己留个档,也给后来人避个坑。
我做的是个二手交易平台,核心链路很简单:查库存 → 扣库存 → 生成订单。为了避免用户重复提交,我在下单前先查了一遍订单表:
@Transactional(rollbackFor = Exception.class)
public void createOrder(Long userId, Long goodsId) {
// 1. 检查是否重复下单
List<Order> exist = orderMapper.selectByUserAndGoods(userId, goodsId, 0);
if (!exist.isEmpty()) {
throw new BizException("您有进行中的订单,别重复点");
}
// 2. 查库存(省略)
// 3. 扣库存(省略)
// 4. 插入订单
orderMapper.insert(order);
}对应的 Mapper 就一行很平常的 SQL:
<select id="selectByUserAndGoods" resultType="Order">
select * from orders
where user_id = #{userId}
and goods_id = #{goodsId}
and status = 0
</select>本地单点调试,完美。老板用 JMeter 上 50 个线程,每个线程模拟同一个用户狂点下单,10 分钟之内控制台一片红。报错信息就长这样:
### Error updating database. Cause: java.sql.SQLException:
Deadlock found when trying to get lock; try restarting transaction我第一反应是:肯定是我业务代码哪里有循环依赖了?或者更新库存时没加锁?结果把 update goods set stock = stock - 1 改成了 for update 之后,死锁不仅没消失,反而出现得更频繁了。当时我就懵了,百度搜了半天,满屏都是复制粘贴的"解决死锁的 N 种方法",压根没说到点子上。
走投无路,我只能去翻 MySQL 的死锁日志。在命令行里执行:
show engine innodb status;往下拉到 LATEST DETECTED DEADLOCK 那一段,核心信息我截出来了:
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 1 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 50, OS thread handle ... , query id ... localhost ... updating
insert into orders (user_id, goods_id, status ...) values (5, 100, 0)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index idx_user_goods of table `db`.`orders`
lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 1 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
insert into orders (user_id, goods_id, status ...) values (5, 100, 0)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 72 index idx_user_goods of table `db`.`orders`
lock_mode X locks gap before rec
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index idx_user_goods of table `db`.`orders`
lock_mode X locks gap before rec insert intention waiting我盯着 lock_mode X locks gap before rec insert intention waiting 这行字看了十分钟。gap?insert intention?这俩词组合在一起,我第一次见。
后来翻了官方文档才知道,MySQL 默认的 RR(Repeatable Read) 隔离级别下,如果查询条件没命中记录,InnoDB 为了防止幻读,会加一个间隙锁(Gap Lock)。两个事务都拿到了同一个间隙的共享锁,互不冲突;但它们接下来都要 insert,insert 需要的是插入意向锁(Insert Intention Lock),而插入意向锁和间隙锁是互斥的。于是你等我、我等你,闭环了,死锁。
我用 Mermaid 画了个时序图,直观感受一下:
sequenceDiagram
autonumber
participant A as 事务A(线程1)
participant B as 事务B(线程2)
participant DB as MySQL InnoDB
A->>DB: begin;
Note over A,DB: select * from orders <br/>where user_id=5 and goods_id=100 <br/>and status=0
DB-->>A: 授予 Gap Lock(共享,不冲突)
B->>DB: begin;
Note over B,DB: 同样的 select 语句
DB-->>B: 授予 Gap Lock(共享,不冲突)
A->>DB: insert into orders ...
Note over A,DB: 申请 Insert Intention Lock
DB-->>A: 等待事务B释放 Gap Lock
B->>DB: insert into orders ...
Note over B,DB: 申请 Insert Intention Lock
DB-->>B: 等待事务A释放 Gap Lock
Note over A,B: 死锁形成!<br/>MySQL 选择回滚其中一个事务说白了,问题就出在"先查后插"这个惯性思维上。单线程没问题,并发一上来必死。
查资料的时候,看到好多帖子说:"把隔离级别改成 Read Committed 就好了,间隙锁就没了。"
说实话,我一开始也动了这个念头,就一行配置的事。但老板原话是:"你那是逃避问题,不是解决问题。RC 是业务能容忍幻读才能用的,你订单防重这场景能容忍幻读吗?"
我想了想,确实不能。万一两个线程同时判断"没有进行中的订单",然后同时插入,那就真重复下单了。
我列了个表格,把几个方案在脑子里过了一遍:
方案 | 核心思路 | 改动成本 | 副作用 | 学生项目适用度 |
|---|---|---|---|---|
改隔离级别为 RC | 让间隙锁直接消失 | 一行配置 | 幻读风险;只是掩盖问题 | ⭐⭐⭐ |
select ... for update | 显式加排他锁,串行化检查 | 改 SQL + 加索引 | 锁等待严重,吞吐量暴跌 | ⭐⭐⭐ |
唯一索引 + 数据库去重 | 把防重逻辑交给数据库唯一索引 | 加索引 + 改代码 | 需要业务上定义好"什么算重复" | ⭐⭐⭐⭐ |
缩小事务粒度 + 状态机 | 先插订单(带初始状态),再异步扣库存 | 业务逻辑大改 | 架构复杂,搞不赢 | ⭐⭐ |
我最后选的方案是方案三:唯一索引兜底。因为在我的业务里,一个用户对同一商品只能存在一个"待支付"订单是合理约束。于是我做了两件事:
ALTER TABLE orders ADD UNIQUE INDEX uk_user_goods (user_id, goods_id);(注意:这里我把 status 从唯一索引里拿掉了,因为 status 会变。如果业务要求"只要用户买过这个商品就不能再下单",那直接锁 user_id + goods_id 就行;如果允许完成后再买,那就需要在应用层判断 status,但死锁问题可以通过下面的代码调整规避。)
@Transactional(rollbackFor = Exception.class)
public void createOrder(Long userId, Long goodsId) {
try {
// 1. 先尝试插入订单(利用唯一索引防重)
Order order = new Order();
order.setUserId(userId);
order.setGoodsId(goodsId);
order.setStatus(0); // 待支付
orderMapper.insert(order);
// 2. 再扣库存(这里用乐观锁防超卖)
int rows = goodsMapper.decreaseStock(goodsId, 1);
if (rows == 0) {
throw new BizException("库存不足或并发冲突");
}
} catch (DuplicateKeyException e) {
throw new BizException("您有进行中的订单,请勿重复提交");
}
}对应的扣库存 SQL 也顺手改成了乐观锁,防止超卖:
<update id="decreaseStock">
update goods
set stock = stock - #{quantity},
version = version + 1,
update_time = now()
where id = #{goodsId}
and stock >= #{quantity}
</update>这样一来,"查订单"这一步直接删掉了。不存在 select 加间隙锁,也就不会有后续的 insert 死锁。重复下单的问题交给数据库的唯一索引原子性去保证,比我自己在 Java 代码里先查后插可靠一百倍。
老板看了这段代码,终于没再说"上线就炸",而是说了句:"这才像点样子。"
死锁解决了,但压测结果还不是特别好看。我又顺手做了两个改动,效果明显:
1. 索引精简化
之前 orders 表上我为了图省事,给 user_id、goods_id、status 各自建了单列索引。MySQL 选执行计划时有时候会走错,导致锁范围变大。我删了多余的单列索引,只保留了 (user_id, goods_id) 的联合唯一索引和一个 status 的普通索引用于后台查询。 Explain 一看,type 从 index_merge 变成了 const,舒服了。
2. 事务粒度缩短
之前我把"查询商品详情、扣库存、插订单、写操作日志"全包在一个大 @Transactional 里。现在我把非核心操作(比如写日志、发站内信)扔到事务外面,用 Spring 的 TransactionEventListener 或者干脆异步线程去做。事务变短了,锁持有时间也短了,并发一上去,吞吐量明显好了不少。
这次被老板按着头改了三天,最大的收获不是解决了死锁,而是明白了一个道理:不要迷信自己代码里的"先查后插"。在并发场景下,你眼里的一行 select 在数据库里可能是一把范围不小的锁。MySQL 的锁模型比我想象的复杂得多,RR 级别下间隙锁、插入意向锁、临键锁(Next-Key Lock)搅在一起,稍不注意就翻车。
如果让我给还在同事一句忠告:写完业务逻辑,一定要压测。JMeter 丢上去跑十分钟,很多问题你本地单点调试一辈子都发现不了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。