首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >三天改了八版,老板终于满意了——记毕设系统从"能用"到"能并发"的打脸实录

三天改了八版,老板终于满意了——记毕设系统从"能用"到"能并发"的打脸实录

原创
作者头像
七条猫
发布2026-08-08 16:32:41
发布2026-08-08 16:32:41
420
举报

上月,本来想着 SpringBoot + MyBatis-Plus + MySQL 这套"老三位"稳稳当当,能跑就行。结果老板上周突然丢了句:"你这系统只能你自己用,上线就炸。"然后丢过来一个 JMeter 脚本,50 线程循环跑,10 分钟内必报 DeadlockFoundException。我熬了两个通宵翻日志,总算把 MySQL 的间隙锁(Gap Lock)这玩意整明白了。记录一下,纯当给自己留个档,也给后来人避个坑。


一、案发经过:压测 10 分钟,必炸

我做的是个二手交易平台,核心链路很简单:查库存 → 扣库存 → 生成订单。为了避免用户重复提交,我在下单前先查了一遍订单表:

代码语言:java
复制
@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:

代码语言:txt
复制
<select id="selectByUserAndGoods" resultType="Order">
    select * from orders 
    where user_id = #{userId} 
      and goods_id = #{goodsId} 
      and status = 0
</select>

本地单点调试,完美。老板用 JMeter 上 50 个线程,每个线程模拟同一个用户狂点下单,10 分钟之内控制台一片红。报错信息就长这样:

代码语言:txt
复制
### 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 的死锁日志。在命令行里执行:

代码语言:sql
复制
show engine innodb status;

往下拉到 LATEST DETECTED DEADLOCK 那一段,核心信息我截出来了:

代码语言:txt
复制
------------------------
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 这行字看了十分钟。gapinsert intention?这俩词组合在一起,我第一次见。

后来翻了官方文档才知道,MySQL 默认的 RR(Repeatable Read) 隔离级别下,如果查询条件没命中记录,InnoDB 为了防止幻读,会加一个间隙锁(Gap Lock)。两个事务都拿到了同一个间隙的共享锁,互不冲突;但它们接下来都要 insertinsert 需要的是插入意向锁(Insert Intention Lock),而插入意向锁和间隙锁是互斥的。于是你等我、我等你,闭环了,死锁。

我用 Mermaid 画了个时序图,直观感受一下:

代码语言:txt
复制
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 + 加索引

锁等待严重,吞吐量暴跌

⭐⭐⭐

唯一索引 + 数据库去重

把防重逻辑交给数据库唯一索引

加索引 + 改代码

需要业务上定义好"什么算重复"

⭐⭐⭐⭐

缩小事务粒度 + 状态机

先插订单(带初始状态),再异步扣库存

业务逻辑大改

架构复杂,搞不赢

⭐⭐

我最后选的方案是方案三:唯一索引兜底。因为在我的业务里,一个用户对同一商品只能存在一个"待支付"订单是合理约束。于是我做了两件事:

1. 加唯一索引

代码语言:sql
复制
ALTER TABLE orders ADD UNIQUE INDEX uk_user_goods (user_id, goods_id);

(注意:这里我把 status 从唯一索引里拿掉了,因为 status 会变。如果业务要求"只要用户买过这个商品就不能再下单",那直接锁 user_id + goods_id 就行;如果允许完成后再买,那就需要在应用层判断 status,但死锁问题可以通过下面的代码调整规避。)

2. 改代码:不再先 select 检查,而是直接 insert,捕获冲突

代码语言:java
复制
@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 也顺手改成了乐观锁,防止超卖:

代码语言:txt
复制
<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_idgoods_idstatus 各自建了单列索引。MySQL 选执行计划时有时候会走错,导致锁范围变大。我删了多余的单列索引,只保留了 (user_id, goods_id) 的联合唯一索引和一个 status 的普通索引用于后台查询。 Explain 一看,typeindex_merge 变成了 const,舒服了。

2. 事务粒度缩短

之前我把"查询商品详情、扣库存、插订单、写操作日志"全包在一个大 @Transactional 里。现在我把非核心操作(比如写日志、发站内信)扔到事务外面,用 Spring 的 TransactionEventListener 或者干脆异步线程去做。事务变短了,锁持有时间也短了,并发一上去,吞吐量明显好了不少。


五、总结

这次被老板按着头改了三天,最大的收获不是解决了死锁,而是明白了一个道理:不要迷信自己代码里的"先查后插"。在并发场景下,你眼里的一行 select 在数据库里可能是一把范围不小的锁。MySQL 的锁模型比我想象的复杂得多,RR 级别下间隙锁、插入意向锁、临键锁(Next-Key Lock)搅在一起,稍不注意就翻车。

如果让我给还在同事一句忠告:写完业务逻辑,一定要压测。JMeter 丢上去跑十分钟,很多问题你本地单点调试一辈子都发现不了。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、案发经过:压测 10 分钟,必炸
  • 二、现场勘查:从日志里抠真相
  • 三、定方案:不是改隔离级别,而是改思路
    • 1. 加唯一索引
    • 2. 改代码:不再先 select 检查,而是直接 insert,捕获冲突
  • 四、顺手做的其他优化
  • 五、总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档