跳到正文
Kale的数字花园
目录 文章
/16 分钟/8 阅读/0 评论

分布式事务实战: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=1
  • finally 块保证无论派单成功与否,落地表都会更新
  • 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 侧异常),这就是脏读。

应对策略:

  1. 应用层 Redis 锁:所有对同一包裹的操作互斥,从根本上减少并发
  2. @GlobalLock:查询端加全局锁检查,等待全局事务完成后再读
  3. 标签状态校验:操作前检查是否有中间标签(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
相关阅读

2 分钟

在项目中集成 Seata(AT模式)

首先,更新 到 版本: 引入 Seata 相关的依赖定义: 修改 加入 Seata 相关的配置: 只需要用普通的 标签来声明: 业务端(通常是微服务)的数据库中需要创建一个 表: 需要用 Seata 提供的 标签声明一次分布式事务的开始:

面试#分布式事务8 阅读 · 0 评论

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。