本节目标:建立「能不用分布式事务就不用」的判断力,分清 2PC/TCC/Saga/本地消息表各自的边界,并掌握本地消息表 + 轮询补偿在跨服务写操作里的完整落地方式。
适用版本:Spring Boot 4.1.x(Java 21)
12.3 分布式事务的取舍
12.1 和 12.2 解决的都是「单个数据库、单个事务」内的边界与并发问题。但真实系统里,一次业务操作常常要跨多个服务或数据源:借书成功之后,要扣减另一个服务的库存、要发一条通知。这些操作分属不同的数据库、不同的进程,本地事务的 commit / rollback 管不到它们。
这时最自然的想法是「用分布式事务把大家绑在一起」。但分布式事务不是银弹——它带来的是更高的延迟、更低的可用性、更复杂的运维,而它换来的「强一致」在很多业务里其实并不需要。本节的核心结论先摆出来:能用本地事务 + 最终一致性解决的,就不要上 2PC。 涉及 Seata、RocketMQ 等中间件的输出,本文一律标注为「示例输出」,本机没有部署这些组件。
12.3.1 先问:这个场景真的需要分布式事务吗
动手选型前,先过三道问题:
- 这些写操作能不能塞进一个数据库? 如果「借阅记录」和「库存」本可以在同一个库里,那就该合并数据源,用本地事务解决——分布式事务的第一优先级是「避免分布式」。
- 不一致的窗口能被业务容忍多久? 发通知晚 1 秒、库存补偿晚几秒,用户通常无感。如果业务能接受「最终一致」,本地消息表就够了。
- 不一致会不会造成资金或库存的硬错误? 会,就要用「本地事务保证关键一步 + 幂等 + 对账补偿」,而不是直接上强一致的 2PC。
大多数「看起来需要分布式事务」的场景,答案其实是第 2 或第 3 种。真正的强一致跨库事务,只在少数核心链路里才值得。
12.3.2 2PC / XA:强一致,但代价高
两阶段提交(2PC)通过一个协调者,让所有参与者在第一阶段 prepare、第二阶段统一 commit / rollback。XA 是它的标准协议。它的吸引力在于「要么全成功要么全回滚」的强一致语义。
但代价很重:
- 同步阻塞:第一阶段锁住所有参与者的资源,直到第二阶段结束才释放。参与者越多、耗时越长,锁持有越久。
- 协调者单点:协调者崩溃会让参与者长时间处于「不确定」状态。
- 可用性下降:任一参与者故障,整个事务卡住。系统整体可用性约等于所有参与者可用性的乘积。
- 性能差:跨库同步往返,吞吐远低于本地事务。
在 Spring 生态里,XA 通常通过 JtaTransactionManager 配合支持 XA 的数据源实现。结论:除非是极少数对强一致有硬要求、且链路很短的场景,否则不要用 2PC。 它把一个原本「部分可用」的系统,变成了「全有或全无」。
12.3.3 TCC:把一致性拆成三段
TCC(Try-Confirm-Cancel)把一次跨服务操作拆成三个方法:
| 阶段 | 作用 | 要求 |
|---|---|---|
| Try | 预留资源(冻结库存、冻结额度) | 幂等、可空回滚 |
| Confirm | 确认使用预留资源 | 幂等,不再做业务判断 |
| Cancel | 释放预留资源 | 幂等,能处理「Try 没执行」的空回滚 |
以借书为例,库存服务的 Try 是把 availableCopies 冻结 1(记到 frozenCopies),Confirm 是真正扣减,Cancel 是把冻结退回。
TCC 的优点是不长时间锁数据库行(只在 Try 阶段短暂锁定)、最终一致、性能比 2PC 好。缺点是侵入性极强:每个参与方都要实现三个方法,还要处理幂等、空回滚、悬挂(Cancel 比 Try 先到)。业务代码复杂度显著上升,只有核心链路才值得。
12.3.4 Saga:一串本地事务 + 补偿
Saga 把长事务拆成一串本地事务,每个本地事务都有对应的补偿操作;任一步失败,就逆序执行前面已成功步骤的补偿。
- 编排式(orchestration):有一个中心协调器按顺序调用各步骤。
- 协同式(choreography):各服务通过事件互相触发,没有中心。
Saga 适合「步骤多、耗时较长、能定义补偿」的流程(如订单履约)。它的难点在补偿:有些操作不可补偿(比如已经发出的短信撤不回来),只能靠「业务上的反向操作」近似,并且要接受中间态对外可见。
12.3.5 本地消息表 + 轮询补偿(推荐默认方案)
如果只是「一个本地写成功后,要可靠地通知另一个服务」,最实用的方案是本地消息表(outbox)+ 轮询补偿。它的核心思想只有一句话:把「业务写」和「待发送消息」放进同一个本地事务,再用一个后台任务把消息可靠地投递出去。
先建一张消息表:
create table outbox_message (
id bigserial primary key,
aggregate_type varchar(64) not null,
aggregate_id varchar(64) not null,
event_type varchar(64) not null,
payload text not null,
status varchar(16) not null, -- PENDING / SENT / FAILED
retry_count int not null default 0,
next_retry_at timestamptz not null default now(),
created_at timestamptz not null default now()
);
create index idx_outbox_pending
on outbox_message (status, next_retry_at)
where status = 'PENDING';
对应的实体与 Repository:
@Entity
class OutboxMessage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String aggregateType;
private String aggregateId;
private String eventType;
@Column(columnDefinition = "text")
private String payload;
@Enumerated(EnumType.STRING)
private OutboxStatus status;
private int retryCount;
private Instant nextRetryAt;
private Instant createdAt;
// getter / setter 略
}
关键一步:业务写和消息写在同一个本地事务里。这样只要事务提交,消息一定在表里;事务回滚,消息也不会残留——不需要任何分布式协调。
@Service
class LoanService {
private final BookRepository bookRepository;
private final LoanRepository loanRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
LoanService(BookRepository bookRepository,
LoanRepository loanRepository,
OutboxRepository outboxRepository,
ObjectMapper objectMapper) {
this.bookRepository = bookRepository;
this.loanRepository = loanRepository;
this.outboxRepository = outboxRepository;
this.objectMapper = objectMapper;
}
@Transactional
public LoanResponse borrow(Long bookId, Long memberId) throws JsonProcessingException {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new BookNotFoundException(bookId));
book.decrementCopies();
Loan loan = loanRepository.save(Loan.create(book, memberId, Instant.now()));
OutboxMessage message = new OutboxMessage();
message.setAggregateType("Loan");
message.setAggregateId(String.valueOf(loan.getId()));
message.setEventType("LOAN_CREATED");
message.setPayload(objectMapper.writeValueAsString(LoanEvent.from(loan)));
message.setStatus(OutboxStatus.PENDING);
message.setNextRetryAt(Instant.now());
outboxRepository.save(message); // 与业务写在同一个事务
return LoanResponse.from(loan);
}
}
注意 ObjectMapper:Spring Boot 4.x 已切到 Jackson 3(包名 tools.jackson),自动配置改用 JsonMapper。如果你注入的是框架自动配置的映射器,类型以实际为准;这里用变量名 objectMapper 只是习惯,不要据此假设它还是 Jackson 2 的 com.fasterxml.jackson.databind.ObjectMapper。
后台轮询任务负责把 PENDING 的消息发出去:
@Component
class OutboxRelay {
private final OutboxRepository outboxRepository;
private final NotificationClient notificationClient;
OutboxRelay(OutboxRepository outboxRepository, NotificationClient notificationClient) {
this.outboxRepository = outboxRepository;
this.notificationClient = notificationClient;
}
@Scheduled(fixedDelay = 1000)
@Transactional
public void relay() {
List<OutboxMessage> pending = outboxRepository
.findTop50ByStatusOrderByIdAsc(OutboxStatus.PENDING);
for (OutboxMessage message : pending) {
try {
notificationClient.send(message.getPayload());
message.setStatus(OutboxStatus.SENT);
} catch (Exception ex) {
message.setRetryCount(message.getRetryCount() + 1);
message.setNextRetryAt(Instant.now().plusSeconds(backoff(message.getRetryCount())));
if (message.getRetryCount() >= 10) {
message.setStatus(OutboxStatus.FAILED); // 转人工/告警
}
}
}
}
private long backoff(int retryCount) {
return Math.min(60L, 1L << Math.min(retryCount, 6)); // 指数退避,上限 60s
}
}
这个方案要成立,必须补上三个保障:
- 消费端幂等:消息可能被重复投递(比如
send成功但标记SENT前崩溃)。消费端必须能识别重复,用消息 id 或业务唯一键去重。 - 发送与标记的顺序:先
send再标记SENT,宁可重复也不漏。反之若先标记再发送,中途崩溃就会永久丢失。 next_retry_at真正生效:上面为了简洁省略了按next_retry_at过滤,生产查询要写成status = 'PENDING' and next_retry_at <= now(),否则退避形同虚设。
本机没有部署数据库与消息中间件,本节所有 SQL、日志与「消息被投递」的描述均为示例输出,用于说明形态,不是实测结果。真正落地需要接一个数据库实例,并让轮询任务与消费者跑起来。
12.3.6 事务消息:把「发消息」也纳入事务
RocketMQ 等消息中间件提供「事务消息」:生产者先发一条半消息(对消费者不可见),执行本地事务,再根据本地事务结果提交或回滚半消息。它把「本地事务」与「消息投递」对齐,效果上等价于本地消息表,但把 outbox 的职责交给了消息中间件。
它和本地消息表的取舍:
- 事务消息依赖中间件支持,运维上多一个能力要求;本地消息表只依赖自己的数据库,最通用。
- 本地消息表需要自己实现轮询与补偿;事务消息由中间件负责回查。
- 两者都只保证「消息可靠发出」,消费端仍需幂等。
本文不部署 RocketMQ,也不部署 Seata 等分布式事务中间件。涉及事务消息的调用代码与输出均为示例输出,不构成实测。
12.3.7 方案对比与选型
| 方案 | 一致性 | 延迟 | 侵入性 | 依赖 | 适用 |
|---|---|---|---|---|---|
| 2PC / XA | 强一致 | 高 | 低(框架层) | 支持 XA 的数据源/协调者 | 极少数强一致短链路 |
| TCC | 最终一致 | 中 | 高(三方法) | 事务协调器 | 核心链路、资源可预留 |
| Saga | 最终一致 | 中 | 中(补偿) | 编排/事件总线 | 步骤多、可补偿的长流程 |
| 本地消息表 | 最终一致 | 低 | 低(一张表) | 仅数据库 | 单写 + 可靠通知(推荐默认) |
| 事务消息 | 最终一致 | 低 | 低 | 支持事务消息的 MQ | 已有 MQ 且需要可靠投递 |
选型顺序建议:先问能否合并数据源用本地事务 → 不能则优先本地消息表 → 有 MQ 且团队熟悉则用事务消息 → 只有核心链路、资源可预留时才上 TCC → 2PC 基本不用。
12.3.8 完整场景:借书成功 → 扣减库存 / 发通知
把 12.3.5 的方案落到 book-loan 的完整链路。假设「借阅记录」在本服务,「库存」和「通知」在外部服务:
- 本地事务:写
loan记录、扣减本地可借数、写一条LOAN_CREATED的 outbox 消息,三者原子提交。 - 轮询投递:
OutboxRelay把消息发给库存服务(扣减)和通知服务(发消息)。 - 消费端幂等:库存服务用
loanId去重,通知服务用消息 id 去重。 - 对账补偿:若消息重试 10 次仍失败,标记
FAILED并告警;同时有一个对账任务,定期比对「已借出但库存未扣」的差异并修复。
为什么这样就够了:借书这个操作里,真正不能出错的是「本地那条借阅记录」,它由本地事务保证。库存扣减和通知是派生动作,允许延迟、允许重试,只要最终一致即可。把「必须一致的」和「可以最终一致的」分开,问题就从「分布式事务」降级成了「可靠消息」。
一个反面教材是把这段逻辑写成「先扣库存、再写借阅记录、再发通知」,三个操作没有共同的事务边界,任何一步失败都会留下不一致,且无法自愈。
12.3.9 常见坑
坑一:为了「看起来严谨」上 2PC。 2PC 的同步阻塞与可用性代价,往往远超它带来的收益。绝大多数业务要的是最终一致,不是强一致。
坑二:消息表与业务表不在同一事务。 这是本地消息表的命门。业务写和 outbox 写必须在同一个 @Transactional 方法里,否则「业务成功但消息没写」或「消息写了但业务回滚」都会发生。
坑三:先标记 SENT 再发送。 中途崩溃会永久丢消息。顺序必须是「先发送、后标记」,接受重复。
坑四:消费端不做幂等。 可靠投递的代价就是「至少一次」,重复是常态。没有幂等的消费端会产生重复扣减。
坑五:忘记对账兜底。 消息表也会出现「重试耗尽仍失败」的情况,必须有对账任务发现并修复长期不一致,否则差异会一直累积。
坑六:把中间件当成一致性保证。 无论是 Seata 还是事务消息,都只解决「投递可靠」,业务侧的幂等与补偿仍要自己做。
小结
- 分布式事务不是银弹;第一优先级是「避免分布式」——能合并数据源就用本地事务。
- 2PC/XA 强一致但同步阻塞、可用性差,基本不用;TCC 侵入性强,只在核心链路用;Saga 适合可补偿的长流程。
- 本地消息表 + 轮询补偿是「单写 + 可靠通知」的推荐默认方案:业务写与消息写同事务,后台轮询投递,指数退避重试。
- 事务消息把可靠投递交给 MQ,效果等价,但依赖中间件;两者都要求消费端幂等。
- 借书链路的关键是分清「必须一致的本地记录」与「可最终一致的派生动作」,把后者降级成可靠消息。
- 落地必配三件套:消费端幂等、先发送后标记、对账补偿兜底。
到这里,事务与并发的三个层次——本地边界、并发锁、跨服务一致性——就闭环了。下一章转向另一个高频需求:定时任务与批处理。13.1 会讲 Spring 的调度方案选型,以及为什么单机 @Scheduled 在多实例部署下会出问题。
阅读导航:上一节:12.2 乐观锁与悲观锁 · 下一节:13.1 调度方案选型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。