分布式事务实战:Seata AT 模式在订单履约链路中的应用
保证即使 finally 块中的 调用失败(网络抖动、OOM 等),定时任务也能兜底回推。 Seata AT 模式的一阶段是**直接提交本地事务**,全局事务的提交/回滚由 TC 协调。这意味着: 在 T1~T3 之间,其他请求查询包裹可能
1. 背景与痛点
业务背景
场景一:订单履约链路跨服务调用
- fulfillment(订单履约)创建出库单时,需要调用 inventory(库存服务)扣减逻辑库存
- 包裹取消/退回时,需要同步通知 inventory 恢复库存
- 派单成功后,需要将状态变更推送给 OMS(订单管理系统)
- 涉及 fulfillment → inventory → WMS → OMS 多系统协同
场景二:调拨单拼单跨服务
- 调拨单拼单需要创建新调拨单 + 创建新出库单 + 标记旧单据取消
- allocation 与 inventory 服务必须保证原子性
技术痛点
痛点一:传统消息队列方案的运维成本
- 生产者需要维护事件记录表 + 定时任务扫表发送消息
- 消费者需要维护消费记录表 + 手动处理幂等
- 运维成本高,定时任务增加系统复杂度
痛点二:Seata AT 模式的脏读问题
- Seata 全局事务工作在读未提交隔离级别,本地事务先提交再全局提交
- 在全局事务 T1 提交后、T2 提交前,其他请求可能读到中间状态
- 调拨单拼单场景:新调拨单 A003 在 T1 后 T2 前被其他请求提交到仓库,导致数据不一致
痛点三:派单结果与状态变更的顺序问题
- 派单结果推送 OMS 和状态变更推送 OMS 是两个独立任务
- 概率性出现状态变更先于派单结果到达 OMS,派单结果被忽略
- 需要保证"先写落地表 → 派单 → 发 Kafka"的严格顺序
痛点四:Kafka 消息乱序到达
- inventory 推送的出库单状态变更消息可能因网络原因乱序
- 先收到"已取消"再收到"仓库处理中",导致状态回退
2. 技术方案
整体架构
┌─────────────────────────────────────────────────────────────────────────────┐
│ fulfillment (TM 发起者) │
│ │
│ @GlobalTransactional 方法(11处) │
│ ├─ saveAllocatePack() ← 自动分仓配货 │
│ ├─ doCancelPackage() ← 包裹取消 │
│ ├─ doReturnPackage() ← 包裹退回 │
│ ├─ doPackageDispatch() ← 派单 │
│ ├─ modifyPackageTransport() ← 修改包裹运输信息 │
│ ├─ modifyPackageShip() ← 修改收货人地址 │
│ ├─ batchModifyChannel() ← 批量修改物流渠道 │
│ └─ saveCancelOrderPackage() ← 订单取消联动包裹 │
│ │
│ Redis 分布式锁:每包裹粒度锁 │
│ ├─ CANCEL_PACKAGE_KEY + packageNumber │
│ ├─ RETURN_PACKAGE_KEY + packageNumber │
│ ├─ DISPATCH_PACKAGE_KEY + packageNumber │
│ └─ ALLOCATE_WAREHOUSE_LOGISTICS_KEY + packageNumber │
└─────────────────────────────────────────────────────────────────────────────┘
│ Feign (OutboundRemoteService)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ inventory (RM 参与者) │
│ │
│ @GlobalTransactional 方法(7处) │
│ ├─ handleCancelResult() ← 处理出库取消结果 │
│ ├─ executeHandleFulfillmentCartonTransported() ← 箱运处理 │
│ ├─ handleShipmentCartonTransported() ← 履约箱运处理 │
│ └─ createInboundAndCommit() ← 创建入库单并提交 │
│ │
│ @GlobalLock + select for update │
│ └─ queryOrderPushRecord() ← 解决 OMS 批量派单脏读问题 │
│ │
│ 落地表:order_push_record(派单记录落地) │
│ 状态流转:NOT_PUSH → PUSHED → push_oms=1 │
└─────────────────────────────────────────────────────────────────────────────┘
│ Kafka
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ OMS(订单管理系统) │
│ │
│ Kafka 消费者: │
│ ├─ outbound_order_status ← 出库单状态变更 │
│ └─ auto_receive ← WMS 自动收货通知 │
│ │
│ @KafkaListener(max.poll.records=1) ← 保证顺序处理 │
└─────────────────────────────────────────────────────────────────────────────┘
Seata AT 模式工作原理
[TM] @GlobalTransactional 开启全局事务 → 生成 XID
│
├─ [RM1] fulfillment 本地操作 → Seata DataSourceProxy 拦截
│ ├─ 执行 SQL 前:生成 before-image
│ ├─ 执行 SQL 后:生成 after-image
│ └─ 写入 undo_log (xid + branch_id + rollback_info)
│
├─ [RM2] inventory 远程 Feign 调用 → 传递 XID → Seata DataSourceProxy 拦截
│ ├─ 执行 SQL 前:生成 before-image
│ ├─ 执行 SQL 后:生成 after-image
│ └─ 写入 undo_log
│
├─ 全部成功 → TC 通知各 RM 异步删除 undo_log(一阶段提交即完成)
└─ 任一失败 → TC 通知各 RM 根据 undo_log 反向补偿(二阶段回滚)
3. 实现细节
模块一:Seata 集成配置
seata:
enabled: true
application-id: fulfillment-service # 必须与 spring.application.name 一致
tx-service-group: creeks_tx_group # 所有微服务使用同一事务组
enable-auto-data-source-proxy: true # 自动代理数据源(AT 模式核心)
service:
vgroup-mapping:
creeks_tx_group: default # 事务组映射到服务器组
grouplist:
default: ${SEATA_SERVICE:seata:8091} # Seata Server 地址
client:
rm:
lock:
retry-policy-branch-rollback-on-conflict: false # 锁冲突时快速失败
retry-interval: 1000 # 重试间隔 1 秒
feign:
enabled: false # 避免与 Sleuth 自动配置冲突
生产环境高可用配置(通过 Zookeeper 注册中心):
# application-prod.yml
seata:
registry:
type: zk
zk:
server-addr: ${ZK_ADDR:zookeeper:2181}
模块二:包裹取消的分布式事务
// fulfillment 侧:TM 发起者
public Map<String, Object> doCancelPackage(List<Package> packages) {
// 1. 加 Redis 分布式锁
List<String> keys = packages.stream()
.map(p -> RedisConstants.CANCEL_PACKAGE_KEY + p.getPackageNumber())
.collect(Collectors.toList());
return lockService.lockAndGetResult(keys, 60, -1, TimeUnit.SECONDS,
() -> SpringUtils.getBean(PackageServiceImpl.class).doCancelPackage(...));
}
@GlobalTransactional(rollbackFor = Exception.class, timeoutMills = 600000)
public Map<String, Object> doCancelPackage(List<Package> packages) {
// 2. 前置校验:检查是否已有取消标签(防止重复取消)
List<Package> checkPackages = checkWaitReturnOrCancel(packages, "cancel");
for (Package pkg : checkPackages) {
// 3. 本地操作
updatePackageStatus(pkg, "CANCELLED");
cancelPackageUpdateOrderStatus(pkg);
labelService.saveBatch(new LabelPO(pkg.getNumber(), "WAITCANCEL"));
logService.saveBatch(new LogPO(pkg.getNumber(), "包裹取消"));
// 4. 远程调用 inventory 恢复库存(通过 XID 传播)
inventoryCancelOutbound(pkg); // → outboundRemoteService.pushOrders(PushType.APPLY_CANCEL)
}
// 5. 全部成功 → Seata 自动提交;任一失败 → Seata 自动回滚所有 RM
}
关键机制:
OutboundRemoteService是 Feign 接口,Seata 通过拦截器自动将 XID 写入 HTTP Header- inventory 侧收到 XID 后,在同一个全局事务中执行本地操作
- 超时时间 600 秒,给大单足够处理时间
模块三:@GlobalLock 解决脏读
// inventory 侧:非全局事务分支中使用 @GlobalLock
/**
* 查询推送单据记录,使用 @GlobalLock 解决 OMS 批量派单全局事务中的脏读问题
*/
@Transactional
@GlobalLock
public List<OrderPushRecord> queryOrderPushRecord(Integer pushType, Long minId, Boolean isWinit) {
return mapper.selectList(Wrappers.<OrderPushRecord>lambdaQuery()
.eq(OrderPushRecord::getPushStatus, PushStatus.NOT_PUSH.getCode())
.eq(OrderPushRecord::getPushType, pushType)
.gt(OrderPushRecord::getId, minId)
.orderByAsc(OrderPushRecord::getId)
.last("FOR UPDATE")); // select for update
}
原理:
@GlobalLock让 Seata 在执行 SELECT FOR UPDATE 时,检查该记录是否被其他全局事务持有全局锁- 如果有,等待锁释放后再读取,保证读到的是已提交的数据
@GlobalLock不需要@GlobalTransactional,适用于"读取全局事务数据"的场景
模块四:Inventory 回推 OMS 的落地表方案
// 派单执行方法
public void tryPush(OrderPushRecord orderPushRecord, BaseOrder baseOrder, Integer pushType) {
OrderPushRecord newRecord = invOrderPushRecordService.getById(orderPushRecord.getId());
try {
// 1. 执行派单(远程调用 WMS)
SpringContextUtils.getBean(this.getClass()).doPush(baseOrder, pushType);
newRecord.setPushStatus(PushStatus.PUSHED.getCode());
newRecord.setPushOmsMsg(JSON.toJSONString(pushResult));
} catch (Exception e) {
newRecord.setPushStatus(PushStatus.PUSH_FAILED.getCode());
newRecord.setReason(e.getMessage());
} finally {
// 2. 更新落地表(push_status)
invOrderPushRecordService.updateById(newRecord);
// 3. 派单成功 → 异步回推 OMS
if (PushStatus.PUSHED.getCode().equals(newRecord.getPushStatus()) && pushOms) {
newRecord.setRetryTimes(0);
invOrderPushRecordService.updateById(newRecord);
// 重新查询确保拿到最新数据
newRecord = invOrderPushRecordService.getById(orderPushRecord.getId());
// 代理对象调用,确保 @Transactional 生效
this.proxy().processPushOms(newRecord);
}
}
}
// 回推 OMS(独立事务)
@Transactional
public void processPushOms(OrderPushRecord record) {
String key = RedisKeyConstant.ORDER_PUSH + record.getId();
lockService.lockAndExecute(key, 0, -1, TimeUnit.SECONDS, () -> {
// 1. 发 Kafka → OMS(派单结果 + 状态)
pushOutboundStatus.send(OutboundOrderInfo.builder()
.outboundOrderNo(req.getOutboundOrderNo())
.outboundOrderStatus(OutboundOrderStatus.IN_REVIEW.getCode())
.sourceNo(req.getSourceNo())
.build());
// 2. 发 Kafka → WMS(自动收货通知)
sendToWms(record.getOrderNum());
// 3. 标记已回推
OrderPushRecord updated = new OrderPushRecord();
updated.setId(record.getId());
updated.setPushOms(1);
invOrderPushRecordService.updateById(updated);
});
}
设计要点:
order_push_record表作为状态机:NOT_PUSH→PUSHED→push_oms=1finally块保证无论派单成功与否,落地表都会更新proxy()获取 Spring 代理对象,确保@Transactional生效- Redis 分布式锁保证幂等性(重复调用不重复发 Kafka)
模块五:定时任务兜底回推
// 定时任务:补偿 push_oms=0 的记录
@Async("jobTaskExecutor")
public void notifyPushOms() {
lockService.lockAndExecute(
RedisKeyConstant.NOTIFY_PUSH_OMS_LOCK_NAME,
0, -1, TimeUnit.SECONDS,
this::processPushOms);
}
保证即使 tryPush() finally 块中的 processPushOms() 调用失败(网络抖动、OOM 等),定时任务也能兜底回推。
模块六:自动分仓的候选仓库重试
// 遍历候选仓库,失败自动尝试下一个
for (WarehouseDto warehouse : allocateCondition.getCandidateWarehouses()) {
try {
this.allocatePackage(allocateCondition, packDto, warehouse, inventoryVo, 0);
allocateResult.successful(warehouse);
break; // 成功则跳出
} catch (AllocateWarehouseException e) {
if (CREATE_OUTBOUND_FAIL.equals(e.getType()) && retryTimes < 3) {
// 创建出库单失败,重试最多 3 次
this.allocatePackage(allocateCondition, packDto, warehouse, inventoryVo, ++retryTimes);
} else {
allocateExceptionList.add(e); // 记录异常,尝试下一个仓库
}
}
}
// 所有候选仓库都失败 → 抛出异常 → @GlobalTransactional 回滚
if (allocateExceptionList.size() == allocateCondition.getCandidateWarehouses().size()) {
throw new AllocateWarehouseException("所有候选仓库配货失败");
}
模块七:Kafka 消息乱序处理
@LockOrder(
fieldOrderNo = "#outboundOrderInfo.sourceNo",
orderType = OrderTypeEnum.PACKAGE,
waitTime = 30)
public void updateListenerOutboundStatus(OutboundOrderInfo outboundOrderInfo) {
// 幂等性检查:没有等待取消标签 → 说明已处理或不应处理
LabelPO labelPO = labelService.queryLabelBySourceCode(
packagePO.getPackageNumber(), LabelEnum.WAITCANCEL.getLabelValue());
if (labelPO == null) {
return; // 直接返回,不重复处理
}
// 处理取消逻辑...
}
4. 技术栈与亮点归纳
技术栈清单
| 分类 | 技术 |
|---|---|
| 框架 | Spring Cloud Alibaba Seata 2.2.7 |
| Seata 模式 | AT 模式(自动 undo_log 回滚) |
| Seata Server | Docker 部署(开发) / K8s + Zookeeper 注册中心(生产) |
| 消息中间件 | Kafka(状态变更异步推送) |
| 分布式锁 | Redisson(应用层串行化) |
| 服务通信 | Feign(XID 自动传播) |
技术亮点
- 落地表 + 定时任务兜底:保证派单结果必达 OMS,不丢消息
- @GlobalLock + select for update:解决非全局事务分支中的脏读问题
- 多层并发控制:Seata 全局锁 + Redis 分布式锁 + @LockOrder 消息顺序锁 + 标签状态校验
- 候选仓库轮询重试:业务层容错,单仓库失败自动切换下一候选
- 前置校验减少回滚:先检查 inventory 是否允许取消,再进入全局事务
5. 难点与亮点剖析
难点一:Seata 脏读的本质与应对
Seata AT 模式的一阶段是直接提交本地事务,全局事务的提交/回滚由 TC 协调。这意味着:
- T1 时刻:fulfillment 本地事务已提交(包裹状态=已取消)
- T2 时刻:inventory 本地事务已提交(库存已恢复)
- T3 时刻:TC 协调完成
在 T1~T3 之间,其他请求查询包裹可能读到"已取消"状态,但全局事务可能最终回滚(inventory 侧异常),这就是脏读。
应对策略:
- 应用层 Redis 锁:所有对同一包裹的操作互斥,从根本上减少并发
- @GlobalLock:查询端加全局锁检查,等待全局事务完成后再读
- 标签状态校验:操作前检查是否有中间标签(WAITCANCEL/WAITRETURN)
难点二:派单与状态变更的顺序保证
原方案:派单任务结束后异步触发状态变更推送 → 两个独立任务,无顺序保证。
改造方案:派单成功后在 finally 块中立即回推 OMS → 同一调用链路保证顺序。
难点三:Kafka 消息乱序
Kafka 保证同 partition 内消息有序,但多个 producer 同时发送可能导致乱序。
应对:@LockOrder 注解基于出库单号加锁,保证同一出库单的消息串行处理 + 幂等性检查(标签是否存在)。
6. 替代方案对比
方案一:消息队列本地事件表方案
- 实现:本地事件记录表 + 定时任务扫表发送 Kafka,消费者维护消费记录表
- 优劣:不依赖 Seata,但需要维护事件表 + 定时任务 + 消费幂等,运维成本高
- 结论:本项目已引入 Seata,Seata AT 模式在同步调用场景下更简洁
方案二:Saga 模式
- 实现:每个分支事务定义正向操作和补偿操作
- 优劣:适合长事务,但需要为每个操作编写补偿逻辑,开发成本高
- 结论:本项目的操作都是短事务(DB 操作 + Feign 调用),AT 模式更合适
7. 技术选型与方案抉择复盘
选型思路
- Seata AT 模式:团队熟悉度高,undo_log 自动生成回滚信息,业务代码无侵入
- Redis 分布式锁:应用层串行化,减少 Seata 全局锁冲突概率
- Kafka 异步推送:状态变更不阻塞主流程
优点
- 业务代码侵入小,只需加
@GlobalTransactional注解 - undo_log 自动生成回滚,无需手写补偿逻辑
- 多层锁机制(Seata 全局锁 + Redis 锁 + @LockOrder)保障并发安全
缺点 / 已知不足
- Seata 读未提交隔离级别导致脏读,需要
@GlobalLock补充 - undo_log 表随时间增长,需要定期清理
- 全局事务超时时间长(600 秒),占用资源
可优化点
- undo_log 定期清理任务
- 按业务场景缩短全局事务超时时间
- 探索 Seata TCC 模式在关键场景的应用
8. 成果与数据
- 覆盖范围:fulfillment 服务 11 处
@GlobalTransactional,inventory 服务 7 处 - 一致性保证:fulfillment → inventory 的配货/取消/派单全链路 ACID 语义
- 脏读修复:通过
@GlobalLock+select for update解决 OMS 批量派单脏读 - 消息必达:落地表 + 定时任务兜底,派单结果 100% 推送 OMS
- 并发安全:多层锁机制,生产环境无并发导致的库存异常
9. 面试题清单
1. Seata AT 模式的工作原理与 undo_log 机制
- 考察点:AT 模式的两阶段流程,before/after image 生成,undo_log 的作用
- 问题剖析:考察对 Seata 核心机制的理解。AT 模式与 TCC/Saga 的本质区别在于"回滚信息自动生成"
- 思考过程:① 讲清一阶段流程(本地事务 + undo_log 写入);② 讲清二阶段提交(删除 undo_log);③ 讲清二阶段回滚(根据 undo_log 反向补偿);④ 结合项目中的 DataSourceProxy
- 详细回答:① AT 模式一阶段:业务 SQL 执行时,Seata 的 DataSourceProxy 拦截 SQL,先查询 before-image(执行前的数据快照),执行 SQL 后查询 after-image,将 before+after 序列化为 rollback_info 写入 undo_log 表,然后提交本地事务;② 二阶段提交:TC 协调所有 RM 成功后,通知各 RM 异步删除 undo_log,本地事务已经在一阶段提交了,所以无需额外操作;③ 二阶段回滚:TC 发现任一 RM 失败,通知所有 RM 根据 undo_log 中的 before-image 执行反向 SQL(如 INSERT 的 before-image 为空,则 DELETE;UPDATE 的 before-image 是旧值,则 UPDATE 回旧值);④ 关键设计:undo_log 表有
ux_undo_log (xid, branch_id)联合唯一索引,保证同一分支事务只有一条 undo_log;⑤ 项目中配置enable-auto-data-source-proxy: true,Seata 自动代理所有 DataSource,无需业务代码改动
2. 分布式锁(Redis)与 Seata 全局锁的区别
- 考察点:应用层并发控制 vs 框架层并发控制,各自的适用场景
- 问题剖析:为什么项目中既要 Redis 锁又要 Seata 锁?两者解决的不是同一个问题
- 思考过程:① 讲清 Redis 锁的作用(应用层串行化);② 讲清 Seata 全局锁的作用(分支事务回滚时的数据保护);③ 两者互补关系
- 详细回答:① Redis 分布式锁是应用层串行化,在
@GlobalTransactional方法入口加锁,基于包裹号加锁(CANCEL_PACKAGE_KEY + packageNumber),防止多个请求并发操作同一包裹;② Seata 全局锁是框架层的并发控制,在二阶段回滚时,如果另一个全局事务已经修改了同一条记录,需要等待对方释放全局锁才能回滚;③ 两者互补:Redis 锁是"第一道防线",减少并发冲突的概率;Seata 全局锁是"最后一道防线",保证回滚时的数据一致性;④ 项目配置retry-policy-branch-rollback-on-conflict: false,意味着 Seata 全局锁冲突时不重试直接回滚,这是快速失败策略——因为 Redis 锁已做了串行化,Seata 锁冲突概率极低,冲突说明有异常情况,直接回滚更安全
3. @GlobalLock 的作用与 @GlobalTransactional 的区别
- 考察点:GlobalLock 用于非全局事务分支中读取全局事务数据,防止脏读
- 问题剖析:很多开发者只知道 @GlobalTransactional,不知道 @GlobalLock 的使用场景
- 思考过程:① 讲清两者的使用场景差异;② 结合项目中 queryOrderPushRecord 方法说明;③ 讲清 select for update 与 @GlobalLock 的协同
- 详细回答:①
@GlobalTransactional是全局事务的 TM 入口,包裹整个分布式事务的生命周期;@GlobalLock用于非全局事务分支中,只检查全局锁状态,不发起全局事务;② 典型场景:OMS 批量派单是一个全局事务,库存服务在查询 order_push_record 时需要读到已提交的数据(而不是 OMS 全局事务的中间状态),所以加上@GlobalLock+select for update;③@GlobalLock的执行流程:Seata 在执行 select for update 前,先检查该记录是否被其他全局事务持有全局锁,如果有则等待锁释放后再查询,保证读到的是已提交的数据;④ 如果不用@GlobalLock,可能读到 OMS 批量派单全局事务中已提交但尚未全局提交的中间状态,导致重复派单
4. Seata 全局事务超时时间设为 600 秒的原因
- 考察点:全局事务超时与本地事务超时的区别,长事务的资源占用
- 问题剖析:为什么全局事务超时远大于普通事务?太短会怎样?太长会怎样?
- 思考过程:① 讲清全局事务超时的含义(TC 协调整个链路的超时时间);② 分析项目中的调用链路长度;③ 太短和太长的影响;④ 优化建议
- 详细回答:① 全局事务超时是指 TC 等待所有 RM 完成的最长时间。项目中 fulfillment → inventory 的调用链路可能涉及多个远程 Feign 调用、多次数据库操作、甚至调用第三方 WMS 接口;② 自动分仓场景需要遍历多个候选仓库,每个仓库都可能重试 3 次,总耗时可能较长;③ 超时太短:全局事务未完成就被 TC 判定超时回滚,导致正常业务失败;④ 超时太长:如果 TM 崩溃或网络中断,TC 无法及时释放全局锁,影响其他事务;⑤ 当前 600 秒是一个保守值。优化方向:按业务场景区分,简单操作(如派单)设 120 秒,复杂操作(如分仓配货)设 600 秒
5. 基于消息队列的分布式事务方案(本地事件表)
- 考察点:事件记录表 + 定时任务扫表的最终一致性方案,与 Seata 对比
- 问题剖析:这是阿里早期常用的分布式事务方案,考察对最终一致性的理解
- 思考过程:① 讲清生产者侧流程(本地事务 + 事件表);② 讲清定时任务;③ 讲清消费者侧流程(消费记录表 + 幂等);④ 与 Seata 对比优劣
- 详细回答:① 生产者侧:本地事务中同时写入业务数据和事件记录表(status=unfinished),本地事务保证两者一致性;② 定时任务:轮询扫描 status=unfinished 的记录,发送到 Kafka,发送成功后更新 status=finished;③ 消费者侧:先写入消费记录表(保证幂等),再执行本地业务操作,两者在同一本地事务中;④ 与 Seata 对比:消息队列方案是最终一致性,有延迟(取决于定时任务间隔),但不依赖外部协调服务;Seata AT 是强一致性,延迟低,但需要部署 Seata Server 且 undo_log 表有存储开销;⑤ 项目中两者结合使用:同步写操作用 Seata 保证强一致,状态变更推送用 Kafka 保证最终一致
6. Kafka 消息乱序到达的解决方案
- 考察点:@LockOrder 注解实现有序消费、幂等性检查、状态前置校验
- 问题剖析:Kafka 同 partition 有序,但不同 producer 或重试可能导致乱序
- 思考过程:① 讲清乱序产生的原因;② 讲清 @LockOrder 的实现原理;③ 讲清幂等性检查;④ 讲清状态前置校验
- 详细回答:① 乱序原因:a. 多个 producer 同时发送,b. 网络延迟不一致,c. 消费者重试导致旧消息重新入队;②
@LockOrder基于 sourceNo(出库单号)加分布式锁,同一出库单的消息必须串行处理,等待时间 30 秒;③ 幂等性检查:处理取消消息前先查标签(WAITCANCEL),标签不存在说明已处理或不应处理,直接返回;④ 状态前置校验:消费者只处理特定状态(IN_PROCESSING/COMPLETE/CANCELLED 等),非目标状态直接 ack 跳过;⑤ max.poll.records=1 保证每次只拉取一条消息,避免批量处理时的顺序问题
7. Seata AT 模式下的前置校验减少回滚
- 考察点:在 @GlobalTransactional 方法执行前先校验远端条件,减少全局事务回滚概率
- 问题剖析:全局事务回滚成本高(需要通知所有 RM 执行 undo),前置校验能显著降低回滚率
- 思考过程:① 讲清全局事务回滚的成本;② 讲清前置校验的具体做法;③ 讲清校验失败的处理
- 详细回答:① Seata AT 回滚成本:TC 需要通知所有已参与的 RM 执行反向补偿,每个 RM 需要读取 undo_log、执行反向 SQL、清理 undo_log,耗时长;② 前置校验:在
doCancelPackage方法中,先通过inventoryRemoteCheckReturnOrCancelPack()调用 inventory 的checkPushOrders()接口,预检是否允许取消/退回;③ inventory 检查出库单状态(是否已发货、是否已收货等),返回 "0" 表示允许,否则返回错误码;④ 校验失败的包裹加入 errorList 过滤掉,只对校验通过的包裹进入@GlobalTransactional方法;⑤ 这是一种"乐观校验"策略,用一次额外的远程调用(成本低)换取避免全局事务回滚(成本高)
8. 生产环境 Seata 高可用部署方案
- 考察点:Seata Server 的注册中心选择(Nacos/Zookeeper/Etcd)、多实例部署
- 问题剖析:单机 Seata Server 是单点故障,生产环境需要高可用
- 思考过程:① 讲清 Seata 的注册中心机制;② 讲清本项目使用 Zookeeper 的配置;③ 讨论多实例部署的注意事项
- 详细回答:① Seata Server 通过注册中心(Zookeeper/Nacos/Etcd)向客户端暴露服务列表,客户端自动发现可用节点;② 本项目生产环境使用 Zookeeper:
seata.registry.type: zk,server-addr 通过环境变量${ZK_ADDR}注入,K8s Deployment 通过JAVA_OPTS指定-Dspring.profiles.active=prod激活生产配置;③ 多实例部署注意事项:a. 多实例共享同一数据库(TC 的 session 和 lock 信息存在 DB 中),b. 注册中心地址一致,c. 文件存储模式改为 DB 模式(file 模式不共享);④ 高可用收益:单节点宕机后客户端自动切换到其他节点,全局事务不中断
9. 配货流程中候选仓库轮询重试的容错设计
- 考察点:业务层容错策略、重试次数控制、异常分类处理
- 问题剖析:不是所有异常都值得重试,需要区分可重试和不可重试异常
- 思考过程:① 讲清为什么要候选仓库轮询;② 讲清哪些异常可重试;③ 讲清重试次数限制;④ 讲清全部失败的处理
- 详细回答:① 候选仓库轮询:一个包裹可能匹配多个仓库(按区域/库存/物流规则),优先匹配最优仓库,失败自动尝试下一个;② 可重试异常:
CREATE_OUTBOUND_FAIL(创建出库单失败,可能是网络抖动、库存并发冲突),最多重试 3 次;③ 不可重试异常:库存不足、仓库不可用等,直接记录异常,尝试下一个候选仓库;④ 全部失败处理:所有候选仓库都失败后抛出AllocateWarehouseException,触发@GlobalTransactional回滚已执行的本地操作(包裹状态更新、标签保存等);⑤ 设计亮点:重试在@GlobalTransactional内部,Seata 会将重试中的每次操作纳入同一全局事务,回滚时统一处理
10. 为什么 Seata 的 feign.enabled=false?
- 考察点:Seata Feign 拦截器与 Spring Cloud Sleuth 的自动配置冲突
- 问题剖析:这是一个实际的踩坑点,考察对框架自动配置冲突的理解
- 思考过程:① 讲清 Seata Feign 拦截器的作用(传递 XID);② 讲清与 Sleuth 冲突的原因;③ 讲清解决方案
- 详细回答:① Seata 提供了
spring-cloud-starter-alibaba-seata来自动配置 Feign 拦截器,将 XID 写入 HTTP Header;② 项目中使用了 Spring Cloud Sleuth 做链路追踪,Sleuth 也有自己的 Feign 拦截器(写入 traceId/spanId);③ 两套自动配置在 Bean 注册顺序上冲突,导致 Feign 客户端创建失败;④ 解决方案:seata.feign.enabled: false关闭 Seata 的自动 Feign 配置,使用自定义的spring-cloud-starter-alibaba-seata-codo(公司内部改造版),解决了与 Sleuth 的冲突;⑤ XID 仍然通过自定义的 Feign RequestInterceptor 传递,确保分布式事务正常工作
10. 简历提炼版
在 fulfillment-inventory 订单履约链路中落地 Seata AT 分布式事务,18 处 @GlobalTransactional 覆盖配货/取消/派单/退回全场景;通过 undo_log 自动生成回滚,零侵入保证 ACID 语义;自研 @GlobalLock + select for update 解决批量派单脏读问题;设计落地表 + 定时任务兜底方案保证派单结果必达 OMS;结合 Redisson 分布式锁 + @LockOrder 消息顺序锁实现多层并发控制,生产环境零库存异常。
关键源码路径
Fulfillment 服务
| 文件 | 路径 |
|---|---|
| 自动分仓配货 | creeks-fulfillment/.../rule/AllocateWarehouseLogisticsHandle.java |
| 包裹取消 | creeks-fulfillment/.../pack/impl/PackageServiceImpl.java |
| 包裹退回 | creeks-fulfillment/.../pack/impl/PackageServiceImpl.java |
| 包裹派单 | creeks-fulfillment/.../pack/impl/PackageServiceImpl.java |
| 订单取消联动 | creeks-fulfillment/.../order/impl/OrderBusinessServiceImpl.java |
| Seata 配置 | creeks-fulfillment/.../resources/application.yml |
Inventory 服务
| 文件 | 路径 |
|---|---|
| 出库取消处理 | creeks-inventory/.../outbound/OutboundCancelResultServiceImpl.java |
| 箱运处理 | creeks-inventory/.../outbound/OutboundOrderStockOutServiceImpl.java |
| 派单落地表 | creeks-inventory/.../business/outbound/OutboundJobServiceImpl.java |
| 回推 OMS | creeks-inventory/.../business/outbound/OutboundJobServiceImpl.java |
| 创建入库单 | creeks-inventory/.../inbound/impl/HandInboundServiceImpl.java |
| Seata 配置 | creeks-inventory/.../resources/application.yml |
| Kafka 消费者 | creeks-inventory/.../consumer/outbound/AllocateOutboundCancelListener.java |