Ohhnews

分类导航

$ cd ..
DZone Java原文

事务应该在何处结束?分布式Java系统中的一致性边界设计

#分布式系统#事务边界#业务不变量#微服务#java

微服务并不会消除事务。它们迫使架构师决定事务应该在哪里结束。这个决定比听起来更难。一个曾经在单个数据库事务中完成的业务操作,在分解之后可能会跨越多个服务。一个订单可能涉及库存、支付、定价、客户限额、审计历史和通知。架构可以清晰划分这些职责,但业务仍然只看到一项操作:下单。这正是许多分布式系统变得脆弱的地方。团队先划定服务边界,之后再讨论一致性。到那时,业务规则已经跨越数据库边界,同步调用已经形成冗长的依赖链,而最终一致性也成了替那些没人能清楚解释的状态开脱的通用借口。更好的方法是从业务不变量开始。首先决定哪些事实必须同时保持为真。然后围绕这些事实划定事务边界和服务边界。技术选择应位于这一决定之后。

从不变量开始,而不是从服务开始

一个不变量是一条业务规则:只要某项操作被视为完成,它就必须保持为真。它比普通校验更强。例如:

  • 已确认的预订必须拥有分配给它的座位。
  • 一笔财务转账必须保持借方与贷方分录之间的会计关系。
  • 一个已接受的命令不得重复产生相同的业务效果。
  • 如果所需的支付授权失败,订单不得被标记为已确认。

这些规则定义了真正的一致性边界。如果为了保持某个不变量为真,两份状态必须一起变化,那么最安全的设计通常是把它们放在同一个本地事务中。把它们拆分到多个服务并不会消除这条规则。它会把规则转化为分布式协调问题。这种转化有时可能是必要的,但它应当是一个有意识的权衡。仅仅因为服务边界在架构图上看起来清晰,并不意味着它没有代价。

三类不同的一致性问题

架构师讨论一致性时,常常仿佛系统只有两种选择:强一致性或最终一致性。在实践中,一个分布式工作流通常包含三个不同的边界。

[LOADING...]

图 1. 三个一致性边界:本地原子性、持久化事件发布,以及跨独立提交服务的最终收敛。

1. 原子核心

原子核心包含必须一起成功或一起失败的状态变更。本地 ACID 事务仍然是保护这一边界最简单、最有力的工具。考虑一个订单服务:它接受命令、创建订单、记录订单的初始状态,并保存一个描述已接受订单的持久化事件。如果丢失该事件会导致下游系统永远不知道这个订单,那么订单记录和事件就必须一起提交。这并不需要分布式事务。两条记录可以存放在同一个数据库中,并参与同一个本地事务。

2. 持久化交接

一旦原子状态提交完成,系统就必须把结果传达给其他组件。这是一个持久性问题,而不仅仅是消息传递问题。在应用逻辑中直接向消息代理发布消息会制造一个危险的缺口。数据库提交可能成功,而发布却失败。先发布则会造成相反的问题:另一个服务可能收到一个事件,而该事件对应的状态从未提交。事务性发件箱模式通过将业务变更和待发送事件存储在同一个本地事务中,来弥合这个缺口。随后,一个独立的发布器会把事件发送到消息代理。这种交接是持久化的,因为发布意图能够在进程崩溃和消息代理临时故障后继续存在。

3. 收敛式工作流

事件跨过服务边界之后,工作流就不再是原子的。每个服务会在不同时间提交自己的状态。因此,架构必须定义系统如何达到有效的最终状态。最终一致性适用的正是这里。它应当描述一个明确的收敛过程,而不是一个任何情况都可能发生的、未定义的时期。设计必须回答:

  • 哪些中间状态是有效的?
  • 工作流可以保持未完成状态多久?
  • 当某个步骤永久失败时会发生什么?
  • 已完成的操作是否可以补偿?
  • 哪个组件拥有工作流状态?
  • 支持团队和审计人员如何确定发生了什么?

如果这些问题没有明确答案,那么这个设计就不是最终一致的,而只是不完整的。

使用事务性发件箱的 Java 边界

下面这个简化的 Spring 示例将订单及其待发送事件保持在同一个事务中:

$ java
@Service
public class OrderApplicationService {

    private final OrderRepository orderRepository;
    private final OutboxRepository outboxRepository;

    public OrderApplicationService(
            OrderRepository orderRepository,
            OutboxRepository outboxRepository) {
        this.orderRepository = orderRepository;
        this.outboxRepository = outboxRepository;
    }

    @Transactional
    public OrderId placeOrder(PlaceOrder command) {
        Order order = Order.create(
                command.customerId(),
                command.items());

        orderRepository.save(order);

        OrderPlaced payload = new OrderPlaced(
                order.id().value(),
                order.customerId().value(),
                order.total(),
                order.version());

        outboxRepository.save(
                OutboxMessage.pending(
                        UUID.randomUUID(),
                        "OrderPlaced",
                        order.id().value(),
                        payload));

        return order.id();
    }
}

重要的细节不是框架注解,而是边界的位置。订单和发件箱消息会在同一个数据库事务中提交。要么两者都存在,要么两者都不存在。发件箱发布器可以读取待处理消息并异步发布它们。如果它在发布之后、将消息标记为已发送之前崩溃,它可能会再次发布同一个事件。只有当消费者能够识别重复的业务效果时,这才是可接受的。发件箱解决了原子交接问题,但它并没有消除对幂等处理的需求。这一区别很重要。每种模式都解决一个特定的故障窗口。在不理解其边界的情况下组合模式,只会带来复杂性,而不会带来正确性。

将工作流建模为状态,而不是调用链

一个常见错误是把分布式事务实现为一连串同步调用:

订单 → 库存 → 支付 → 发货 → 通知

当每次调用都成功时,这条链看起来很简单。然而在失败情况下,它的行为就像一笔没有分布式事务保证的分布式事务。假设库存已经预留,支付提供商也完成了授权,但它的响应在到达订单服务之前超时了。订单服务不能安全地假定支付失败。重试可能会创建另一笔授权,而立即开始补偿则可能取消一个已经支付的订单。

这些情况应当被建模为持久化的业务状态,而不是隐藏在请求处理逻辑内部。正常路径会从 PENDING 进入 INVENTORY_RESERVED,然后进入 PAYMENT_PENDING,最后进入 CONFIRMED。明确的支付拒绝会把工作流转入 PAYMENT_FAILED,随后释放库存并取消订单。超时则需要走不同的路径。由于结果不确定,工作流会转入 PAYMENT_STATUS_UNKNOWN。在对订单进行确认还是继续补偿作出决定之前,必须有一个核对步骤去查询支付提供商,或处理后续的支付事件。

[LOADING...]

图 2. 持久化订单工作流区分了确定的支付失败与需要核对的未知支付结果。

每个状态转换都应当有一个持久化的原因、一个稳定的业务标识符、一个预期的前一状态,以及一条幂等规则。拥有工作流的组件必须在发起下一步之前记录进度。采用这种设计后,工作流可以在进程崩溃后恢复,因为它的进度以数据形式存在,而不是只存在于线程或请求栈中。它还为架构师、开发人员和支持团队提供了一种权威解释,说明发生了什么,以及系统接下来应该做什么。这并不意味着每个工作流都需要一个大型编排平台。一张状态表和一个小型协调器可能就足够了。重要的是,中间状态、失败路径和恢复决策都必须是明确的。

最终一致性何时是安全的

当暂时的不一致可以接受,并且收敛过程能够被证明时,最终一致性是一个好选择。典型示例包括:

  • 源记录提交后更新搜索索引
  • 发送确认通知
  • 刷新分析或报表投影
  • 更新推荐或缓存视图
  • 传播非关键的资料变更

这些操作通常可以滞后,而不会违反核心业务事务。在库存、信用额度、支付授权和监管记录方面,决策会变得更加困难。这些领域通常包含无法容忍任意中间状态的规则。它们可能仍然需要分布式工作流,但该工作流必须使用预留、版本控制、过期、核对或其他显式控制。例如,库存不必在订单开始时就永久扣减。它可以被预留一段有限的时间。预留会成为一等领域概念,拥有所有者、过期时间和释放规则。这种模型比假装异步扣减能像本地事务一样工作要更诚实。

每个服务一个数据库是指导原则,而不是起点

数据库所有权很重要,因为共享模式会造成耦合。然而,在理解领域之前,不应先套用“每个服务一个数据库”。如果两份数据参与同一个不变量,并且经常一起变化,那么把它们分开可能会造成更多耦合,而不是更少。这些服务在纸面上可能可以独立部署,但在生产环境中却需要持续的运行时协调。有时,正确的初始架构是一个具有强内部边界和一个事务型数据库的模块化单体。等到业务边界、扩展需求和一致性要求都清楚之后,再抽取服务。这并不是否定微服务,而是认识到分布式应当用来解决真实问题。网络边界对于独立所有权、部署、扩展、安全和故障隔离很有用。当它们被置于一个需要持续协调的不变量内部时,代价会很高。

实用的决策测试

在把一个操作拆分到多个服务之前,架构师应当问以下问题:

问题架构含义
这些变更必须始终一起可见吗?尽可能将它们保留在同一个本地事务中。
业务能容忍不完整状态吗?如果能,定义允许的状态及其最长持续时间。
操作可以安全重试吗?要求稳定的业务标识符和幂等规则。
已完成的操作可以撤销吗?将补偿定义为独立的业务操作。
谁拥有跨服务的进度?使用显式协调器或清晰记录的协同方式。
系统如何检测停滞的工作?持久化截止时间并支持核对。
什么证据可以解释最终状态?记录状态转换、原因、版本和关联标识符。

这些问题应当在选择消息代理、工作流引擎或框架之前得到回答。基础设施可以传输消息并执行重试,但它无法决定业务允许哪些临时状态。

常见的边界错误

在分布式 Java 系统中,有几类设计错误反复出现。

先拆分,后协调

团队围绕名词或技术分层来分解系统,然后发现每个重要操作都会跨越多个服务。由此产生的架构拥有许多服务,但独立业务能力很少。

把一切都称为最终一致

最终一致性被当作一个标签使用,却没有定义预期的最终状态、截止时间或恢复路径。于是,暂时的不一致变成了永久漂移。

把消息代理投递当作业务正确性

消息代理可以重新投递消息,但它无法知道相应的业务效果是否已经发生。正确性必须在业务状态发生变化的地方强制执行。

将工作流状态隐藏在应用代码中

重试、超时和补偿被实现为嵌套调用,却没有持久化的状态模型。崩溃之后,系统无法确定从哪里恢复。

假设补偿总是有效

设计中为每个前进一步都画了一条后退箭头,即使业务操作对时间敏感或不可逆。

架构即保证的放置方式

分布式设计的核心问题不在于系统是否使用微服务、事件、Saga 或发件箱,而在于系统把保证放在哪里。对于必须一起变化的最小状态集合,使用本地事务。当已提交状态必须被传达时,使用持久化交接。当多个服务必须随时间收敛时,使用显式工作流。只在业务确实支持的地方添加补偿。最重要的是,让业务不变量来塑造边界。清晰的服务图很有价值,但它并不比维护使系统正确的规则更重要。

微服务让边界可见。好的架构让这些边界诚实。

DZone 贡献者表达的观点仅代表他们自己。