入库出库链路重构:从本地事务到 Saga 分布式事务
副标题:一条出库单要走 6 个服务,事务怎么保证?
1. 问题引入:出库单创建到发货,中间崩了怎么办
最近咱们团队在重构 WMS(仓储管理系统)的出库链路,说实话,这事儿让我掉了不少头发。
事情是这样的:以前系统是个"大单体",创建出库单、扣库存、生成拣货任务、复核、发货……所有逻辑都在一个服务里,一个 @Transactional 注解往方法头上一拍,Spring 帮你把事务管得明明白白。出了问题?回滚就完事儿了,简单粗暴,相当安逸。
但安逸的日子总是短暂的。随着业务扩张,库存、拣货、复核、物流、财务……各个模块都拆成了独立服务。这时候问题来了——
用户下了一张出库单,系统开始干活:
- 订单服务:创建出库单
- 库存服务:预占库存
- 拣货服务:生成拣货任务
- 复核服务:扫描复核
- 发货服务:通知物流发货
- 库存服务:正式扣减库存
这 6 个步骤,跨越了 5 个服务。假设第 4 步复核服务突然挂了,前面库存已经占了,拣货任务也生成了,后面怎么办?回滚?可 @Transactional 管不到别的服务啊!
更惨的是现实里真出过这种事儿:某天夜里库存服务成功扣了 2000 件货,结果拣货服务因为数据库连接池打满没生成任务。第二天仓库大哥一脸懵——“系统显示库存少了,可我这边没任务啊,货发还是不发?”
说白了,微服务时代,长事务怎么拆?分布式事务怎么选? 这就是咱们今天要聊的核心问题。
2. 分布式事务方案对比
遇到这个问题,我第一反应是:业界不是有好几套分布式事务方案吗?挑一个合适的就行。但真去研究才发现,没有银弹,每个方案都有它的脾气。
2.1 2PC:强一致,但太"重"了
两阶段提交(2PC)大家应该都听过,Prepare + Commit,讲究一个"要么全成功,要么全回滚",数据强一致。
但问题也很明显:
- 性能差:整个过程要锁定资源,其他请求得排队等着
- 单点风险:协调者挂了,所有参与者都得干等着
- 不适合高并发:WMS 这种业务,高峰期一秒几百单,2PC 能把数据库锁哭
我一开始也想过要不要用 Seata 的 AT 模式,毕竟它对业务侵入小,用起来也顺手。但仔细一研究,AT 模式底层其实也是基于 2PC 的思路,虽然做了不少优化,比如异步提交、全局锁细化,但本质上还是有全局事务协调的开销。对于咱们这种一步走错就要回滚五六步的长链路来说,风险收益比不太划算。
咱们这出库链路虽然重要,但也不是金融转账那种"差一分钱都不行"的场景。为了强一致牺牲性能和可用性?不值当。
2.2 TCC:性能好,但业务侵入大
TCC(Try-Confirm-Cancel)思路不错,先预留资源,再确认或取消。性能比 2PC 好得多,不锁全局资源。
但缺点也很扎心:
- 每个接口要拆成 Try/Confirm/Cancel 三个
- 业务代码改造成本巨大
- Confirm/Cancel 还要保证幂等,脑子嗡嗡的
我算了算,咱们出库链路 6 个步骤,每个步骤都要写 TCC 三套逻辑。比如"预占库存"这个操作,Try 阶段要"冻结库存",Confirm 阶段要"把冻结转成正式扣减",Cancel 阶段要"解冻库存"。听起来清晰,但落地的时候你会发现:
- 库存表结构得改,加冻结数量字段
- 原来的业务代码全得重构,调用方也要改
- 空回滚、幂等、悬挂这三个 TCC 经典坑,一个都不能少
团队至少得折腾一个月。工期不允许,pass。
2.3 Saga:长事务友好,流程型业务的菜
Saga 模式的思路特别简单:把长事务拆成一连串本地事务,每个本地事务执行完就提交。如果中间某步失败了,就按相反顺序执行补偿操作,把前面的改动"抹回去"。
我第一次看到 Saga 的时候,脑子里冒出的念头是:这不就是"手动回滚"嘛?但真用到业务里才发现,它简直太适合 WMS 了:
- 业务流程本身就是按步骤走的,一步接一步,天然好拆分
- 每一步都有明确的"反向操作"(占库存 ↔ 释放库存,生成任务 ↔ 取消任务)
- 不需要全局锁,性能影响小
- 业务侵入度比 TCC 低很多,现有接口稍微改造一下就能接入
而且 Saga 有两种实现方式:编排式(Choreography)和协调式(Orchestration)。编排式是每个服务做完自己的事,发消息通知下一个服务;协调式是有一个中央编排器,统一调度所有步骤。咱们选的是协调式,因为出库链路步骤多、顺序固定,有个"总指挥"看着更踏实,出问题也更好排查。
2.4 本地消息表:简单可靠,但实现繁琐
本地消息表的思路是:在业务库旁边建个消息表,业务操作和消息写入用本地事务保证一致。然后有个定时任务去扫表,把消息发给下游服务。
这个方案简单、可靠、不依赖外部中间件,但:
- 要自己写消息表、定时任务、消费逻辑
- 异常处理、重试、死信队列都得自己管
- 链路 visibility(可观测性)比较差,出问题不好排查
2.5 我们的选择:Saga + 本地消息表混合使用
最后咱们定的方案是:主链路用 Saga 编排,关键步骤的异步通知用本地消息表兜底。
为啥这么搭?
- Saga 负责把 6 个步骤串起来,正向执行 + 反向补偿,逻辑清晰
- 本地消息表负责处理一些"不需要实时回滚但必须可靠送达"的通知,比如发货成功后给财务系统发消息
- 两者互补,既保证了事务一致性,又不过分复杂
说白了,选方案不是选最牛的,是选最适合自己团队和业务的。
3. Saga 模式在出库链路中的应用
好,方案定了,咱们看看 Saga 在出库链路里具体怎么落地。
3.1 出库流程拆解
一条出库单的生命周期,咱们拆成 6 个步骤:
| 步骤 | 正向操作 | 补偿操作 |
|---|---|---|
| 1 | 创建出库单 | 取消出库单 |
| 2 | 预占库存 | 释放库存 |
| 3 | 生成拣货任务 | 取消拣货任务 |
| 4 | 复核通过 | 复核回退 |
| 5 | 通知发货 | 取消发货 |
| 6 | 正式扣减库存 | 回滚库存扣减 |
注意,不是每一步都会真的触发补偿。比如复核通过了,如果发货失败,理论上要回退复核状态。但实际业务中,复核回退可能涉及扫描数据撤销、PDA 设备状态同步,甚至已经打印的面单也要作废,非常复杂。所以咱们在方案评审的时候定了一个原则:补偿操作的设计一定要结合业务实际,能简单回滚的就回滚,实在复杂的可以走人工介入或异常工单流程。不能为了技术完美而硬搞,否则补偿逻辑比正向逻辑还复杂,得不偿失。
3.2 Saga 编排器伪代码
下面这段是咱们 Saga 编排器的核心思路,用伪代码展示:
// Saga 编排器:定义出库单的正向步骤和补偿步骤
class OutboundSaga {
List<SagaStep> steps = Arrays.asList(
new SagaStep("创建出库单", this::createOrder, this::cancelOrder),
new SagaStep("预占库存", this::occupyStock, this::releaseStock),
new SagaStep("生成拣货任务", this::createPickTask, this::cancelPickTask),
new SagaStep("复核通过", this::confirmCheck, this::rollbackCheck),
new SagaStep("通知发货", this::notifyShip, this::cancelShip),
new SagaStep("扣减库存", this::deductStock, this::rollbackDeduct)
);
// 执行 Saga
public void execute(String orderNo) {
int currentStep = 0;
try {
for (; currentStep < steps.size(); currentStep++) {
SagaStep step = steps.get(currentStep);
// 关键:每步执行前记录状态,方便异常恢复
sagaLog.save(orderNo, currentStep, "EXECUTING");
step.getAction().execute(orderNo);
sagaLog.save(orderNo, currentStep, "SUCCESS");
}
} catch (Exception e) {
// 某步失败了,从当前步骤开始反向补偿
log.error("步骤 {} 执行失败,开始补偿", currentStep);
compensate(orderNo, currentStep - 1);
throw new SagaException("出库 Saga 执行失败", e);
}
}
// 补偿逻辑:从失败前一步开始,倒序执行补偿
private void compensate(String orderNo, int lastSuccessStep) {
for (int i = lastSuccessStep; i >= 0; i--) {
SagaStep step = steps.get(i);
sagaLog.save(orderNo, i, "COMPENSATING");
try {
step.getCompensation().execute(orderNo);
sagaLog.save(orderNo, i, "COMPENSATED");
} catch (Exception ex) {
// 补偿也失败了,记录告警,需要人工介入
sagaLog.save(orderNo, i, "COMPENSATE_FAILED");
alertService.send("Saga 补偿失败,单号:" + orderNo + ",步骤:" + i);
throw ex; // 中断后续补偿,避免状态更乱
}
}
}
}
关键点在哪?
sagaLog是个持久化日志,记录每个步骤的状态。这是 Saga 的"黑匣子",编排器挂了也能靠它恢复- 正向操作每成功一步就提交,不锁全局事务
- 补偿是倒序执行的,先补偿最后成功的步骤,一步步往回滚
- 补偿也可能失败(比如下游服务也挂了),这时候要发告警,不能默默吞掉异常
3.3 单个步骤的伪代码
以"预占库存"为例,看看正向和补偿怎么写:
// 正向:预占库存
void occupyStock(String orderNo) {
// 调用库存服务 RPC 接口
stockService.occupy(orderNo, skuList);
}
// 补偿:释放库存
void releaseStock(String orderNo) {
// 调用库存服务释放接口
stockService.release(orderNo);
}
是不是比 TCC 简单多了?不用写 Try/Confirm/Cancel 三套,正向和补偿各一个方法就行。当然,简单的前提是每个服务的接口本身要支持幂等,这个咱们下一节专门讲。
4. 幂等设计:分布式事务的保命符
说到幂等,这可是分布式系统的生命线。Saga 模式里,幂等更是重中之重。
4.1 为什么 Saga 步骤必须幂等
你想啊,Saga 编排器调用库存服务"预占库存",网络超时了。编排器不知道到底成功没成功,只能重试。如果库存服务的"预占"接口不幂等,重试一次就扣两次库存,那仓库大哥得拿刀来找你了。
再比如补偿阶段,释放库存的接口如果被调了两次,结果把别人的库存也释放了,这谁顶得住?
还有一个更隐蔽的场景:Saga 恢复任务扫描到一笔未完成的记录,它不确定某步到底执行成功没,于是决定"补偿一下保险"。如果补偿接口不幂等,就可能出现"正向其实成功了,补偿又执行了一次"的双向灾难。
所以,Saga 的每一步——正向操作和补偿操作,都必须幂等。这不是可选项,是保命符。
4.2 幂等实现:业务单号 + 操作类型 + 状态机
咱们的幂等设计比较简单粗暴但有效:
- 幂等键 =
业务单号:操作类型(比如SO202404140001:OCCUPY_STOCK) - 每个服务维护一张幂等表,记录这个操作是否执行过、执行结果是什么
- 接口进来先查幂等表,执行过了直接返回上次结果
这里有个小技巧:幂等键里一定要包含"操作类型"。如果你只用业务单号做幂等键,那"预占库存"和"释放库存"就会互相冲突——同一个单号,两种完全不同的操作,不能混为一谈。
看看幂等拦截器的伪代码:
// 幂等拦截器:在 RPC 接口入口统一处理
@Around("@annotation(Idempotent)")
public Object idempotentIntercept(ProceedingJoinPoint point) {
// 从请求参数或 Header 里取幂等键
String idempotentKey = extractKey(point);
// 1. 先查幂等表
IdempotentRecord record = idempotentDao.selectByKey(idempotentKey);
if (record != null) {
if (record.getStatus() == "SUCCESS") {
// 执行过了,直接返回缓存结果
return record.getResult();
}
if (record.getStatus() == "PROCESSING") {
// 正在执行,可能是并发重入,根据业务决定是等待还是抛异常
throw new DuplicateRequestException("请求处理中,请稍后");
}
}
// 2. 没执行过,插入 PROCESSING 记录
idempotentDao.insert(idempotentKey, "PROCESSING");
try {
// 3. 执行业务逻辑
Object result = point.proceed();
// 4. 成功,更新为 SUCCESS 并缓存结果
idempotentDao.update(idempotentKey, "SUCCESS", serialize(result));
return result;
} catch (Exception e) {
// 5. 失败,删除或标记为 FAILED,允许下次重试
idempotentDao.delete(idempotentKey);
throw e;
}
}
关键点在哪?
PROCESSING状态是为了防并发:两个相同请求同时进来,只有一个能执行业务逻辑- 成功了缓存结果,下次直接返回,保证幂等
- 失败了清掉记录,让重试有机会重新执行
4.3 状态机辅助幂等
除了幂等拦截器,业务表本身也可以加个状态机。比如出库单表有个 status 字段:
CREATED -> OCCUPIED -> PICKED -> CHECKED -> SHIPPED -> DEDUCTED
每次操作前先校验状态,"预占库存"接口收到请求时,发现出库单已经是 OCCUPIED 状态,直接返回成功。这是业务层面的幂等,和拦截器双保险。
5. 异常场景演练
纸上得来终觉浅,咱们来演练几个真实会踩坑的场景。
场景 1:占库存成功,生成拣货任务失败 → 释放库存
这是 Saga 最经典的补偿场景。
步骤 1 创建出库单:成功
步骤 2 预占库存:成功
步骤 3 生成拣货任务:失败(拣货服务数据库挂了)
Saga 编排器捕获异常后,从步骤 2 开始倒序补偿:
// 补偿流程
cancelPickTask(orderNo); // 步骤 3 还没成功,跳过或空操作
releaseStock(orderNo); // 步骤 2 补偿:释放库存
cancelOrder(orderNo); // 步骤 1 补偿:取消出库单
最终状态:出库单取消,库存释放,数据一致。用户看到的就是"下单失败,请重试"。
这里有个细节要注意:cancelPickTask 在步骤 3 失败时其实还没执行过,但咱们的补偿逻辑是统一遍历的。所以 cancelPickTask 本身也要做成幂等:如果查不到这个单号的拣货任务,直接返回成功,不要抛异常。否则 Saga 编排器会以为补偿失败,整个链路就卡住了。
场景 2:发货成功,扣减库存重复调用 → 幂等返回成功
假设步骤 5 "通知发货"成功了,步骤 6 "扣减库存"第一次调用时网络超时,Saga 编排器重试第二次。
// 第一次调用:网络超时,实际库存服务已经扣成功了
stockService.deduct(orderNo); // -> 实际成功,但响应丢失
// Saga 重试第二次调用
stockService.deduct(orderNo); // -> 幂等拦截器拦截,返回上次成功结果
库存服务的幂等表里有记录:SO202404140001:DEDUCT_STOCK = SUCCESS,所以第二次直接返回成功,不会重复扣减。
场景 3:Saga 编排器挂了 → 恢复机制怎么设计
这是最吓人的场景。假设 Saga 执行到步骤 4,编排器自己所在的服务突然重启了。等它恢复后,怎么知道该继续执行还是该补偿?
答案就是前面提到的 Saga 日志(sagaLog)。
// Saga 恢复任务:定时扫描未完成的 Saga 记录
class SagaRecoveryJob {
@Scheduled(fixedDelay = 30000)
public void recover() {
List<SagaLog> unfinished = sagaLog.selectUnfinished();
for (SagaLog log : unfinished) {
String orderNo = log.getOrderNo();
int lastStep = log.getLastStep();
String status = log.getStatus();
if (status == "EXECUTING") {
// 上次正在执行某步时挂了,不确定那步到底成功没
// 策略:先查下游服务状态,或者直接补偿重来
if (checkStepSuccess(orderNo, lastStep)) {
// 实际成功了,从下一步继续
sagaExecutor.resume(orderNo, lastStep + 1);
} else {
// 实际没成功,从当前步开始补偿
sagaExecutor.compensate(orderNo, lastStep - 1);
}
}
if (status == "COMPENSATING") {
// 上次补偿到一半挂了,继续补偿
sagaExecutor.continueCompensate(orderNo, lastStep);
}
}
}
}
关键点在哪?
- 没有日志,编排器挂了就是"死无对证"
EXECUTING状态是最麻烦的,因为不知道那步到底执行成功没。所以每个下游服务最好提供"查询操作状态"的接口,方便恢复时确认- 如果查不了状态,保守策略是直接补偿,宁可多回滚一次,也不要留下不一致数据
6. 验证与总结
6.1 改造前后的异常恢复能力对比
咱们来直观对比一下:
| 场景 | 改造前(单体 @Transactional) | 改造后(Saga + 幂等) |
|---|---|---|
| 库存服务成功,拣货服务失败 | 无法回滚其他服务,数据不一致 | 自动触发补偿,释放库存 |
| 网络超时导致重复调用 | 可能重复扣减库存 | 幂等拦截,返回缓存结果 |
| 服务宕机 | 事务悬而未决,需要人工介入 | Saga 恢复任务自动处理 |
| 新增业务步骤 | 改大事务,风险高 | 在 Saga 编排器里加一步就行 |
从"人工救火"到"自动恢复",这就是重构的价值。
6.2 一个血的教训
最后分享一个真事儿,希望大家引以为戒。
Saga 上线后的第一周,一切正常。第二周某天,仓库反馈"有批货明明没发出去,系统却显示库存被占了"。一查日志,发现是一笔出库单在"预占库存"后,“生成拣货任务"失败了,Saga 也触发了补偿。但补偿逻辑里,releaseStock 写漏了一个分支——当库存服务返回"库存已释放"时,补偿方法直接抛了个异常说"释放失败”。
结果呢?Saga 编排器以为补偿没成功,就反复重试。重试了几次后触发了告警,人工介入一看,库存其实早释放了,但出库单状态因为补偿中断还卡在 CREATED。更坑的是,那张出库单后来被另一个定时任务重新捞起来,又走了一遍预占库存……
幽灵库存就是这么来的。
那天晚上我盯着日志看了三个小时,最后发现问题出在不到十行代码的分支遗漏上。你说气人不气人?
教训是什么?
- 补偿逻辑必须和正向逻辑一样认真写,不能因为是"回滚"就敷衍
- 补偿方法也要幂等,下游返回"已处理"要当成成功,不能抛异常
- 上线前一定要把每个步骤的补偿场景跑一遍,不能只在脑子里过
- 异常告警要配全,但更重要的是告警来了要有人看、有人跟
写在最后
从单体到微服务,分布式事务是绕不开的坎。2PC 太重、TCC 太繁,Saga 对于 WMS 这种流程型业务来说,是个相当务实的选择。再配合好幂等设计和恢复机制,基本能覆盖大部分异常场景。
当然,Saga 也不是万能的。它保证的是最终一致性,不是实时强一致。如果你的业务是转账、对账那种"一分都不能差"的场景,可能还是得考虑更严格的方案。
你在项目中是怎么处理分布式事务的?用过 Saga 吗?有没有踩过什么印象深刻的坑?欢迎在评论区交流,咱们一起唠唠!

209

被折叠的 条评论
为什么被折叠?



