凌晨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扫码:
开启全局事务 锁住move_plan.id = 12345这一整行 更新actual_qty(+1) 等事务提交才释放锁
作业人员B扫码:
开启全局事务 想锁move_plan.id = 12345 发现被A占了 开始等... 等啊等... 超时爆炸
作业人员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点的客户电话,会教你做人。
夜雨聆风