PAYATHON 2026

如何在 Actor model 中安全执行非幂等操作?

支付小周

结论

Actor 恢复时只重建状态,不应再次执行付款、发邮件、调用仓库接口等外部副作用。

实际设计通常不追求“绝对只执行一次”,而是结合以下机制:

  • 持久化业务状态,区分“尚未请求”“已请求”和“已确认”;
  • 在事件持久化成功后执行外部操作;
  • 为每个副作用生成稳定的幂等键,例如 orderId + operationType;
  • 使用 outbox、可靠投递或专门的副作用 actor 处理重试;
  • 保存外部系统返回的业务结果,例如 paymentId;
  • 恢复时只重放事件,不在事件处理器中调用外部服务。

记录“某订单是否已付款”并不违背 Actor Model。只要这份记录有明确的一致性边界,它就是处理外部副作用所需的业务状态。真正需要解决的是“本地记录成功,但外部调用结果未知”这一故障窗口。

为什么事件重放不应该再次付款

持久化 actor 通常有两类逻辑:

  1. 根据历史事件恢复内部状态;
  2. 根据新命令产生事件并触发后续行为。

恢复期间只执行第一类逻辑。事件处理器必须保持纯粹和确定性,只修改内存状态。例如:

private void Apply(PaymentCompleted evt)
{
    State.PaymentStatus = PaymentStatus.Completed;
    State.PaymentId = evt.PaymentId;
}

这里不能调用支付网关:

private void Apply(PaymentCompleted evt)
{
    // 错误:事件重放时会再次执行
    paymentGateway.Charge(evt.OrderId, evt.Amount);
}

如果 actor 在恢复时再次付款,通常是因为副作用进入了事件重放路径,或者恢复后的状态没有记录工作流进行到了哪一步。

正常情况下,actor 会重放以下事件:

OrderPlaced
PaymentRequested
PaymentCompleted
ConfirmationEmailRequested
ConfirmationEmailSent
PicklistRequested

重放结束后,actor 应该知道付款和确认邮件已经完成。此时只需等待仓库的发货消息,不应从第一步重新开始。

不过,只保存 PaymentCompleted 仍无法彻底解决问题。系统可能在支付成功后、持久化 PaymentCompleted 前崩溃。这是非幂等操作最难处理的故障窗口。

用状态机描述工作流

不要把订单流程写成“启动后依次执行六个方法”,而应将其建模为可恢复的状态机:

public enum PaymentStatus
{
    NotRequested,
    Pending,
    Completed,
    Failed,
    Unknown
}

public sealed class OrderState
{
    public string OrderId { get; set; } = "";
    public PaymentStatus PaymentStatus { get; set; }
    public string? PaymentId { get; set; }

    public bool ConfirmationEmailSent { get; set; }
    public bool PicklistSent { get; set; }
    public bool Shipped { get; set; }
    public bool ShippingEmailSent { get; set; }
}

每一步都应有明确的请求状态和完成状态。对于结果可能不确定的外部调用,一个 bool 往往不够。

付款至少需要区分:

NotRequested -> Pending -> Completed
                       \-> Failed
                       \-> Unknown

Unknown 表示支付网关可能已经处理了请求,但本地没有拿到可靠结果。此时不能直接再次扣款,应先通过幂等键或查询接口确认原交易的状态。

先持久化意图,再执行副作用

收到支付命令后,可以先持久化“准备付款”这一事实:

Command<ProcessPayment>(cmd =>
{
    if (State.PaymentStatus == PaymentStatus.Completed)
    {
        Sender.Tell(new PaymentAlreadyCompleted(State.PaymentId!));
        return;
    }

    if (State.PaymentStatus == PaymentStatus.Pending)
    {
        return;
    }

    var operationId = $"order:{State.OrderId}:payment";

    Persist(
        new PaymentRequested(State.OrderId, cmd.Amount, operationId),
        evt =>
        {
            Apply(evt);

            paymentActor.Tell(new ChargePayment(
                evt.OrderId,
                evt.Amount,
                evt.OperationId));
        });
});

支付成功后,再持久化结果:

Command<PaymentSucceeded>(msg =>
{
    if (State.PaymentStatus == PaymentStatus.Completed)
    {
        return;
    }

    Persist(
        new PaymentCompleted(
            msg.OrderId,
            msg.OperationId,
            msg.PaymentId),
        evt =>
        {
            Apply(evt);
            Sender.Tell(new Ack(evt.OperationId));
        });
});

按这个顺序处理,actor 不会在付款意图毫无记录的情况下发起请求。但这仍不能单独提供 exactly-once:

  1. PaymentRequested 已持久化;
  2. 支付网关扣款成功;
  3. 应用在收到响应或持久化 PaymentCompleted 前崩溃;
  4. 恢复后只能看到 PaymentRequested。

因此,恢复后不能盲目重新扣款,而应使用相同的 operationId 查询或重试。

使用稳定的幂等键

每个逻辑副作用都需要一个稳定的操作标识。重试时必须复用同一标识,不能重新生成:

var operationId = $"order:{orderId}:payment";

调用支付服务时,将它作为 idempotency key:

var result = await paymentGateway.ChargeAsync(
    orderId: command.OrderId,
    amount: command.Amount,
    idempotencyKey: command.OperationId);

如果同一请求发送了多次,支付服务应返回同一笔交易的结果,而不是创建多笔扣款。

如果支付服务是自建的,可以在其数据库中设置唯一约束:

CREATE TABLE payment_operations (
    operation_id VARCHAR(200) PRIMARY KEY,
    order_id VARCHAR(100) NOT NULL,
    status VARCHAR(30) NOT NULL,
    provider_payment_id VARCHAR(200),
    response_payload TEXT
);

处理逻辑大致如下:

INSERT INTO payment_operations (
    operation_id,
    order_id,
    status
)
VALUES (
    :operation_id,
    :order_id,
    'processing'
)
ON CONFLICT (operation_id) DO NOTHING;

只有成功插入记录的一方负责创建付款,其他重复请求直接读取已有结果。

这张表不是为了“绕开 actor”,而是支付服务幂等性边界的一部分。Actor journal 记录订单发生过什么,支付操作表则保证同一个支付意图不会执行两次,两者职责不同。

如果第三方支付网关既不支持幂等键,也不能按商户订单号查询交易,那么应用仅靠 Actor Model 无法严格保证不重复扣款。此时只能加入本地去重、交易查询、人工对账或补偿流程,并接受某些故障下结果可能无法确定。

用 outbox 可靠地发送外部消息

发邮件、发送 picklist 等操作适合使用 transactional outbox。

在同一个本地事务中保存业务状态和待发送消息:

BEGIN;

UPDATE orders
SET confirmation_email_status = 'pending'
WHERE order_id = :order_id
  AND confirmation_email_status = 'not_requested';

INSERT INTO outbox (
    message_id,
    message_type,
    aggregate_id,
    payload,
    status
)
VALUES (
    :message_id,
    'SendOrderConfirmationEmail',
    :order_id,
    :payload,
    'pending'
)
ON CONFLICT (message_id) DO NOTHING;

COMMIT;

独立的 dispatcher 读取 outbox 并发送消息。发送失败时可以重试,成功后再把记录标记为完成。

但 outbox 也有一个典型的故障窗口:

  1. 邮件服务已经接受请求;
  2. dispatcher 还没有将 outbox 标记为完成;
  3. dispatcher 崩溃;
  4. 恢复后再次发送。

因此,outbox 能保证“消息不会因本地崩溃而丢失”,却无法自动让外部副作用获得 exactly-once 语义。接收方仍需根据稳定的 message_id 去重。

邮件服务可以维护发送记录:

CREATE TABLE email_deliveries (
    message_id VARCHAR(200) PRIMARY KEY,
    recipient VARCHAR(320) NOT NULL,
    template_name VARCHAR(100) NOT NULL,
    provider_message_id VARCHAR(200),
    status VARCHAR(30) NOT NULL
);

同一封业务邮件使用固定标识:

var messageId = $"order:{orderId}:confirmation-email";

发货邮件使用另一个标识:

var messageId = $"order:{orderId}:shipping-email";

不要在每次重试时生成新的 GUID,否则接收方无法判断这些请求代表同一个业务动作。

Actor 之间使用至少一次投递和去重

分布式系统中更常见的是 at-least-once delivery:消息可能重复,但不容易丢失。接收 actor 必须具备去重能力。

发送方为消息分配稳定的投递标识:

public sealed record SendPicklist(
    string OrderId,
    string DeliveryId,
    IReadOnlyList<OrderLine> Lines);

接收方保存已经处理过的标识:

Command<SendPicklist>(cmd =>
{
    if (State.ProcessedDeliveries.Contains(cmd.DeliveryId))
    {
        Sender.Tell(new PicklistAccepted(cmd.DeliveryId));
        return;
    }

    Persist(
        new PicklistCreated(
            cmd.OrderId,
            cmd.DeliveryId,
            cmd.Lines),
        evt =>
        {
            Apply(evt);
            Sender.Tell(new PicklistAccepted(evt.DeliveryId));
        });
});

处理结果必须先持久化,再发送确认。否则,接收方可能已经确认,却在保存状态前崩溃。发送方会认为消息已经处理完成,接收方却没有留下任何记录。

Akka.NET Persistence 的可靠投递机制可以协助完成重试、确认和恢复,但具体 API 会因 Akka.NET 版本及所选组件而异。无论使用哪种 API,底层原则都是:

稳定消息 ID + 至少一次投递 + 接收方持久化去重 + 明确确认

框架提供的“可靠投递”不代表所有外部副作用天然具有 exactly-once 语义。

恢复完成后如何继续流程

恢复时只调用 Apply 重建状态。恢复完成后,再根据当前状态判断是否继续未完成的工作。

示意代码如下:

Recover<OrderPlaced>(Apply);
Recover<PaymentRequested>(Apply);
Recover<PaymentCompleted>(Apply);
Recover<ConfirmationEmailRequested>(Apply);
Recover<ConfirmationEmailSent>(Apply);
Recover<PicklistRequested>(Apply);
Recover<OrderShipped>(Apply);
Recover<ShippingEmailSent>(Apply);

Recover<RecoveryCompleted>(_ =>
{
    ResumePendingOperations();
});

恢复后的处理可以写成:

private void ResumePendingOperations()
{
    if (State.PaymentStatus == PaymentStatus.Pending)
    {
        paymentActor.Tell(new ResolvePayment(
            State.OrderId,
            $"order:{State.OrderId}:payment"));
    }

    if (State.PaymentStatus == PaymentStatus.Completed &&
        !State.ConfirmationEmailSent)
    {
        emailActor.Tell(new SendConfirmationEmail(
            State.OrderId,
            $"order:{State.OrderId}:confirmation-email"));
    }

    if (State.ConfirmationEmailSent &&
        !State.PicklistSent)
    {
        warehouseActor.Tell(new SendPicklist(
            State.OrderId,
            $"order:{State.OrderId}:picklist",
            State.Lines));
    }
}

ResolvePayment 应优先查询已有交易,或者使用相同的幂等键安全重试,不能无条件创建一笔新付款。

恢复完成后还要避免同时启动多个相同任务。可以结合 Pending 状态、消息 ID、单 actor 串行处理和接收方去重来限制重复执行。

快照不会改变副作用语义

快照只用于缩短恢复时间,不应影响业务正确性。

无论 actor 从头重放所有事件,还是先加载 snapshot 再重放后续事件,最终状态都应相同。不能依赖“快照恰好记录了已经付款”来避免重复扣款,因为快照可能尚未创建、写入失败,或者在故障恢复时不可用。

正确性取决于事件、状态机、幂等键和去重机制。snapshot 只是性能优化。

不要在持久化前抢先执行副作用

下面的写法存在风险:

var result = await paymentGateway.ChargeAsync(...);

Persist(
    new PaymentCompleted(result.PaymentId),
    Apply);

支付已经成功,但如果系统在 Persist 前崩溃,本地将完全不知道这笔付款的结果。

反过来,先保存 PaymentCompleted 再调用支付网关也不行:

Persist(
    new PaymentCompleted(...),
    evt => paymentGateway.ChargeAsync(...));

这会让本地状态声称付款已经完成,而外部调用实际可能失败。

操作横跨本地 journal 和外部服务时,没有一种简单的调用顺序能够自动提供原子性。除非双方参与同一个分布式事务,否则需要采用“持久化意图、可重试执行、幂等键、结果确认”的协议。

推荐的订单工作流

整个流程可以设计为以下事件序列:

OrderPlaced
PaymentRequested
PaymentCompleted
ConfirmationEmailRequested
ConfirmationEmailSent
PicklistRequested
PicklistAccepted
OrderShipped
ShippingEmailRequested
ShippingEmailSent

各步骤遵循同一套处理规则:

  1. 收到命令后检查当前状态。如果操作已经完成,直接返回已有结果。
  2. 持久化操作意图,并记录稳定的 operationId。
  3. 持久化成功后,将工作交给副作用执行者。
  4. 执行者使用同一个 operationId 调用外部系统。
  5. 外部系统或适配层根据 operationId 去重。
  6. 收到成功结果后,持久化完成事件。
  7. 只有完成事件持久化后,才确认上游消息。
  8. 恢复后查询或重试所有 Pending 操作,不再启动已经完成的操作。

还需要注意的边界情况

邮件通常无法严格撤销

如果邮件供应商已经接收邮件,但响应在网络中丢失,应用很难判断邮件是否已经发送。应优先选择支持自定义消息标识、幂等请求或投递状态查询的供应商。否则只能在“可能重复”和“可能漏发”之间取舍,并通过本地去重尽量缩小风险。

付款失败不一定表示没有扣款

超时、连接断开和 5xx 只表示调用方没有得到确定答复,不代表支付失败。应将这种情况记录为 Unknown,随后按照幂等键或商户订单号查询。不要把所有异常都转成“重新付款”。

去重记录需要保留足够长的时间

如果过早清理去重记录,而旧消息仍可能再次投递,重复操作就会重新发生。保留时间应覆盖消息系统的最大重试周期、人工补发周期和灾难恢复窗口。

补偿不等于幂等

退款、发送更正邮件等补偿动作可以修复部分业务后果,但这些动作本身也不是幂等的,同样需要独立的操作 ID、状态和去重机制。

最终原则

Actor Model 保证 actor 内部消息的串行处理,但不会自动保证数据库、支付网关、邮件服务和消息代理之间的跨系统 exactly-once。

工程上通常采用以下组合:

at-least-once delivery
+ persistent state machine
+ stable idempotency key
+ receiver-side deduplication
+ outbox
+ acknowledgement
+ reconciliation

因此,记录“订单是否已经付款”这个思路没有问题,但不要只保存一个布尔值。还应保存付款意图、操作 ID、处理状态、外部交易 ID 和最后确认结果,并让支付端根据操作 ID 保证幂等。这样,即使 actor 从零开始恢复,也只会重建状态并继续未完成的协议,不会再次执行已经完成的业务动作。

备注:内容仅供参考。