分布式事务与幂等

07 · 核心亮点

亮点七 · 取消一个订单,要改两个库

Seata AT 模式 + Redisson 公平锁 + 状态机条件更新。三重手段的分工,是这个模块最值得讲清的部分。

涉及模块:driver / trade / web-customer / web-courier(注意:不在 oms)

亮点七 · 取消一个订单,要改两个库

业务问题: 客户点了"取消订单"。 系统要同时做三件事:① 把订单状态改成已取消;② 把取件任务改成已取消; ③ 如果快递员已经到门口了,还要通知调度重新派单。

订单在 OMS 的库,取件任务在 work 的库。这是两个微服务、两个数据库。 如果 ① 成功 ② 失败,客户看到订单已取消, 但快递员手机上还显示"这个单还在,请去取"——快递员白跑一趟,客户投诉。


一、先做一个关键判断:什么时候需要分布式事务

这是面试官最爱问的第一个问题,也是最能区分候选人的问题。

很多候选人的回答是"跨服务就用 Seata"。这是错的。

1.1 先问三个问题

text
Q1: 两边必须同时成功吗?还是允许中间状态?
Q2: 中间状态持续多久?如果是几毫秒,用户看得见吗?
Q3: 失败了能不能补偿?补偿的业务成本是多少?

1.2 三种情况的正确做法

情况是否需要分布式事务正确做法
强一致要求、中间态不可见✅ 需要Seata AT / TCC
允许短暂中间态、用户无感❌ 不需要本地事务 + MQ 最终一致
失败可人工/自动补偿❌ 不需要补偿表 + 定时对账

1.3 我们项目里的两类场景

场景 A:取消订单(本项目用了 Seata)

java
// chain-cloud-ms-web-customer OmsServiceImpl#cancel
@GlobalTransactional
@Override
public void cancel(Long id) {
    // 更新订单(OMS 库)
    orderFeign.updateStatus(ListUtil.of(id), OrderStatusEnum.CANCELLED.getCode());

    // 查询取派件任务(work 库)
    List<PickupDispatchTaskDTO> pickupDispatchTaskDTOS =
            pickupDispatchTaskFeign.findByOrderId(id, PickupDispatchTaskTypeEnum.PICKUP);
    if (CollUtil.isEmpty(pickupDispatchTaskDTOS)) {
        // 暂未生成取件任务
        log.info("{}订单暂无取派件任务", id);
        return;                              // ← 提前返回,正常提交
    }

    // 更新取件任务(work 库)
    PickupDispatchTaskDTO taskDTO = pickupDispatchTaskDTOS.get(0);
    taskDTO.setStatus(PickupDispatchTaskStatusEnum.CANCELLED);
    taskDTO.setCancelReason(PickupDispatchTaskCancelReasonEnum.CANCEL_BY_USER);
    pickupDispatchTaskFeign.updateStatus(taskDTO);
}

为什么需要 Seata: 订单和取件任务必须同时改, 因为中间态(订单已取消但任务还在)会直接导致快递员白跑。 用户看得见这个中间态。

场景 B:下单通知调度(没用 Seata,用了延迟消息)

text
BEGIN
  INSERT cc_order          ← 本地事务
COMMIT
sendMsg(delay = 5s)        ← 事务外,失败落库补偿

为什么不用 Seata: 订单落库和消息发送之间有 5 秒的天然延迟, 这 5 秒里订单是"已创建但还没派单"的——这是业务上完全正常的状态 (订单创建和派单本来就是异步的)。 所以不需要强一致,用最终一致就够了。

这是本亮点最重要的一段话:

"我们判断要不要上分布式事务的标准是: 这个中间状态用户看得见吗?业务能接受多久?

取消订单的中间态是'订单取消了但快递员还要去取', 用户和快递员都看得见,而且后果是白跑一趟——所以必须强一致。

但下单的中间态是'订单创建了但还没派单', 这是业务上本来就要持续几秒的正常状态,用户预期里就有。 所以我们用本地事务 + 延迟消息 + 失败补偿, 不用 Seata。

Seata 不是'跨服务就上',它是'中间态不可接受才上'。"


二、三重幂等手段(这才是这个模块的核心)

全项目只有一个地方把三种手段都用上了:driver 服务的 intoStorage。

java
// chain-cloud-ms-driver-service DriverJobServiceImpl#intoStorage
@Override
@GlobalTransactional
public void intoStorage(DriverDeliverDTO driverDeliverDTO) {
    // 1. 查作业单
    DriverJobEntity driverJob = super.getById(driverDeliverDTO.getId());
    if (ObjectUtil.isEmpty(driverJob)) {
        throw new CCException(DriverJobExceptionEnum.DRIVER_JOB_NOT_FOUND);
    }

    // 2. 状态机校验:只有"运输中"的作业单才能入库
    if (ObjectUtil.notEqual(driverJob.getStatus(), DriverJobStatusEnum.PROCESSING)) {
        throw ...;              // 状态不对 → 拒绝(幂等的第二道)
    }
    Long transportTaskId = driverJob.getTransportTaskId();

    // 3. 加锁:只能有一个司机操作
    String lockRedisKey = Constants.LOCKS.DRIVER_JOB_LOCK_PREFIX + transportTaskId;
    RLock lock = this.redissonClient.getFairLock(lockRedisKey);
    if (lock.tryLock()) {
        try {
            TransportTaskDTO transportTask = this.transportTaskFeign.findById(transportTaskId);

            // 4. 业务短路:任务已结束就直接返回(不是抛异常,是正常提交)
            if (ObjectUtil.equalsAny(transportTask.getStatus(),
                    TransportTaskStatusEnum.CANCELLED,
                    TransportTaskStatusEnum.COMPLETED)) {
                return;          // ← 幂等的第三道:已结束,提交而非回滚
            }

            // 5. 真正的业务:更新运单 + 完成任务
            this.transportOrderFeign.updateByTaskId(String.valueOf(transportTaskId));
            this.transportTaskFeign.completeTransportTask(transportTaskCompleteDTO);
        } finally {
            lock.unlock();      // ← 注意 finally 的位置:在 if 里面
        }
    } else {
        throw new CCException(DriverJobExceptionEnum.DRIVER_JOB_INTO_STORAGE_ERROR);
    }
    ...
}

2.1 三道防线的分工

text
第 1 道:分布式锁(Redisson 公平锁)
  作用:同一时刻只有一个线程能进入临界区
  解决:并发请求同时修改同一条数据
  粒度:lockKey = "DRIVER_JOB_LOCK_" + transportTaskId   (按运输任务粒度)

第 2 道:状态机前置校验
  作用:进入业务前先判断当前状态合不合法
  解决:顺序问题(用旧状态做了决策)
  实现:if (status != PROCESSING) throw

第 3 道:业务短路(关键!)
  作用:锁拿到后、真正改数据前,再查一次下游状态
  解决:跨服务场景下的"已结束"判定
  实现:if (status in {CANCELLED, COMPLETED}) return;   ← 注意是 return 不是 throw

第 3 道为什么必须是 return 而不是 throw?

这是个很容易答错的细节:

java
if (已结束) {
    return;                    // ✅ 正常提交
}

如果写成:

java
if (已结束) {
    throw new CCException("任务已结束");   // ❌ 会触发 Seata 全局回滚
}

后果: 本来这个请求是"重复提交,应该被忽略", 但抛异常会导致 Seata 把整个全局事务回滚—— 包括其它服务已经成功的操作。 一个幂等的重复请求,变成了一个破坏性的回滚。

这个点是本亮点最能体现"理解分布式事务语义"的地方。 面试时一定要讲,因为它区分了"看过代码"和"理解语义"。

2.2 为什么用公平锁(Fair Lock)

java
RLock lock = this.redissonClient.getFairLock(lockRedisKey);
getLock()(非公平)getFairLock()(公平)
分配谁先抢到谁进按请求到达顺序 FIFO
性能略高略低(要维护队列)
饥饿可能有线程长期抢不到不会饥饿

业务场景: 司机端入库操作是高频的(司机在装货过程中可能反复点"到达")。 如果用非公平锁,某个 unlucky 的司机会一直抢不到锁, 其它司机都在等,运输任务卡住。 公平锁保证了 FIFO,不会有人被饿死。

为什么不用 RedissonClient.getLock()? 因为这个场景是"多个司机可能同时对同一批任务操作", 公平性比吞吐量更重要。

tryLock() 的参数要注意:

java
// driver 服务:无参(不等待,拿不到就返回 false)
if (lock.tryLock()) { ... }

// trade 服务:有等待时间,无 leaseTime
if (lock.tryLock(TradingCacheConstant.REDIS_WAIT_TIME, TimeUnit.SECONDS)) { ... }

tryLock() 不传 leaseTime 意味着什么? Redisson 默认开启 watchdog(看门狗)自动续期, 锁持有 30 秒会自动续到 30 秒,直到 unlock()。 这保证了业务执行很久也不会锁超时。

这是"正确用法" —— 但要注意:必须保证 unlock() 一定会被调用, 否则死锁。见坑 2。


三、难点拆解

难点 1:Seata AT 模式的原理,以及它的代价

AT(Auto Transaction)模式的两阶段:

text
一阶段:
  业务 SQL 正常执行 → 本地提交
  同时记录"前后镜像"到 undo_log 表:
    id | branch_id | xid | table_name | pk | before_image | after_image

二阶段(成功):
  删除 undo_log 记录

二阶段(失败):
  校验当前数据 vs after_image 是否被其它事务改过
    ├─ 未被改过 → 用 before_image 回滚(UPDATE 回去)→ 删 undo_log
    └─ 被改过  → 人工介入(这是 AT 的脏写问题)

AT 模式的三个优点:

  1. 无侵入 —— 业务代码几乎不用改(加个 @GlobalTransactional 就行);
  2. 不阻塞 —— 一阶段就提交了,不持有数据库行锁;
  3. 对业务友好 —— 不用写 try/confirm/cancel 三套方法。

三个代价(必须主动说出来):

代价说明
全局锁竞争二阶段校验需要持有 undo_log 的全局锁,高并发同一行会串行
只支持关系库MongoDB / ES / Redis 不支持(要用 TCC 或 Saga)
脏写风险二阶段校验失败时无法自动处理,需要人工介入
性能开销每次全局事务要写 undo_log,多一次 IO
不能跨非事务资源Seata 回滚不了 MQ 和 Redis —— 见坑 1

这个"不能跨非事务资源"是最重要的,必须讲。

难点 2:什么时候用 Seata,什么时候用 MQ 最终一致

决策树(这是可以直接背的答案):

text
Q: 两边的数据必须"同时"可见吗?
  ├─ 是 → Q: 允许中间态存在多久?
  │        ├─ 不能有任何中间态 → Seata AT / TCC
  │        └─ 可以有几秒/几分钟 → 本地事务 + MQ(最终一致)
  └─ 否(可以异步) → 本地事务 + MQ

具体到本项目的两个对比:

取消订单下单通知调度
两个动作的间隔必须为 0天然有几秒
中间态的危害快递员白跑、客户投诉无(本来就要异步派单)
用什么Seata本地事务 + 延迟消息 + 补偿
代码位置web-customer#canceloms#mailingSave → noticeOrderStatusChange

第三种情况(补偿型):

text
Q: 失败之后能不能接受"人工介入"?
  ├─ 不能 → 强一致
  └─ 能   → 补偿表 + 定时对账(成本低很多)

补偿型方案的例子:退款 退款要调微信接口,不可能用 Seata(外部 HTTP 不可回滚)。 标准做法是:

text
本地事务:把退款单置为"退款中" + 累加退款金额 → 提交
  ↓
异步:调微信退款接口
  ↓
回调/轮询:把退款单置为"成功"或"失败"
  ↓
对账任务:扫描长时间"退款中"的单,人工或自动处理

这个项目里 refundJob 就是干这个的(见亮点 8)。


四、实际遇到的困难与踩坑

坑 1:Seata 回滚不了 MQ,产生"幽灵消息"

事实:

java
// chain-cloud-ms-web-courier TaskServiceImpl#pickup
@GlobalTransactional
@Override
public boolean pickup(TaskPickupVO vo) {
    var taskDTO = pickupDispatchTaskFeign.findById(Long.valueOf(vo.getId()));
    if (ObjectUtil.notEqual(taskDTO.getStatus(), PickupDispatchTaskStatusEnum.NEW)) {
        throw new CCWebException("快递必须是未取状态!");     // ← 状态校验
    }
    ...
    orderFeign.orderPickup(orderPickupDTO);                 // OMS 本地事务
    ...
    this.mqFeign.sendMsg(Constants.MQ.Exchanges.COURIER,     // ← MQ 发送
                         Constants.MQ.RoutingKeys.COURIER_PICKUP, msg);
    return true;
}

问题:

text
1. Feign 调用顺序:先改 work 任务状态,再改 OMS 订单状态,再发 MQ
2. Seata 全局事务覆盖了 1 和 2(两个 DB)
3. ★ 但是 MQ 不是 Seata 管辖的资源
4. 假设第 2 步失败:
   → Seata 回滚 work 和 OMS 的数据库 ✅
   → 但第 3 步的 MQ 已经发出去了 ❌ 不会撤回
   → 下游收到"快递员已揽收"的消息,但它去查订单,订单还是"待取件"
   → 数据不一致,且这条消息无法撤回

这就是"幽灵消息(phantom message)"。

全项目没有任何一处使用 @TransactionalEventListener(AFTER_COMMIT) 或 transactional outbox, 所以这个问题是无解的(除非改造)。

正确的解法(按推荐度排序):

方案 1:本地消息表(Transactional Outbox)

java
@Transactional
public boolean pickup(TaskPickupVO vo) {
    orderFeign.orderPickup(dto);

    // 消息和业务数据在同一个本地事务里写!
    TaskMessage msg = TaskMessage.builder()
            .msgId(UUID.randomUUID().toString())
            .exchange(Constants.MQ.Exchanges.COURIER)
            .routingKey(Constants.MQ.RoutingKeys.COURIER_PICKUP)
            .payload(json)
            .status("PENDING")          // 待发送
            .build();
    taskMessageMapper.insert(msg);     // ← 和业务数据同一个事务
    return true;
}

// 独立的调度任务轮询发送
@Scheduled(fixedDelay = 1000)
public void scanAndSend() {
    List<TaskMessage> list = taskMessageMapper.selectPending(100);
    for (TaskMessage m : list) {
        try {
            mqService.sendMsg(m.getExchange(), m.getRoutingKey(), m.getPayload());
            m.setStatus("SENT");
            taskMessageMapper.update(m);
        } catch (Exception e) {
            m.setRetryCount(m.getRetryCount() + 1);
            if (m.getRetryCount() > 10) m.setStatus("DEAD");
            taskMessageMapper.update(m);
        }
    }
}

方案 2:@TransactionalEventListener(AFTER_COMMIT)

java
@Transactional
public boolean pickup(TaskPickupVO vo) {
    orderFeign.orderPickup(dto);
    publisher.publishEvent(new CourierPickedUpEvent(orderId));   // 发领域事件
    return true;
}

// 监听器:只在本地事务真正提交后才执行
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPickedUp(CourierPickedUpEvent event) {
    mqService.sendMsg(EXCHANGE, KEY, buildMsg(event));
}

方案 2 更轻量,但它只保证"本地事务提交后才发消息", 不保证"消息一定发出去" —— 发送失败还是得靠补偿。 所以生产环境通常是 方案 2 + 补偿表。

坑 2:finally { lock.unlock(); } 写在 if 外面

事实(trade 服务,4 处):

java
// chain-cloud-ms-trade-service BasicPayServiceImpl#refundTrading
String key = TradingCacheConstant.REFUND_PAY + tradingOrderNo;
RLock lock = redissonClient.getFairLock(key);
try {
    if (lock.tryLock(TradingCacheConstant.REDIS_WAIT_TIME, TimeUnit.SECONDS)) {
        // ... 业务
        return true;
    }
    throw new CCException(TradingEnum.NATIVE_QUERY_FAIL);      // tryLock 返回 false 时走到这
} catch (CCException e) {
    throw e;
} catch (Exception e) {
    log.error(...);
    throw new CCException(TradingEnum.NATIVE_QUERY_FAIL);
} finally {
    lock.unlock();          // ← 不管有没有拿到锁,都会执行
}

问题:

java
if (lock.tryLock(...)) { ... return true; }
throw new CCException(...);       // 抛出 → 进入 finally → lock.unlock()
// 但这把锁根本不是当前线程持有的!
// → Redisson 抛 IllegalMonitorStateException
// → 这个异常会【覆盖】掉原来要抛的 CCException

后果: 原本清晰的业务异常被一个底层框架异常覆盖, 排查问题时看到的堆栈是 IllegalMonitorStateException 而不是业务错误, 非常误导。

涉及文件(全部 4 处):

文件行
trade/service/impl/NativePayServiceImpl.java100
trade/service/impl/JsapiPayServiceImpl.java73
trade/service/impl/BasicPayServiceImpl.java80, 126, 159
trade/service/impl/NotifyServiceImpl.java130

正确写法(driver 服务是对的):

java
boolean locked = false;
try {
    locked = lock.tryLock(WAIT_TIME, TimeUnit.SECONDS);
    if (locked) {
        // 业务
        return true;
    }
    throw new CCException(TradingEnum.NATIVE_QUERY_FAIL);
} finally {
    if (locked && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

对比 driver 服务(正确):

java
if (lock.tryLock()) {
    try {
        // 业务
    } finally {
        lock.unlock();      // ← 在 if 里面,只在拿到锁时才解锁
    }
} else {
    throw new CCException(...);
}

这是同一个项目里"一处写对、一处写错"的对比,很适合在面试里讲。 它说明:不是不会,是没统一。 修复方式不是逐个改,而是把加锁逻辑抽成一个模板方法:

java
// 统一的锁模板,杜绝这类错误
public <T> T withLock(String key, Duration wait, Supplier<T> action) {
    RLock lock = redissonClient.getFairLock(key);
    boolean locked = false;
    try {
        locked = lock.tryLock(wait.toMillis(), TimeUnit.MILLISECONDS);
        if (!locked) throw new CCException(AcquireLockFailedException, key);
        return action.get();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new CCException("获取锁被中断", e);
    } finally {
        if (locked && lock.isHeldByCurrentThread()) lock.unlock();
    }
}

坑 3:OMS 里一个 @GlobalTransactional 都没有

事实(grep 全仓库 9 处):

文件方法
driver-service DriverJobServiceImpl.javaupdateStatus / intoStorage / 4 个司机作业方法
web-courier TaskServiceImpl.javapickup / cancel
web-customer OmsServiceImpl.javacancel ← 唯一涉及订单的
web-manager BaseTruckServiceImpl.java车辆排班
web-manager WorkServiceImpl.java作业操作

chain-cloud-ms-oms-service 本身零个 @GlobalTransactional。

这意味着什么?

架构上是正确的:BFF 层(web-customer)编排多个下游服务的事务, 而每个业务服务(OMS)只关心自己的单库事务。

但这也可能是个误解: 很多人以为"Seata 是给业务服务用的", 实际上是事务发起方(BFF)用,参与者是被调用的服务。

面试时要说清楚这一点,因为它显示你理解 Seata 的工作模型: @GlobalTransactional 标在"发起方",全局事务的边界是这个方法, 里面所有通过 Feign 调用到的服务都自动成为参与者。

但有个真实的架构问题: 事务边界放在 BFF 层,意味着任何绕过 BFF 的直接调用都拿不到分布式事务保护。 如果以后有人写个定时任务直接调 orderFeign.updateStatus + taskFeign.updateStatus, 就会失去原子性。

修复: 提供一个"编排层"的公共组件,或者在 Feign 拦截器里自动传递 XID。 (Seata 本身有 SeataFeignClientAutoConfiguration,会自动传播 XID, 但前提是所有 Feign 调用都是经过 Feign 的,而不是绕过它直连。)

坑 4:OMS 订单创建完全没有幂等

事实:

java
// MailingSaveDTO 的完整字段
sendAddress, receiptAddress, pickUpTime, pickupType, payMethod,
goodsType, goodsName, goodNum, totalVolume, totalWeight,
measureLong, measureWidth, measureHigh, mark, memberId
// ↑ 没有 requestId,没有 idempotentKey,没有 orderNo

OrderServiceImpl#mailingSave 里也没有任何去重检查、没有 Redis 锁、没有唯一键。

后果: 用户网络卡顿,点了两次"确认下单",就生成两张订单。 客户会收到两条取件通知,财务对账多一笔。

这在移动端是高频问题——按钮没有防抖 + 网络重试 + 用户手抖。

修复方案(三层):

java
// 第 1 层:前端幂等键(最标准)
// 客户端生成一个 requestId(UUID),下单时带上
// 服务端按 (memberId, requestId) 唯一索引拦截
java
// 第 2 层:短时间窗口去重(Redis)
// key = "ORDER_SUBMIT_" + memberId + "_" + 寄收地址哈希
// TTL 30 秒,命中就返回上次的结果(注意不是报错,而是返回同一个订单)
public OrderDTO mailingSave(MailingSaveDTO dto) {
    String key = "ORDER_SUBMIT:" + dto.getMemberId() + ":"
               + DigestUtils.md5Hex(dto.getSendAddress() + dto.getReceiptAddress());
    RLock lock = redissonClient.getFairLock(key);
    try {
        if (lock.tryLock()) {
            // 双重检查:拿到锁后再查一次
            OrderDTO existing = findRecentOrder(dto, 30, TimeUnit.SECONDS);
            if (existing != null) {
                return existing;      // ← 返回已有订单,而不是报错
            }
            return doMailSave(dto);
        }
        throw new CCException("订单正在提交中,请勿重复操作");
    } finally {
        if (lock.isHeldByCurrentThread()) lock.unlock();
    }
}
java
// 第 3 层:业务唯一键兜底
// 对强幂等要求的场景(比如支付回调),靠数据库唯一索引

注意第 2 层的语义选择: 重复提交应该返回第一次的结果,而不是报"重复提交"错误。 因为对用户来说,他想要的是"我刚才那单成了没有",报错会让他更困惑。


五、如果重做,我会怎么改

优先级改动收益
P0trade 的 4 处 finally unlock() 改为条件解锁消除异常被覆盖
P0OMS 下单加幂等(requestId + Redis 短窗口)消除重复订单
P1抽 withLock 模板方法,全项目统一锁用法从根上消除锁的写法错误
P1pickup 改用 @TransactionalEventListener(AFTER_COMMIT) 或 outbox消除幽灵消息
P1明确事务边界的文档 + 提供编排层公共组件防止绕过 BFF 丢事务
P2加一个分布式事务的端到端验证(构造第二个服务抛异常,断言第一个服务回滚)防止 Seata 配置失效而不自知
P2引入 Sentinel 对 Seata 的 TC 做保护TC 挂了全局事务会挂起

关于最后一条"分布式事务的端到端验证",值得展开

Seata 的配置问题非常隐蔽。 如果:

  • Nacos 上的 shared-spring-seata.yml 里的 tx-service-group 拼错了;
  • 或者 Seata TC(事务协调者)没启动;
  • 或者某个服务没引入 seata-spring-boot-starter;

那么 @GlobalTransactional 会静默失效—— 它不报错,就是不生效,变成一个普通的本地事务。

后果:所有你以为有分布式事务保护的地方,其实都没有。

验证方法(可以主动做,成本很低):

java
@Test
void Seata全局事务有效性验证() {
    // 1. 确认 XID 传递:让某个服务把 XID 打印出来
    //    Seata 的 RootContext.bind(xid) 之后,Feign 会自动传播

    // 2. 构造回滚场景
    //    在 work 的 updateStatus 里加一个测试专用的开关:
    //    if (System.getProperty("test.fail.after.update") != null) throw new RuntimeException();

    // 3. 断言:
    //    - OMS 的 cc_order.status 应该还是原值(回滚了)
    //    - work 的 cc_pickup_dispatch_task.status 应该还是原值
    //    - 如果两个都改了 → 说明 Seata 没生效
}

同样的道理适用于 Redisson: 要在测试环境真的起一个 Redis, 验证 tryLock 的并发互斥和 unlock 的正确性,而不是只测业务方法。


六、面试问答

Q1:什么时候用分布式事务?什么时候不用?

A: 我的判断标准是三个问题:

text
Q1: 中间状态用户看得见吗?
Q2: 能接受这个中间状态持续多久?
Q3: 失败了能不能补偿?

看得见 + 不能等 + 难补偿 → 分布式事务。 看不见 + 可以等 + 好补偿 → 本地事务 + MQ 最终一致。

具体例子:

场景选择原因
取消订单(OMS + work)Seata中间态"订单取消了快递员还要去取",用户看得见,后果是白跑
下单后通知调度MQ 延迟消息中间态"订单已建还没派单"是业务本来就要持续几秒的
退款补偿表 + 轮询外部 HTTP 接口根本不能回滚
消息发送本地消息表/outboxSeata 只管关系库,管不了 MQ

核心原则一句话:Seata 的适用边界是"关系库之间的强一致", 一旦涉及 MQ、Redis、第三方 HTTP,就不应该指望它。

Q2:Seata AT 模式的原理?它的代价是什么?

A: AT 是两阶段 + 全局锁 + undo_log:

text
一阶段:业务 SQL 正常执行并提交,同时把数据的前后镜像写进 undo_log
二阶段成功:删 undo_log
二阶段失败:拿 before_image 回滚,但要先【校验 after_image 没被别人改过】
           —— 这个校验需要持有全局锁

四个代价:

  1. 二阶段持全局锁 —— 同一个业务主键上的并发全局事务会串行,

高频场景下是性能瓶颈;

  1. 脏写问题 —— 如果别的应用在 Seata 之外改了同一条数据,

二阶段校验失败,Seata 无法自动处理,需要人工介入;

  1. 只支持关系库 —— MongoDB / ES / Redis 都不行,得用 TCC 或 Saga;
  2. 有额外 IO —— 每次全局事务要写 undo_log,

而且 undo_log 表需要定期归档清理。

它的核心优点是无侵入:业务代码基本不用改,加个注解就行。 所以我的建议是:能用 AT 就别上 TCC,能用最终一致就别上 Seata。

Q3:你的幂等是怎么做的?

A: 我用的是三道防线,因为它们解决的是不同层面的问题:

text
第 1 道:分布式锁(Redisson 公平锁)
  解决【并发】—— 同一时刻只有一个请求进入临界区
  为什么要公平锁:司机端是高频操作,非公平锁会导致某些请求长期抢不到而饿死

第 2 道:状态机前置校验
  解决【顺序】—— 用旧状态做了不该做的决策
  实现:if (status != PROCESSING) throw

第 3 道:业务短路(最重要,也最容易做错)
  解决【跨服务的最终判定】
  实现:拿到锁后再查一次下游,已结束就直接 return
  ★ 必须 return 而不是 throw —— 因为 throw 会触发 Seata 回滚整个全局事务,
    把一个"重复提交"变成了"破坏性回滚"

这三条是层层递进的:锁保证同时只有一个, 状态机保证顺序正确,业务短路保证最终判定准确。

追问:为什么第 2 道和第 3 道都要? 因为第 2 道只能校验本地状态, 而这个场景要判断的"任务是否已结束"在另一个服务(work)。 锁只能解决并发,解决不了"我拿锁的这段时间里别人已经处理完了"。

Q4:分布式锁有什么坑?

A: 我踩过三个:

  1. finally { unlock() } 写在 if (tryLock()) 外面

—— tryLock 返回 false 时 unlock 会抛 IllegalMonitorStateException,把原本的业务异常覆盖掉。 trade 服务有 4 处这个问题,driver 服务是对的。 修复是抽一个 withLock 模板方法统一。

  1. 锁粒度选错 —— 有的地方按 transportTaskId 加锁(对的),

有的地方按 tradingOrderNo 加锁(也对), 但粒度太粗会严重影响吞吐。 锁粒度的原则是"能保证业务正确性的最小粒度"。

  1. 没有 leaseTime 时的看门狗 —— tryLock(waitTime) 不传 leaseTime,

Redisson 会开 watchdog 每 10 秒续期到 30 秒。 这是好事(业务执行很久不会锁超时), 但前提是必须保证 unlock() 一定会执行, 否则会死锁 30 秒。服务 crash 时靠的是"锁持有者线程死了"来释放。

追问:Redisson 的锁能跨进程吗? 能,因为它是基于 Redis 的分布式锁。 但要注意:锁只能保证"我的临界区不被打断",不能保证"我的操作一定成功"。 比如我在锁里调了一个 HTTP 接口,超时了——锁释放了,但接口可能已经在下游执行了。 这叫"锁的超时与业务的超时不一致",需要下游也做幂等。

Q5:如果让你重新设计这个项目的分布式一致性方案?

A: 我会分三层来改,而不是简单地"全都上 Seata":

第一层:能不用分布式事务就不用

text
先问:这个中间态业务能接受吗?
能接受 → 本地事务 + 业务消息表(outbox)+ 定时补偿

理由:Seata 的运维成本(TC 高可用、undo_log 归档、全局锁排查) 远高于"最终一致 + 补偿"的成本。

第二层:必须强一致的场景,用状态机代替分布式事务

这是我认为最重要的一点: 很多场景下,"设计成强一致"不如"设计成状态机 + 补偿"更可靠。

text
// 与其:Seata 保证订单和任务同时取消
// 不如:任务表加一个"订单状态"冗余字段,
//      所有状态变更走【条件更新】,天然幂等且可重试
UPDATE cc_pickup_dispatch_task
SET status = 'CANCELLED'
WHERE order_id = ? AND status = 'NEW'

条件更新(CAS)本身就是一种幂等手段, 而且它在任何一层都能用(数据库、缓存、MQ 消费), 不依赖 Seata TC 的可用性。

第三层:必须用 Seata 的地方,把它包在一个独立的编排服务里

text
web-* BFF  →  order-orchestrator(专门的编排服务)
                  ├── @GlobalTransactional
                  ├── 只做编排,不含业务逻辑
                  └── 可以独立扩缩容、独立降级

好处: 事务边界收敛到一个地方,不再散落在 4 个 BFF 里; 编排服务可以做更严格的监控(全局事务的成功率、平均耗时、回滚率); 业务服务只管单库事务,不需要理解 Seata。

这最后一点我觉得最重要: 分布式事务是"架构级"的决策,不应该由业务开发的随手决定。


七、源码定位速查

文件行作用
ms-web-customer/.../OmsServiceImpl.java396-415@GlobalTransactional 取消订单(唯一涉及 OMS 的)
ms-web-courier/.../TaskServiceImpl.java567-618@GlobalTransactional 揽收(含 MQ 发送 → 幽灵消息)
同上643@GlobalTransactional 取消任务
ms-driver-service/.../DriverJobServiceImpl.java178-228intoStorage:锁 + 状态机 + 业务短路(三重防护)
同上128, 236, 324, 349其余 @GlobalTransactional
ms-trade-service/.../BasicPayServiceImpl.java84-128⚠️ 退款(finally unlock() 在 if 外)
ms-trade-service/.../NativePayServiceImpl.java54-102⚠️ 同上
ms-trade-service/.../JsapiPayServiceImpl.java33-75⚠️ 同上
ms-trade-service/.../NotifyServiceImpl.java97-133⚠️ 同上(支付回调幂等)
common/constant/Constants.java11-21LOCKS.DRIVER_JOB_LOCK_PREFIX
ms-oms-service/.../OrderServiceImpl.java93-237下单主链路(无幂等、无 GlobalTransactional)
ms-oms-service/.../CrudOrderServiceImpl.java64-85@Transactional 本地事务(独立 bean 避免自调用)