PAYATHON 2026

如何处理可能乱序的 Domain (integration) events?

支付老李

结论

不要按照事件到达的顺序直接覆盖 Order.Status。

Ordering BC 需要限制状态只能合法迁移,但更好的建模方式是,不要把”已付款”和”已发货”压缩到一个二选一的字段里。这是两个独立且存在因果关系的事实,应该分别记录:

PaymentStatus = Processed
ShippingStatus = Shipped

如果业务仍然需要统一的 Order.Status,应从这些事实推导,而不是让每个 integration event 直接为它赋值。这样,即使 PaymentProcessed 晚于 OrderShipped 到达,也不会把已经发货的订单降级为 Payed。

问题的根源

下面的事件处理方式假定消息会严格按照业务发生的顺序到达:

void Handle(PaymentProcessed @event)
{
    order.Status = OrderStatus.Payed;
}

void Handle(OrderShipped @event)
{
    order.Status = OrderStatus.Shipped;
}

这个假定在异步系统中并不可靠。消息可能因为重试、不同的 topic 或 queue、消费者并发、网络延迟等原因乱序到达。即使消息系统提供顺序保证,通常也只限于特定的 partition、session 或 message group,并不会自动保证跨 BC、跨消息流的全局顺序。

因此,不能用事件的到达顺序判断业务事实的发生顺序。

首选方案:分别保存独立事实

“付款完成”和”订单已发货”不是同一个维度的状态。可以把模型改成:

public enum PaymentStatus
{
    Pending,
    Processed,
    Failed
}

public enum ShippingStatus
{
    NotReady,
    Ready,
    Shipped
}

public sealed class Order
{
    public Guid Id { get; private set; }

    public PaymentStatus PaymentStatus { get; private set; }
    public ShippingStatus ShippingStatus { get; private set; }

    public OrderStatus Status =>
        ShippingStatus == ShippingStatus.Shipped
            ? OrderStatus.Shipped
            : PaymentStatus == PaymentStatus.Processed
                ? OrderStatus.Payed
                : OrderStatus.Pending;

    public void ApplyPaymentProcessed()
    {
        PaymentStatus = PaymentStatus.Processed;
    }

    public void ApplyOrderShipped()
    {
        ShippingStatus = ShippingStatus.Shipped;
    }
}

即使按下面的顺序处理事件:

OrderShipped
PaymentProcessed

最终结果仍然是:

PaymentStatus  = Processed
ShippingStatus = Shipped
Status         = Shipped

模型保存的是已经确认发生的事实。后到的付款事件只会补充付款信息,不会撤销已经发生的发货事实。

实际项目中,Payed 通常应拼写为 Paid。如果现有 API 或数据库枚举已经使用 Payed,重命名时需要考虑兼容性。

状态机仍然有必要

如果 Order.Status 确实表示领域模型中的单一生命周期状态,那么使用状态机或显式的状态迁移规则是合理的。处理事件时应拒绝状态降级,而不是无条件覆盖:

public sealed class Order
{
    public OrderStatus Status { get; private set; }

    public void Apply(PaymentProcessed @event)
    {
        switch (Status)
        {
            case OrderStatus.Pending:
                Status = OrderStatus.Payed;
                break;

            case OrderStatus.Payed:
            case OrderStatus.Shipped:
                // 已处理,或者当前状态已经更靠后。
                break;

            default:
                throw new InvalidOperationException(
                    $"Cannot apply PaymentProcessed when order is {Status}.");
        }
    }

    public void Apply(OrderShipped @event)
    {
        switch (Status)
        {
            case OrderStatus.Pending:
            case OrderStatus.Payed:
                Status = OrderStatus.Shipped;
                break;

            case OrderStatus.Shipped:
                // 幂等处理。
                break;

            default:
                throw new InvalidOperationException(
                    $"Cannot apply OrderShipped when order is {Status}.");
        }
    }
}

这段代码包含一个需要明确的业务决定:是否允许尚未观察到 PaymentProcessed 的订单进入 Shipped。

如果 OrderShipped 是 Shipping BC 已确认发生的事实,Ordering BC 通常不应因为本地尚未收到付款事件而否认它。此时可以接受 Pending -> Shipped,再由迟到的 PaymentProcessed 补齐付款信息。

如果业务规定”未确认付款绝不能发货”,这一约束主要应由负责启动发货流程的 BC 或 process manager 保证,不能只依赖 Ordering BC 的投影状态。

不要仅依赖事件时间戳

为事件增加 OccurredAt 有助于审计,但不能单独解决事件顺序问题:

{
  "eventId": "2fa3...",
  "orderId": "8471...",
  "occurredAt": "2026-09-01T08:30:00Z",
  "eventType": "PaymentProcessed"
}

不同服务的时钟可能存在偏差,事件的时间精度也可能不够。消费者不能简单地认为时间戳更大的事件一定对应更高的业务状态。

如果多个事件来自同一个有序事件流,可以使用生产者分配的单调递增版本号:

{
  "eventId": "2fa3...",
  "aggregateId": "8471...",
  "aggregateVersion": 12,
  "eventType": "PaymentProcessed"
}

消费者可以记录最后处理的版本:

if (@event.AggregateVersion <= checkpoint.LastProcessedVersion)
{
    return;
}

if (@event.AggregateVersion != checkpoint.LastProcessedVersion + 1)
{
    await StoreForLater(@event);
    return;
}

await Apply(@event);
checkpoint.AdvanceTo(@event.AggregateVersion);

但当 PaymentProcessed 和 OrderShipped 分别由不同 BC 发布时,通常没有共同的 aggregateVersion,两个 BC 各自的版本号也不能直接比较。跨 BC 的业务顺序需要通过明确的因果流程、独立事实建模或专门的协调器来表达。

用流程协调表达因果关系

如果 Shipping 必须等 Payment 完成后才能开始,可以使用 saga 或 process manager:

PaymentProcessed
        │
        ▼
FulfillmentProcess
        │  Send StartShipping command
        ▼
Shipping BC
        │
        ▼
OrderShipped

具体流程如下:

  1. Payment BC 完成付款,并可靠地发布 PaymentProcessed。
  2. process manager 消费该事件,然后发送 StartShipping command。
  3. Shipping BC 校验命令并执行发货。
  4. Shipping BC 发布 OrderShipped。
  5. Ordering BC 分别记录付款和发货事实。

这个流程建立了业务上的因果顺序,但 Ordering BC 仍不一定会按相同顺序收到两个事件,因为它们可能经过不同的消息通道。因此,Ordering BC 依然需要支持幂等和乱序处理。

每个消费者都要实现幂等

乱序往往伴随着重复投递。integration event 应携带稳定的 EventId,消费者则应在同一个本地事务中记录处理结果和事件 ID:

public async Task Handle(PaymentProcessed @event)
{
    if (await inbox.Contains(@event.EventId))
    {
        return;
    }

    var order = await orders.Get(@event.OrderId);

    order.ApplyPaymentProcessed();

    await inbox.MarkAsProcessed(@event.EventId);
    await unitOfWork.Commit();
}

如果 MarkAsProcessed 与业务数据更新不在同一个事务中,消费者崩溃时仍可能造成重复更新。常见的处理方式包括:

  • 生产端使用 transactional outbox,保证业务提交与事件发布不会丢失。
  • 消费端使用 inbox 或 processed-message table,避免重复事件产生重复副作用。
  • 更新数据时使用 optimistic concurrency,防止多个消费者同时覆盖同一订单。
  • 对无法立即处理的事件执行重试、暂存或死信处理,同时保留诊断信息。

区分领域状态和查询投影

还需要确认 Order.Status 属于哪类数据。

如果它决定 Ordering BC 内部能否取消订单、修改地址或退款,那么它就是领域状态,必须通过 aggregate 的方法和不变量更新,事件处理器不能直接修改字段。

如果它只是订单列表中的展示字段,就更接近 read model。read model 可以使用可交换、单调的更新方式:

UPDATE order_view
SET payment_processed = TRUE
WHERE order_id = @order_id;
UPDATE order_view
SET shipped = TRUE
WHERE order_id = @order_id;

查询时再计算展示状态:

CASE
    WHEN shipped = TRUE THEN 'Shipped'
    WHEN payment_processed = TRUE THEN 'Payed'
    ELSE 'Pending'
END

这种更新能很好地容忍乱序。无论两个事件以什么顺序执行,最终结果都相同。

实施时的判断原则

处理这类问题时,可以按以下顺序判断:

  1. 确认事件表达的是可以撤销的命令,还是已经发生且不能否认的事实。
  2. 分别保存付款、发货等不同维度,避免多个事件争抢同一个字段。
  3. 如果必须保留统一状态,就从事实推导,或者使用合法且单调的状态迁移。
  4. 只有确实需要建立跨 BC 的因果关系时,才引入 saga 或 process manager。
  5. 使用 outbox、inbox、EventId 和 optimistic concurrency 处理消息丢失、重复投递及并发更新。
  6. 只有当事件属于同一个有序流时,才使用统一的 sequence 或 version 检测缺口。
  7. 对长期缺失的前置事件设置告警和 reconciliation,不要无限等待。

状态机可以防止 Payed 覆盖 Shipped,但它只是最后一道领域保护。更稳妥的模型是分别保存独立事实,让事件处理具备幂等、可交换和单调更新的性质,再把统一的订单状态作为这些事实的派生结果。

备注:内容仅供参考。