乐于分享
好东西不私藏

凌晨3点,客户电话把我吵醒,移库APP崩了

凌晨3点,客户电话把我吵醒,移库APP崩了

凌晨3点,客户电话把我吵醒,移库APP崩了

上周三凌晨3点,手机响了。

客户现场负责人语气很急:"移库APP彻底没法用了!扫码之后一直转圈,作业人员都在等着,你们赶紧看看!"

挂了电话,爬起来开电脑,查日志。

满屏的红色:

io.seata.rm.datasource.exec.LockConflictException: 
get global lock fail, xid:10.0.5.45:8091:63730685467898195

Seata锁超时。盯着屏幕想了十分钟,一个简简单单的移库操作,怎么就把分布式事务搞崩了?


罪魁祸首:移库计划表

我们的仓储系统有个移库功能:作业人员扫码,把货物从A库位移到B库位。

移库计划表大概长这样:

CREATE TABLE move_plan (
    id BIGINT PRIMARY KEY,
    plan_no VARCHAR(32),          -- 计划单号
    from_location VARCHAR(32),    -- 来源库位
    to_location VARCHAR(32),      -- 目标库位
    plan_qty INT,                 -- 计划数量
    actual_qty INT DEFAULT 0,     -- 实际作业数量
    status INT,                   -- 状态
    update_time DATETIME
);

关键字段是actual_qty。每次作业人员完成一次移库,这个字段就要+1。

代码很简单:

@GlobalTransactional
public void completeMove(Long planId, String barcode) {
    // 1. 校验条码
    validateBarcode(barcode);
    
    // 2. 更新移库计划表
    MovePlan plan = movePlanService.getById(planId);
    plan.setActualQty(plan.getActualQty() + 1);  // 实际数量+1
    movePlanService.updateById(plan);
    
    // 3. 记录移库明细
    moveDetailService.save(planId, barcode);
    
    // 4. 更新库存
    inventoryService.move(plan.getFromLocation(), plan.getToLocation(), barcode);
}

一个移库计划单,多个作业人员同时作业,各自扫码完成移库。

看起来没问题,对吧?


真相:20个人同时扫同一张单

客户现场是24小时作业。凌晨3点,夜班人员全部到位,20多个人同时开工。

他们扫的是同一张移库计划单。

这张单计划移库1000件货,20个人同时作业,几乎同时扫码,同时调用completeMove方法。

Seata的处理流程:

作业人员A扫码:

  1. 开启全局事务
  2. 锁住move_plan.id = 12345这一整行
  3. 更新actual_qty(+1)
  4. 等事务提交才释放锁

作业人员B扫码:

  1. 开启全局事务
  2. 想锁move_plan.id = 12345
  3. 发现被A占了
  4. 开始等...
  5. 等啊等...
  6. 超时爆炸

作业人员C、D、E...全部超时。


Seata:我锁的是整行,不管你改几个字段

Seata AT模式的真相:行级锁,锁整行,不管你改几个字段。

A和B明明只是各自给actual_qty+1,但在Seata眼里,你们都是在抢同一行数据。

字段级别的并发?不存在的。


凌晨3点半:先让它跑起来

客户现场20个人等着,不能干等。先治标。

调大Seata锁等待时间:

seata:
  client:
    rm:
      lock:
        retry-times: 300      # 重试300次
        retry-interval: 100   # 每次等100ms

最多等30秒。

重启后,APP能用了,但扫码后要转圈很久。作业人员抱怨卡顿,但至少能干活了。

心里清楚,这只是把炸弹的引线拉长了。


白天:根治方案

第二天白天,团队讨论后决定:去掉冗余字段,改查子表。

问题根源是move_plan表里的actual_qty字段。20个人同时更新这个字段,必然冲突。

解决方案:主表去掉actual_qty,需要时实时查子表统计。

表结构调整

-- 移库计划表去掉actual_qty
CREATE TABLE move_plan (
    id BIGINT PRIMARY KEY,
    plan_no VARCHAR(32),
    from_location VARCHAR(32),
    to_location VARCHAR(32),
    plan_qty INT,
    status INT,
    update_time DATETIME
);

-- 移库明细表(原本就有,用来记录每次扫码)
CREATE TABLE move_detail (
    id BIGINT PRIMARY KEY,
    plan_id BIGINT,
    barcode VARCHAR(32),
    operator VARCHAR(32),
    create_time DATETIME
);

改造后代码

完成移库时,不再更新主表字段,只插入明细:

@GlobalTransactional
public void completeMove(Long planId, String barcode) {
    validateBarcode(barcode);
    
    // 不再更新move_plan表!
    // 只插入移库明细
    moveDetailService.save(planId, barcode);
    
    // 更新库存
    inventoryService.move(from, to, barcode);
}

每个人扫码时,插入的是move_detail表的不同行,互不冲突。

查询实际数量时统计

需要显示actual_qty时,实时查子表:

public int getActualQty(Long planId) {
    // SELECT COUNT(*) FROM move_detail WHERE plan_id = ?
    return moveDetailService.countByPlanId(planId);
}

查询不走Seata全局事务,只是普通查询,无锁冲突。

20个人同时作业?没问题,各插各的明细行,查询时实时统计。


效果

上线后观察一周:

  • 锁超时告警归零
  • 扫码响应从平均8秒降到200ms
  • 客户现场再也没打过凌晨3点的电话

其他备选方案

如果拆表改动太大,还有几个备选:

方案二:乐观锁+本地事务(如果不拆表)

如果必须保留actual_qty字段,更新时走本地事务+乐观锁:

@GlobalTransactional
public void completeMove(Long planId, String barcode) {
    validateBarcode(barcode);
    moveDetailService.save(planId, barcode);
    inventoryService.move(from, to, barcode);
    
    // 数量更新走本地事务(无全局锁)
    incrementActualQty(planId);
}

@Transactional
public void incrementActualQty(Long planId) {
    actualMapper.increment(planId);
}

优点:改动小。

缺点:actual_qty不在分布式事务保护内,如果回滚,数量不会回滚。

方案三:Redis计数器(如果不拆表)

如果保留actual_qty字段,但用Redis缓存:

@GlobalTransactional
public void completeMove(Long planId, String barcode) {
    validateBarcode(barcode);
    moveDetailService.save(planId, barcode);
    inventoryService.move(from, to, barcode);
    
    redisTemplate.opsForHash().increment("move:actual:" + planId, "qty", 1);
}

优点:性能最好。

缺点:Redis和数据库可能不一致。


复盘:三个教训

教训一:对Seata锁机制理解太浅

以为锁的是更新的字段,实际是锁整行。设计表结构时完全没考虑多人同时作业的场景。

教训二:现场并发预估不足

测试时都是单个人操作,没模拟20个人同时扫同一张单的场景。上线后才发现问题。

教训三:高频更新字段不该冗余存储

actual_qty这种每扫一次码就要+1的字段,应该在主表去掉,需要时实时查子表统计。冗余字段+高频更新=锁冲突。


一句话总结

Seata的锁是行级的,这是设计决定的,改变不了。

我们能做的,就是别让多个人去抢同一行。

要么拆表,要么用乐观锁,要么用Redis。总之,别让Seata的锁成为瓶颈。

否则,凌晨3点的客户电话,会教你做人。

相关学习资料