亮点七 · 取消一个订单,要改两个库
业务问题: 客户点了"取消订单"。 系统要同时做三件事:① 把订单状态改成已取消;② 把取件任务改成已取消; ③ 如果快递员已经到门口了,还要通知调度重新派单。
订单在 OMS 的库,取件任务在 work 的库。这是两个微服务、两个数据库。 如果 ① 成功 ② 失败,客户看到订单已取消, 但快递员手机上还显示"这个单还在,请去取"——快递员白跑一趟,客户投诉。
一、先做一个关键判断:什么时候需要分布式事务
这是面试官最爱问的第一个问题,也是最能区分候选人的问题。
很多候选人的回答是"跨服务就用 Seata"。这是错的。
1.1 先问三个问题
Q1: 两边必须同时成功吗?还是允许中间状态?
Q2: 中间状态持续多久?如果是几毫秒,用户看得见吗?
Q3: 失败了能不能补偿?补偿的业务成本是多少?1.2 三种情况的正确做法
| 情况 | 是否需要分布式事务 | 正确做法 |
|---|---|---|
| 强一致要求、中间态不可见 | ✅ 需要 | Seata AT / TCC |
| 允许短暂中间态、用户无感 | ❌ 不需要 | 本地事务 + MQ 最终一致 |
| 失败可人工/自动补偿 | ❌ 不需要 | 补偿表 + 定时对账 |
1.3 我们项目里的两类场景
场景 A:取消订单(本项目用了 Seata)
// 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,用了延迟消息)
BEGIN
INSERT cc_order ← 本地事务
COMMIT
sendMsg(delay = 5s) ← 事务外,失败落库补偿为什么不用 Seata: 订单落库和消息发送之间有 5 秒的天然延迟, 这 5 秒里订单是"已创建但还没派单"的——这是业务上完全正常的状态 (订单创建和派单本来就是异步的)。 所以不需要强一致,用最终一致就够了。
这是本亮点最重要的一段话:
"我们判断要不要上分布式事务的标准是: 这个中间状态用户看得见吗?业务能接受多久?
取消订单的中间态是'订单取消了但快递员还要去取', 用户和快递员都看得见,而且后果是白跑一趟——所以必须强一致。
但下单的中间态是'订单创建了但还没派单', 这是业务上本来就要持续几秒的正常状态,用户预期里就有。 所以我们用本地事务 + 延迟消息 + 失败补偿, 不用 Seata。
Seata 不是'跨服务就上',它是'中间态不可接受才上'。"
二、三重幂等手段(这才是这个模块的核心)
全项目只有一个地方把三种手段都用上了:driver 服务的 intoStorage。
// 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 三道防线的分工
第 1 道:分布式锁(Redisson 公平锁)
作用:同一时刻只有一个线程能进入临界区
解决:并发请求同时修改同一条数据
粒度:lockKey = "DRIVER_JOB_LOCK_" + transportTaskId (按运输任务粒度)
第 2 道:状态机前置校验
作用:进入业务前先判断当前状态合不合法
解决:顺序问题(用旧状态做了决策)
实现:if (status != PROCESSING) throw
第 3 道:业务短路(关键!)
作用:锁拿到后、真正改数据前,再查一次下游状态
解决:跨服务场景下的"已结束"判定
实现:if (status in {CANCELLED, COMPLETED}) return; ← 注意是 return 不是 throw第 3 道为什么必须是 return 而不是 throw?
这是个很容易答错的细节:
if (已结束) {
return; // ✅ 正常提交
}如果写成:
if (已结束) {
throw new CCException("任务已结束"); // ❌ 会触发 Seata 全局回滚
}后果: 本来这个请求是"重复提交,应该被忽略", 但抛异常会导致 Seata 把整个全局事务回滚—— 包括其它服务已经成功的操作。 一个幂等的重复请求,变成了一个破坏性的回滚。
这个点是本亮点最能体现"理解分布式事务语义"的地方。 面试时一定要讲,因为它区分了"看过代码"和"理解语义"。
2.2 为什么用公平锁(Fair Lock)
RLock lock = this.redissonClient.getFairLock(lockRedisKey);getLock()(非公平) | getFairLock()(公平) | |
|---|---|---|
| 分配 | 谁先抢到谁进 | 按请求到达顺序 FIFO |
| 性能 | 略高 | 略低(要维护队列) |
| 饥饿 | 可能有线程长期抢不到 | 不会饥饿 |
业务场景: 司机端入库操作是高频的(司机在装货过程中可能反复点"到达")。 如果用非公平锁,某个 unlucky 的司机会一直抢不到锁, 其它司机都在等,运输任务卡住。 公平锁保证了 FIFO,不会有人被饿死。
为什么不用 RedissonClient.getLock()? 因为这个场景是"多个司机可能同时对同一批任务操作", 公平性比吞吐量更重要。
tryLock() 的参数要注意:
// 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)模式的两阶段:
一阶段:
业务 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 模式的三个优点:
- 无侵入 —— 业务代码几乎不用改(加个
@GlobalTransactional就行); - 不阻塞 —— 一阶段就提交了,不持有数据库行锁;
- 对业务友好 —— 不用写 try/confirm/cancel 三套方法。
三个代价(必须主动说出来):
| 代价 | 说明 |
|---|---|
| 全局锁竞争 | 二阶段校验需要持有 undo_log 的全局锁,高并发同一行会串行 |
| 只支持关系库 | MongoDB / ES / Redis 不支持(要用 TCC 或 Saga) |
| 脏写风险 | 二阶段校验失败时无法自动处理,需要人工介入 |
| 性能开销 | 每次全局事务要写 undo_log,多一次 IO |
| 不能跨非事务资源 | Seata 回滚不了 MQ 和 Redis —— 见坑 1 |
这个"不能跨非事务资源"是最重要的,必须讲。
难点 2:什么时候用 Seata,什么时候用 MQ 最终一致
决策树(这是可以直接背的答案):
Q: 两边的数据必须"同时"可见吗?
├─ 是 → Q: 允许中间态存在多久?
│ ├─ 不能有任何中间态 → Seata AT / TCC
│ └─ 可以有几秒/几分钟 → 本地事务 + MQ(最终一致)
└─ 否(可以异步) → 本地事务 + MQ具体到本项目的两个对比:
| 取消订单 | 下单通知调度 | |
|---|---|---|
| 两个动作的间隔 | 必须为 0 | 天然有几秒 |
| 中间态的危害 | 快递员白跑、客户投诉 | 无(本来就要异步派单) |
| 用什么 | Seata | 本地事务 + 延迟消息 + 补偿 |
| 代码位置 | web-customer#cancel | oms#mailingSave → noticeOrderStatusChange |
第三种情况(补偿型):
Q: 失败之后能不能接受"人工介入"?
├─ 不能 → 强一致
└─ 能 → 补偿表 + 定时对账(成本低很多)补偿型方案的例子:退款 退款要调微信接口,不可能用 Seata(外部 HTTP 不可回滚)。 标准做法是:
本地事务:把退款单置为"退款中" + 累加退款金额 → 提交
↓
异步:调微信退款接口
↓
回调/轮询:把退款单置为"成功"或"失败"
↓
对账任务:扫描长时间"退款中"的单,人工或自动处理这个项目里 refundJob 就是干这个的(见亮点 8)。
四、实际遇到的困难与踩坑
坑 1:Seata 回滚不了 MQ,产生"幽灵消息"
事实:
// 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;
}问题:
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)
@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)
@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 处):
// 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(); // ← 不管有没有拿到锁,都会执行
}问题:
if (lock.tryLock(...)) { ... return true; }
throw new CCException(...); // 抛出 → 进入 finally → lock.unlock()
// 但这把锁根本不是当前线程持有的!
// → Redisson 抛 IllegalMonitorStateException
// → 这个异常会【覆盖】掉原来要抛的 CCException后果: 原本清晰的业务异常被一个底层框架异常覆盖, 排查问题时看到的堆栈是 IllegalMonitorStateException 而不是业务错误, 非常误导。
涉及文件(全部 4 处):
| 文件 | 行 |
|---|---|
trade/service/impl/NativePayServiceImpl.java | 100 |
trade/service/impl/JsapiPayServiceImpl.java | 73 |
trade/service/impl/BasicPayServiceImpl.java | 80, 126, 159 |
trade/service/impl/NotifyServiceImpl.java | 130 |
正确写法(driver 服务是对的):
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 服务(正确):
if (lock.tryLock()) {
try {
// 业务
} finally {
lock.unlock(); // ← 在 if 里面,只在拿到锁时才解锁
}
} else {
throw new CCException(...);
}这是同一个项目里"一处写对、一处写错"的对比,很适合在面试里讲。 它说明:不是不会,是没统一。 修复方式不是逐个改,而是把加锁逻辑抽成一个模板方法:
// 统一的锁模板,杜绝这类错误
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.java | updateStatus / intoStorage / 4 个司机作业方法 |
web-courier TaskServiceImpl.java | pickup / cancel |
web-customer OmsServiceImpl.java | cancel ← 唯一涉及订单的 |
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 订单创建完全没有幂等
事实:
// MailingSaveDTO 的完整字段
sendAddress, receiptAddress, pickUpTime, pickupType, payMethod,
goodsType, goodsName, goodNum, totalVolume, totalWeight,
measureLong, measureWidth, measureHigh, mark, memberId
// ↑ 没有 requestId,没有 idempotentKey,没有 orderNoOrderServiceImpl#mailingSave 里也没有任何去重检查、没有 Redis 锁、没有唯一键。
后果: 用户网络卡顿,点了两次"确认下单",就生成两张订单。 客户会收到两条取件通知,财务对账多一笔。
这在移动端是高频问题——按钮没有防抖 + 网络重试 + 用户手抖。
修复方案(三层):
// 第 1 层:前端幂等键(最标准)
// 客户端生成一个 requestId(UUID),下单时带上
// 服务端按 (memberId, requestId) 唯一索引拦截// 第 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();
}
}// 第 3 层:业务唯一键兜底
// 对强幂等要求的场景(比如支付回调),靠数据库唯一索引注意第 2 层的语义选择: 重复提交应该返回第一次的结果,而不是报"重复提交"错误。 因为对用户来说,他想要的是"我刚才那单成了没有",报错会让他更困惑。
五、如果重做,我会怎么改
| 优先级 | 改动 | 收益 |
|---|---|---|
| P0 | trade 的 4 处 finally unlock() 改为条件解锁 | 消除异常被覆盖 |
| P0 | OMS 下单加幂等(requestId + Redis 短窗口) | 消除重复订单 |
| P1 | 抽 withLock 模板方法,全项目统一锁用法 | 从根上消除锁的写法错误 |
| P1 | pickup 改用 @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 会静默失效—— 它不报错,就是不生效,变成一个普通的本地事务。
后果:所有你以为有分布式事务保护的地方,其实都没有。
验证方法(可以主动做,成本很低):
@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: 我的判断标准是三个问题:
Q1: 中间状态用户看得见吗?
Q2: 能接受这个中间状态持续多久?
Q3: 失败了能不能补偿?看得见 + 不能等 + 难补偿 → 分布式事务。 看不见 + 可以等 + 好补偿 → 本地事务 + MQ 最终一致。
具体例子:
| 场景 | 选择 | 原因 |
|---|---|---|
| 取消订单(OMS + work) | Seata | 中间态"订单取消了快递员还要去取",用户看得见,后果是白跑 |
| 下单后通知调度 | MQ 延迟消息 | 中间态"订单已建还没派单"是业务本来就要持续几秒的 |
| 退款 | 补偿表 + 轮询 | 外部 HTTP 接口根本不能回滚 |
| 消息发送 | 本地消息表/outbox | Seata 只管关系库,管不了 MQ |
核心原则一句话:Seata 的适用边界是"关系库之间的强一致", 一旦涉及 MQ、Redis、第三方 HTTP,就不应该指望它。
Q2:Seata AT 模式的原理?它的代价是什么?
A: AT 是两阶段 + 全局锁 + undo_log:
一阶段:业务 SQL 正常执行并提交,同时把数据的前后镜像写进 undo_log
二阶段成功:删 undo_log
二阶段失败:拿 before_image 回滚,但要先【校验 after_image 没被别人改过】
—— 这个校验需要持有全局锁四个代价:
- 二阶段持全局锁 —— 同一个业务主键上的并发全局事务会串行,
高频场景下是性能瓶颈;
- 脏写问题 —— 如果别的应用在 Seata 之外改了同一条数据,
二阶段校验失败,Seata 无法自动处理,需要人工介入;
- 只支持关系库 —— MongoDB / ES / Redis 都不行,得用 TCC 或 Saga;
- 有额外 IO —— 每次全局事务要写
undo_log,
而且 undo_log 表需要定期归档清理。
它的核心优点是无侵入:业务代码基本不用改,加个注解就行。 所以我的建议是:能用 AT 就别上 TCC,能用最终一致就别上 Seata。
Q3:你的幂等是怎么做的?
A: 我用的是三道防线,因为它们解决的是不同层面的问题:
第 1 道:分布式锁(Redisson 公平锁)
解决【并发】—— 同一时刻只有一个请求进入临界区
为什么要公平锁:司机端是高频操作,非公平锁会导致某些请求长期抢不到而饿死
第 2 道:状态机前置校验
解决【顺序】—— 用旧状态做了不该做的决策
实现:if (status != PROCESSING) throw
第 3 道:业务短路(最重要,也最容易做错)
解决【跨服务的最终判定】
实现:拿到锁后再查一次下游,已结束就直接 return
★ 必须 return 而不是 throw —— 因为 throw 会触发 Seata 回滚整个全局事务,
把一个"重复提交"变成了"破坏性回滚"这三条是层层递进的:锁保证同时只有一个, 状态机保证顺序正确,业务短路保证最终判定准确。
追问:为什么第 2 道和第 3 道都要? 因为第 2 道只能校验本地状态, 而这个场景要判断的"任务是否已结束"在另一个服务(work)。 锁只能解决并发,解决不了"我拿锁的这段时间里别人已经处理完了"。
Q4:分布式锁有什么坑?
A: 我踩过三个:
finally { unlock() }写在if (tryLock())外面
—— tryLock 返回 false 时 unlock 会抛 IllegalMonitorStateException,把原本的业务异常覆盖掉。 trade 服务有 4 处这个问题,driver 服务是对的。 修复是抽一个 withLock 模板方法统一。
- 锁粒度选错 —— 有的地方按
transportTaskId加锁(对的),
有的地方按 tradingOrderNo 加锁(也对), 但粒度太粗会严重影响吞吐。 锁粒度的原则是"能保证业务正确性的最小粒度"。
- 没有 leaseTime 时的看门狗 ——
tryLock(waitTime)不传 leaseTime,
Redisson 会开 watchdog 每 10 秒续期到 30 秒。 这是好事(业务执行很久不会锁超时), 但前提是必须保证 unlock() 一定会执行, 否则会死锁 30 秒。服务 crash 时靠的是"锁持有者线程死了"来释放。
追问:Redisson 的锁能跨进程吗? 能,因为它是基于 Redis 的分布式锁。 但要注意:锁只能保证"我的临界区不被打断",不能保证"我的操作一定成功"。 比如我在锁里调了一个 HTTP 接口,超时了——锁释放了,但接口可能已经在下游执行了。 这叫"锁的超时与业务的超时不一致",需要下游也做幂等。
Q5:如果让你重新设计这个项目的分布式一致性方案?
A: 我会分三层来改,而不是简单地"全都上 Seata":
第一层:能不用分布式事务就不用
先问:这个中间态业务能接受吗?
能接受 → 本地事务 + 业务消息表(outbox)+ 定时补偿理由:Seata 的运维成本(TC 高可用、undo_log 归档、全局锁排查) 远高于"最终一致 + 补偿"的成本。
第二层:必须强一致的场景,用状态机代替分布式事务
这是我认为最重要的一点: 很多场景下,"设计成强一致"不如"设计成状态机 + 补偿"更可靠。
// 与其:Seata 保证订单和任务同时取消
// 不如:任务表加一个"订单状态"冗余字段,
// 所有状态变更走【条件更新】,天然幂等且可重试
UPDATE cc_pickup_dispatch_task
SET status = 'CANCELLED'
WHERE order_id = ? AND status = 'NEW'条件更新(CAS)本身就是一种幂等手段, 而且它在任何一层都能用(数据库、缓存、MQ 消费), 不依赖 Seata TC 的可用性。
第三层:必须用 Seata 的地方,把它包在一个独立的编排服务里
web-* BFF → order-orchestrator(专门的编排服务)
├── @GlobalTransactional
├── 只做编排,不含业务逻辑
└── 可以独立扩缩容、独立降级好处: 事务边界收敛到一个地方,不再散落在 4 个 BFF 里; 编排服务可以做更严格的监控(全局事务的成功率、平均耗时、回滚率); 业务服务只管单库事务,不需要理解 Seata。
这最后一点我觉得最重要: 分布式事务是"架构级"的决策,不应该由业务开发的随手决定。
七、源码定位速查
| 文件 | 行 | 作用 |
|---|---|---|
ms-web-customer/.../OmsServiceImpl.java | 396-415 | @GlobalTransactional 取消订单(唯一涉及 OMS 的) |
ms-web-courier/.../TaskServiceImpl.java | 567-618 | @GlobalTransactional 揽收(含 MQ 发送 → 幽灵消息) |
| 同上 | 643 | @GlobalTransactional 取消任务 |
ms-driver-service/.../DriverJobServiceImpl.java | 178-228 | intoStorage:锁 + 状态机 + 业务短路(三重防护) |
| 同上 | 128, 236, 324, 349 | 其余 @GlobalTransactional |
ms-trade-service/.../BasicPayServiceImpl.java | 84-128 | ⚠️ 退款(finally unlock() 在 if 外) |
ms-trade-service/.../NativePayServiceImpl.java | 54-102 | ⚠️ 同上 |
ms-trade-service/.../JsapiPayServiceImpl.java | 33-75 | ⚠️ 同上 |
ms-trade-service/.../NotifyServiceImpl.java | 97-133 | ⚠️ 同上(支付回调幂等) |
common/constant/Constants.java | 11-21 | LOCKS.DRIVER_JOB_LOCK_PREFIX |
ms-oms-service/.../OrderServiceImpl.java | 93-237 | 下单主链路(无幂等、无 GlobalTransactional) |
ms-oms-service/.../CrudOrderServiceImpl.java | 64-85 | @Transactional 本地事务(独立 bean 避免自调用) |