如何在 Actor model 中安全执行非幂等操作?
结论
Actor 恢复时只重建状态,不应再次执行付款、发邮件、调用仓库接口等外部副作用。
实际设计通常不追求“绝对只执行一次”,而是结合以下机制:
- 持久化业务状态,区分“尚未请求”“已请求”和“已确认”;
- 在事件持久化成功后执行外部操作;
- 为每个副作用生成稳定的幂等键,例如
orderId + operationType; - 使用 outbox、可靠投递或专门的副作用 actor 处理重试;
- 保存外部系统返回的业务结果,例如
paymentId; - 恢复时只重放事件,不在事件处理器中调用外部服务。
记录“某订单是否已付款”并不违背 Actor Model。只要这份记录有明确的一致性边界,它就是处理外部副作用所需的业务状态。真正需要解决的是“本地记录成功,但外部调用结果未知”这一故障窗口。
为什么事件重放不应该再次付款
持久化 actor 通常有两类逻辑:
- 根据历史事件恢复内部状态;
- 根据新命令产生事件并触发后续行为。
恢复期间只执行第一类逻辑。事件处理器必须保持纯粹和确定性,只修改内存状态。例如:
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:
PaymentRequested已持久化;- 支付网关扣款成功;
- 应用在收到响应或持久化
PaymentCompleted前崩溃; - 恢复后只能看到
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 也有一个典型的故障窗口:
- 邮件服务已经接受请求;
- dispatcher 还没有将 outbox 标记为完成;
- dispatcher 崩溃;
- 恢复后再次发送。
因此,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
各步骤遵循同一套处理规则:
- 收到命令后检查当前状态。如果操作已经完成,直接返回已有结果。
- 持久化操作意图,并记录稳定的
operationId。 - 持久化成功后,将工作交给副作用执行者。
- 执行者使用同一个
operationId调用外部系统。 - 外部系统或适配层根据
operationId去重。 - 收到成功结果后,持久化完成事件。
- 只有完成事件持久化后,才确认上游消息。
- 恢复后查询或重试所有
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 从零开始恢复,也只会重建状态并继续未完成的协议,不会再次执行已经完成的业务动作。
备注:内容仅供参考。
