《Spring Boot 实战》12.3 分布式事务的取舍

分布式事务不是银弹。本节对比 2PC/XA、TCC、Saga、本地消息表与事务消息的适用边界,重点给出本地消息表 + 轮询补偿在「借书成功 → 扣减库存 / 发通知」场景下的完整实现思路,并说明本文不部署 Seata 等中间件,以及消费端幂等与对账补偿的必要性。

本节目标:建立「能不用分布式事务就不用」的判断力,分清 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. 这些写操作能不能塞进一个数据库? 如果「借阅记录」和「库存」本可以在同一个库里,那就该合并数据源,用本地事务解决——分布式事务的第一优先级是「避免分布式」。
  2. 不一致的窗口能被业务容忍多久? 发通知晚 1 秒、库存补偿晚几秒,用户通常无感。如果业务能接受「最终一致」,本地消息表就够了。
  3. 不一致会不会造成资金或库存的硬错误? 会,就要用「本地事务保证关键一步 + 幂等 + 对账补偿」,而不是直接上强一致的 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 的完整链路。假设「借阅记录」在本服务,「库存」和「通知」在外部服务:

  1. 本地事务:写 loan 记录、扣减本地可借数、写一条 LOAN_CREATED 的 outbox 消息,三者原子提交。
  2. 轮询投递:OutboxRelay 把消息发给库存服务(扣减)和通知服务(发消息)。
  3. 消费端幂等:库存服务用 loanId 去重,通知服务用消息 id 去重。
  4. 对账补偿:若消息重试 10 次仍失败,标记 FAILED 并告警;同时有一个对账任务,定期比对「已借出但库存未扣」的差异并修复。

为什么这样就够了:借书这个操作里,真正不能出错的是「本地那条借阅记录」,它由本地事务保证。库存扣减和通知是派生动作,允许延迟、允许重试,只要最终一致即可。把「必须一致的」和「可以最终一致的」分开,问题就从「分布式事务」降级成了「可靠消息」。

一个反面教材是把这段逻辑写成「先扣库存、再写借阅记录、再发通知」,三个操作没有共同的事务边界,任何一步失败都会留下不一致,且无法自愈。

12.3.9 常见坑

坑一:为了「看起来严谨」上 2PC。 2PC 的同步阻塞与可用性代价,往往远超它带来的收益。绝大多数业务要的是最终一致,不是强一致。

坑二:消息表与业务表不在同一事务。 这是本地消息表的命门。业务写和 outbox 写必须在同一个 @Transactional 方法里,否则「业务成功但消息没写」或「消息写了但业务回滚」都会发生。

坑三:先标记 SENT 再发送。 中途崩溃会永久丢消息。顺序必须是「先发送、后标记」,接受重复。

坑四:消费端不做幂等。 可靠投递的代价就是「至少一次」,重复是常态。没有幂等的消费端会产生重复扣减。

坑五:忘记对账兜底。 消息表也会出现「重试耗尽仍失败」的情况,必须有对账任务发现并修复长期不一致,否则差异会一直累积。

坑六:把中间件当成一致性保证。 无论是 Seata 还是事务消息,都只解决「投递可靠」,业务侧的幂等与补偿仍要自己做。

小结

  • 分布式事务不是银弹;第一优先级是「避免分布式」——能合并数据源就用本地事务。
  • 2PC/XA 强一致但同步阻塞、可用性差,基本不用;TCC 侵入性强,只在核心链路用;Saga 适合可补偿的长流程。
  • 本地消息表 + 轮询补偿是「单写 + 可靠通知」的推荐默认方案:业务写与消息写同事务,后台轮询投递,指数退避重试。
  • 事务消息把可靠投递交给 MQ,效果等价,但依赖中间件;两者都要求消费端幂等。
  • 借书链路的关键是分清「必须一致的本地记录」与「可最终一致的派生动作」,把后者降级成可靠消息。
  • 落地必配三件套:消费端幂等、先发送后标记、对账补偿兜底。

到这里,事务与并发的三个层次——本地边界、并发锁、跨服务一致性——就闭环了。下一章转向另一个高频需求:定时任务与批处理。13.1 会讲 Spring 的调度方案选型,以及为什么单机 @Scheduled 在多实例部署下会出问题。

阅读导航:上一节:12.2 乐观锁与悲观锁 · 下一节:13.1 调度方案选型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计