PAYATHON 2026

我的 Saga 结构是正确的解决方案吗?(NServiceBus)

支付小周

结论

Saga 可以协调“营业日结束后生成银行文件”这类长时间流程,但不适合直接保存当天收到的所有消息。

建议采用以下设计:

  • Saga 只保存流程状态,如营业日期、截止时间、文件状态和统计数量。
  • 每笔付款或取消请求都作为独立业务记录持久化。
  • 营业结束 timeout 到达后,查询尚未导出的记录并生成银行文件。
  • 使用消息 ID 或业务操作 ID 实现幂等处理,避免消息重试导致重复付款。

如果把不断增长的 List<T> 放入 Saga,系统会依赖具体 Saga persister 的集合映射能力。Saga 数据也会频繁增大,成为并发写入的热点。

当前数据类的问题

示例中的成员是字段,不是属性:

public virtual List<PaymentRequestedMessage> PaymentRequestedMessages;

C# 字段不能声明为 virtual。如果当前持久化方案支持集合,至少应改成属性:

public virtual IList<PaymentRequestedMessage> PaymentRequestedMessages
{
    get;
    set;
}

不过,语法正确并不代表 NServiceBus 的 Saga persister 一定能持久化该属性。不同版本和不同 persister 对复杂对象、集合、子实体及映射配置的支持并不相同。关系型持久化方案通常无法将任意消息对象集合自动映射到 Saga 表中。

即使能够映射,也不建议把完整消息保存在 Saga 中。Saga 每处理一条消息,都可能需要读取并更新整个集合。交易量增加后,记录会越来越大,并发冲突和超时的风险也会增加。

推荐的数据结构

Saga 只保存批次级状态:

public class DailyBankFileSagaData : IContainSagaData
{
    public virtual Guid Id { get; set; }
    public virtual string Originator { get; set; }
    public virtual string OriginalMessageId { get; set; }

    public virtual DateTime BusinessDate { get; set; }
    public virtual DateTime CutoffTimeUtc { get; set; }

    public virtual bool TimeoutScheduled { get; set; }
    public virtual bool FileGenerationStarted { get; set; }
    public virtual bool FileGenerated { get; set; }

    public virtual int PaymentCount { get; set; }
    public virtual int CancellationCount { get; set; }
}

付款和取消操作存入独立的数据表,例如:

public class BankPaymentInstruction
{
    public Guid Id { get; set; }

    // 用于消息幂等
    public string SourceMessageId { get; set; }

    // 用于同一笔付款的业务关联
    public string PaymentId { get; set; }

    public DateTime BusinessDate { get; set; }
    public BankInstructionType Type { get; set; }

    public decimal Amount { get; set; }
    public string Currency { get; set; }

    public BankInstructionStatus Status { get; set; }
    public DateTime ReceivedAtUtc { get; set; }
}

public enum BankInstructionType
{
    Payment,
    Cancellation
}

public enum BankInstructionStatus
{
    Pending,
    Exporting,
    Exported,
    Cancelled
}

数据库应为 SourceMessageId 建立唯一约束。同一条消息因重试而再次处理时,唯一约束可以阻止系统创建重复指令。

消息处理流程

1. 按营业日关联 Saga

当天收到的第一条付款或取消消息启动 Saga,后续消息通过 BusinessDate 关联到同一实例。

概念上的关联关系如下:

protected override void ConfigureHowToFindSaga(
    SagaPropertyMapper<DailyBankFileSagaData> mapper)
{
    mapper.MapSaga(saga => saga.BusinessDate)
        .ToMessage<PaymentRequested>(message => message.BusinessDate);

    mapper.MapSaga(saga => saga.BusinessDate)
        .ToMessage<PaymentCancelled>(message => message.BusinessDate);
}

具体关联 API 会随 NServiceBus 版本而变化,但设计上必须有稳定的批次标识。单独使用日期只适用于“一天一个批次”的场景。如果系统涉及多个银行、商户、币种或结算账户,应使用组合批次键,例如:

BankId + MerchantId + Currency + BusinessDate

最好把这些字段组合成单一的 BatchId,同时用于 Saga 关联和数据库查询。

2. 首次启动时安排 timeout

public async Task Handle(
    PaymentRequested message,
    IMessageHandlerContext context)
{
    await StorePaymentInstruction(message);

    Data.PaymentCount++;

    if (!Data.TimeoutScheduled)
    {
        Data.TimeoutScheduled = true;
        Data.CutoffTimeUtc = CalculateCutoffTimeUtc(message.BusinessDate);

        await RequestTimeout(
            context,
            Data.CutoffTimeUtc,
            new CloseBusinessDay
            {
                BusinessDate = message.BusinessDate
            });
    }
}

这里的 RequestTimeout 只是示意写法。旧版 NServiceBus 可能使用 Saga 上的 RequestTimeout<T>(),新版可能通过 IMessageHandlerContext 调用,具体应以项目使用的 NServiceBus 版本为准。

取消消息也按相同方式持久化:

public async Task Handle(
    PaymentCancelled message,
    IMessageHandlerContext context)
{
    await StoreCancellationInstruction(message);

    Data.CancellationCount++;

    if (!Data.TimeoutScheduled)
    {
        Data.TimeoutScheduled = true;
        Data.CutoffTimeUtc = CalculateCutoffTimeUtc(message.BusinessDate);

        await RequestTimeout(
            context,
            Data.CutoffTimeUtc,
            new CloseBusinessDay
            {
                BusinessDate = message.BusinessDate
            });
    }
}

3. timeout 到达后启动文件生成

public async Task Timeout(
    CloseBusinessDay timeout,
    IMessageHandlerContext context)
{
    if (Data.FileGenerationStarted)
    {
        return;
    }

    Data.FileGenerationStarted = true;

    await context.SendLocal(new GenerateBankFile
    {
        BusinessDate = Data.BusinessDate
    });
}

timeout 最好只发送 GenerateBankFile 命令,不要直接在 Saga handler 中生成文件。文件生成可能涉及大量查询、格式化、磁盘写入或远程存储,处理时间通常比普通消息更长。交给独立 handler 后,重试、监控和扩展都会更方便。

生成命令的 handler 再查询待处理记录:

public async Task Handle(
    GenerateBankFile message,
    IMessageHandlerContext context)
{
    var instructions = await repository.GetPendingInstructions(
        message.BusinessDate);

    var file = bankFileWriter.Create(instructions);

    await bankFileStore.Save(file);

    await repository.MarkAsExported(
        instructions.Select(x => x.Id));

    await context.Publish(new BankFileGenerated
    {
        BusinessDate = message.BusinessDate,
        InstructionCount = instructions.Count
    });
}

Saga 收到完成事件后结束:

public Task Handle(
    BankFileGenerated message,
    IMessageHandlerContext context)
{
    Data.FileGenerated = true;
    MarkAsComplete();

    return Task.CompletedTask;
}

必须处理的边界情况

截止时间附近到达的消息

timeout 和付款消息可能同时到达。不能认为 timeout 开始处理时,所有在截止时间前发送的消息都已经处理完成。不同队列、不同 endpoint 和消息重试都会影响实际到达顺序。

需要明确业务规则,例如:

  • 根据消息中的业务时间确定所属批次。
  • 截止时间之后收到的消息进入下一个营业日。
  • 截止后保留一个宽限窗口。
  • 生成文件时锁定批次,锁定后到达的消息进入补充文件。

批次归属不能只由消息实际进入处理流程的时间决定。

消息重复

NServiceBus 处理消息时可能进行重试,因此 handler 必须具备幂等性。至少应保存以下字段并设置唯一约束:

SourceMessageId

要求更严格的系统还应为业务操作建立唯一键,例如:

PaymentId + OperationType + OperationSequence

并发和批次锁定

生成文件前,应以原子操作将符合条件的记录从 Pending 改为 Exporting,锁定本次文件包含的内容。文件保存成功后,再将状态改为 Exported。

如果生成过程失败,系统需要能够根据批次号安全重试,避免重复生成内容不同的银行文件。

事务一致性

如果业务记录和 Saga 数据位于同一个支持事务的存储中,可以在同一次消息处理事务中提交。如果两者使用不同数据库,或者文件保存在外部存储中,就需要根据项目的 NServiceBus 版本和 transport 配置采用 Outbox、幂等写入或显式状态机。不能默认数据库写入、消息发送和文件保存天然属于同一事务。

Command 和 Event 的语义

“生成银行文件”要求一个明确的接收方执行操作,适合使用 Send 发送 command。

“付款已接受”“付款已取消”和“银行文件已生成”描述的是已经发生的事实,适合使用 Publish 发布 event。通常不应在接收 command 后将其原样再次发布。更清楚的流程是由 command handler 完成业务处理,再发布描述处理结果的 event。

如果确实需要在 Saga 中保存集合

只有当集合很小、具有明确上限,而且当前 Saga persister 明确支持相应映射时,才应考虑这种设计。集合中也应保存精简的值对象或记录 ID,不要保存完整的消息对象:

public class DailyBankFileSagaData : IContainSagaData
{
    public virtual Guid Id { get; set; }
    public virtual string Originator { get; set; }
    public virtual string OriginalMessageId { get; set; }

    public virtual IList<Guid> InstructionIds { get; set; }
}

这种方式仍然取决于 persister 是否支持集合映射以及相关配置。对于每天可能产生大量付款的系统,更合适的做法通常是独立保存每笔指令,让 Saga 只负责管理批次的生命周期。

备注:内容仅供参考。