ASP.NET Core Saga 分布式事务深度实战:备件采购跨服务长流程,如何保证“要么全成,要么全回“

0. 开篇:库存冻结了,采购单却没建成

备件申领流程在微服务化后被拆进了几个限界上下文:

复制代码
【备件上下文 SparePart】     申领单、审批、库存冻结
【采购上下文 Procurement】   采购申请、询价、采购订单
【库存上下文 Inventory】     出入库、库存账
【供应商上下文 Supplier】    供应商主数据、报价
【财务上下文 Finance】       预算校验、费用归集

一次"申领审批通过"的完整业务链路是这样的:

复制代码
审批通过
  → 冻结备件库存(无库存则跳过)
  → 生成采购申请
  → 预算校验(财务)
  → 询价/比价(供应商)
  → 创建采购订单
  → 通知供应商 + 通知船端

上线后第二个月,出了一次经典事故:审批通过后,库存冻结成功了,但创建采购申请时财务服务正在重启,预算校验超时。链路中断。结果是:

  • 库存被冻结,船员看到的是"库存锁定、不可用";
  • 采购申请根本没生成,采购那边什么都看不到;
  • 没有任何报错到达审批人------异常被异步链路吞掉了;
  • 三天后盘点才发现这批备件"既不能用、也没人买"。

单体时代,这一段是一个数据库事务,BEGIN ... COMMIT,要么全成要么全回。拆成微服务后,本地事务管不到别的库、别的服务,传统 ACID 事务的边界被网络击穿了。

这就是分布式事务要解决的问题。而业界对这类"跨服务、长流程、步骤多"的业务,给出的主流答案不是 2PC,而是 Saga 模式

这篇博客把 Saga 从原理到落地讲透:两种协调模式的取舍、状态机持久化、补偿动作设计、空补偿/悬挂/幂等三大坑、故障注入测试,最后给出 PMS 备件采购 Saga 的完整实现。

💬 互动一下:你们的系统里,跨服务/跨库的写操作现在是怎么保证一致性的?是分布式事务中间件、本地消息表,还是......纯靠人工对数据?评论区坦白一下。


1. 先明确:为什么不用 2PC/XA

两阶段提交(2PC)是教科书答案:协调者让所有参与者先 Prepare,全部 OK 再 Commit。

复制代码
协调者:Prepare!──► 参与者1: Yes(锁资源)
                 ──► 参与者2: Yes(锁资源)
                 ──► 参与者3: No
协调者:Abort!(全部回滚)

在互联网业务里它几乎被放弃,原因:

  1. 同步阻塞:Prepare 后到 Commit 前,所有参与者持锁等待。采购流程跑几分钟到几天(询价要人工),锁几分钟甚至几天,系统直接瘫痪;
  2. 协调者单点:协调者在 Commit 阶段挂掉,参与者处于"不知道该提交还是回滚"的悬置状态,需要人工介入;
  3. 数据库支持受限:PostgreSQL 有 XA 但很少用,MongoDB/Redis/MQ 根本不参与 XA;
  4. 与微服务自治冲突:每个服务自己的库、自己的发布节奏,被一个跨库事务绑定在一起。

Saga 的思路完全相反:不追求原子性(Atomicity),而是保证最终一致性(Eventual Consistency)


2. Saga 核心思想:拆成一串本地事务 + 一串补偿

Saga 把一个大业务流程拆成 N 个本地事务(Local Transaction),每个本地事务:

  • 在自己服务内用普通 ACID 事务提交(短事务、不跨服务);

  • 完成后触发下一步(通过编排器指令或事件);

  • 每一步都预先设计好对应的补偿动作(Compensating Transaction)

    正向流程(都成功):
    T1 ──► T2 ──► T3 ──► ... ──► Tn ✅ 全部完成

    某步失败(T3 失败):
    T1 ──► T2 ──► T3 ❌

    ▼ 按逆序执行补偿
    C2 ◄── C1 (T3 本身若没提交则无需补偿)

    语义保证:所有已提交步骤的影响,被补偿动作逐一撤销,系统回到业务上的"未发生"状态。

关键认知:Saga 没有"回滚",只有"补偿"。 这俩的区别是理解 Saga 的命门:

回滚(Rollback) 补偿(Compensation)
数据库层面撤销,当作没发生 业务层面执行一个新的正向动作抵消影响
数据恢复原状 数据留下痕迹,状态变为"已取消/已冲销"
自动、可靠 需要业务设计,可能失败、需要重试
例:DELETE 插入的行 例:冻结库存 → 解冻 ;创建采购单 → 标记取消并通知供应商 ;扣款 → 退款

补偿动作必须按逆序执行(C2 先于 C1),因为后面的步骤可能依赖前面步骤产生的数据。

还有一个重要语义:Saga 不保证隔离性(Isolation)。中间状态对外可见------库存已经冻结但采购单还没建出来,这期间别的业务看得到冻结。所以 Saga 流程在业务上要能容忍"中间态",靠状态字段(如"采购处理中")对外表达,靠幂等和状态机防止中间态被误操作。


3. 两种协调模式:编排式 vs 协同式

3.1 协同式(Choreography,事件编舞)

没有中心协调者。每个服务做完自己的本地事务后发布事件,下一个服务订阅事件并执行,失败时发布"补偿事件",相关服务自行补偿。

复制代码
SparePart 服务 ──(ApplyApproved)──► Procurement 服务 ──(PurchaseRequested)──► Finance 服务
                                          ▲                                        │
                                          │                                        ▼
                                   (PurchaseCancelled) ◄──(BudgetRejected)──── 预算校验失败
csharp 复制代码
// 每个服务只关心"发生了什么",不关心全局
public class ApplyApprovedConsumer : IConsumer<ApplyApprovedEvent>
{
    public async Task Consume(ConsumeContext<ApplyApprovedEvent> ctx)
    {
        // 本地事务:创建采购申请
        var requisition = PurchaseRequisition.CreateFromApprovedApply(ctx.Message);
        await _db.PurchaseRequisitions.AddAsync(requisition);
        await _uow.SaveChangesWithOutboxAsync(ctx);   // Outbox 保证事件可靠发布
        // 发布 PurchaseRequested 事件(通过 Outbox)
    }
}
  • 优点:服务松耦合、无单点、易扩展新订阅者;
  • 缺点流程逻辑散落在各个消费者里,没有任何一个地方能看清全流程;服务多了之后事件流向成谜("这个事件谁在消费?改一个字段影响谁?");补偿链尤其难追踪;循环依赖风险。
  • 适合:步骤少(3~5 步)、参与方稳定、流程简单的场景。

3.2 编排式(Orchestration,指挥家模式)

引入一个中心化的 Saga 编排器(Orchestrator),它知道完整流程,按状态机依次给各服务发命令、收回复、决定下一步或补偿。各服务只做被吩咐的事,不感知全局。

复制代码
                    ┌──────────────────────────┐
                    │   PurchaseSaga 编排器     │
                    │  (状态机 + 持久化)       │
                    └──────────┬───────────────┘
          FreezeStock 命令     │      事件回复
            ┌─────────────────┼─────────────────┐
            ▼                 ▼                  ▼
      SparePart服务     Procurement服务      Finance服务 ...
  • 优点:流程集中可见、状态清晰、易监控易干预、补偿逻辑明确、新人一眼看懂业务;
  • 缺点:编排器是逻辑中心(不是性能单点,可水平扩展,因为状态在数据库);编排器里集中了流程逻辑,别把它写成"上帝类"。
  • 适合:步骤多、有分支(有库存/无库存走不同路径)、有人工节点(询价等待)、需要监控干预的长流程。

3.3 PMS 的选择

备件采购流程步骤多(7+ 步)、有分支、有人工等待(询价可能等几天)、强监控需求(采购要知道每张单卡在哪) ,选编排式 ;而像"审批通过后发通知、记审计日志、刷新报表缓存"这类简单的事后扇出动作,继续用协同式领域事件(MediatR INotification + Outbox),不塞进 Saga。

判断口诀:流程需要"知道走到哪了、下一步该干嘛、失败了怎么退"→ 编排式;只是"事情发生了,谁关心谁自己处理"→ 协同式。


4. Saga 状态机:编排器的核心设计

4.1 Saga 实例持久化

编排器本身是无状态的(可以重启、可以多实例),Saga 的进度全部持久化在数据库。这是它和"内存里跑个状态机"的本质区别。

sql 复制代码
CREATE TABLE saga_instance (
    saga_id          VARCHAR(64) PRIMARY KEY,        -- Saga 实例 ID(幂等键,见第 7 节)
    saga_type        VARCHAR(64) NOT NULL,           -- 流程类型:PURCHASE_FLOW
    business_key     VARCHAR(64) NOT NULL,           -- 业务键:申领单 ID
    current_state    VARCHAR(32) NOT NULL,           -- 当前状态
    payload          JSONB NOT NULL,                 -- 流程上下文(各步产出的 ID)
    version          BIGINT NOT NULL DEFAULT 0,      -- 乐观锁
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    completed_at     TIMESTAMPTZ,
    UNIQUE (saga_type, business_key)                 -- 一张申领单只能有一个采购 Saga
);

CREATE TABLE saga_step_log (
    id               BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    saga_id          VARCHAR(64) NOT NULL REFERENCES saga_instance(saga_id),
    step_name        VARCHAR(64) NOT NULL,           -- FreezeStock / CreateRequisition ...
    direction        VARCHAR(16) NOT NULL,           -- FORWARD / COMPENSATE
    status           VARCHAR(16) NOT NULL,           -- PENDING / SUCCEEDED / FAILED / COMPENSATED
    request_payload  JSONB,
    result_payload   JSONB,
    error_message    TEXT,
    attempt_count    INT NOT NULL DEFAULT 0,
    executed_at      TIMESTAMPTZ,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 卡住的 Saga 巡检用
CREATE INDEX idx_saga_state_updated ON saga_instance (current_state, updated_at)
    WHERE completed_at IS NULL;

payload 存流程上下文------各步产出的关键 ID,补偿时要用:

json 复制代码
{
  "applyId": "a1b2...",
  "shipId": "SHIP_001",
  "frozenStockIds": ["f-001", "f-002"],
  "requisitionId": "rq-7788",
  "budgetId": "bg-2233",
  "purchaseOrderId": null,
  "supplierQuotes": [ ... ]
}

4.2 状态与转移定义

csharp 复制代码
// Saga 状态枚举
public enum PurchaseSagaState
{
    Started,            // 已启动
    StockFrozen,        // 库存冻结完成(无库存时直接跳过到下一步)
    RequisitionCreated, // 采购申请已建
    BudgetChecked,      // 预算校验通过
    Quoting,            // 询价中(人工等待态)
    OrderCreated,       // 采购订单已创建
    Notified,           // 通知完成
    Completed,          // ✅ 全部完成
    Compensating,       // 补偿中
    Compensated,        // ✅ 已全部补偿(业务取消)
    Failed              // 补偿也失败,需人工干预
}

// 步骤定义:每步绑定正向动作 + 补偿动作
public interface ISagaStep
{
    string Name { get; }
    Task<StepResult> ExecuteAsync(SagaContext context, CancellationToken ct);
    Task<StepResult> CompensateAsync(SagaContext context, CancellationToken ct);
}

步骤清单与补偿对照表(这张表是 Saga 设计的核心交付物,评审时逐行确认):

步骤 正向动作(本地事务) 补偿动作 补偿幂等键
FreezeStock 冻结可用库存(库存状态 → Frozen) 解冻(Frozen → Available) freezeId
CreateRequisition 创建采购申请(状态:草稿) 采购申请状态 → Cancelled requisitionId
CheckBudget 预算预占(冻结预算额度) 释放预算预占 budgetLockId
RequestQuote 发出询价(外部动作) 标记询价作废、通知供应商撤回 inquiryId
CreateOrder 创建采购订单(状态:待确认) 订单状态 → Cancelled,通知供应商 orderId
NotifyParties 发送站内信/邮件通知 发送"流程取消"通知(通知不可撤回,只能再发一条更正 notificationId

注意最后一行:不是所有动作都能"撤销" 。通知发出去了、供应商已经发货了、付款已经打了------这些现实世界动作无法回滚,只能用新动作抵消(发取消通知、退货、退款)。Saga 的边界要在业务上划清楚:走到"不可逆点"(Point of No Return)之后,流程只能向前完成,不能整体补偿------比如供应商已发货,就不能取消订单,只能走退货流程。设计时在状态机上明确标出不可逆点。

4.3 编排器实现

csharp 复制代码
public class PurchaseSagaOrchestrator
{
    private readonly PmsDbContext _db;
    private readonly IPublishEndpoint _bus;
    private readonly IServiceProvider _sp;
    private readonly ILogger<PurchaseSagaOrchestrator> _logger;

    // 步骤顺序(正向);补偿按此表逆序执行
    private static readonly string[] StepOrder =
    {
        "FreezeStock", "CreateRequisition", "CheckBudget",
        "RequestQuote", "CreateOrder", "NotifyParties"
    };

    /// <summary>
    /// 启动 Saga(由"审批通过"事件触发;sagaId 用申领单 ID,天然幂等)
    /// </summary>
    public async Task StartAsync(Guid applyId, string shipId, CancellationToken ct)
    {
        var sagaId = applyId.ToString("N");

        // 幂等:同一申领单重复触发(事件重复投递)不重复启动
        var existing = await _db.SagaInstances.IgnoreQueryFilters()
            .FirstOrDefaultAsync(s => s.SagaType == "PURCHASE_FLOW"
                                   && s.BusinessKey == applyId.ToString(), ct);
        if (existing is not null) return;

        var instance = new SagaInstance
        {
            SagaId = sagaId,
            SagaType = "PURCHASE_FLOW",
            BusinessKey = applyId.ToString(),
            CurrentState = PurchaseSagaState.Started.ToString(),
            Payload = JsonSerializer.Serialize(new PurchaseSagaPayload
            {
                ApplyId = applyId, ShipId = shipId
            })
        };
        _db.SagaInstances.Add(instance);
        await _db.SaveChangesAsync(ct);

        // 异步驱动第一步(发布内部命令,由消费者接管,避免阻塞审批接口)
        await _bus.Publish(new SagaAdvanceCommand(sagaId), ct);
    }

    /// <summary>
    /// 驱动 Saga 向前推进(幂等:可重复调用、可并发保护)
    /// </summary>
    public async Task AdvanceAsync(string sagaId, CancellationToken ct)
    {
        await using var tx = await _db.Database.BeginTransactionAsync(ct);

        var instance = await LockInstanceAsync(sagaId, ct);   // SELECT ... FOR UPDATE
        if (instance is null || IsTerminalState(instance.CurrentState)) return;

        var payload = JsonSerializer.Deserialize<PurchaseSagaPayload>(instance.Payload)!;
        var context = new SagaContext(instance, payload);

        // 找到"第一个尚未成功"的步骤
        var completedSteps = await _db.SagaStepLogs
            .Where(l => l.SagaId == sagaId && l.Direction == "FORWARD" && l.Status == "SUCCEEDED")
            .Select(l => l.StepName).ToListAsync(ct);

        foreach (var stepName in StepOrder)
        {
            if (completedSteps.Contains(stepName)) continue;

            var step = ResolveStep(stepName);
            var result = await ExecuteStepWithRetryAsync(step, context, instance, ct);

            if (result.IsSuccess)
            {
                await LogStepAsync(instance, stepName, "FORWARD", "SUCCEEDED", result, ct);
                await SavePayloadAsync(instance, context.Payload, ct);
                await tx.CommitAsync(ct);

                if (stepName == "RequestQuote")
                {
                    // 人工等待态:Saga 挂起,等"报价完成"回调事件驱动恢复
                    instance.CurrentState = PurchaseSagaState.Quoting.ToString();
                    await _db.SaveChangesAsync(ct);
                    return;
                }
                // 继续下一步(递归/发布命令都可以;这里发布命令解耦)
                await _bus.Publish(new SagaAdvanceCommand(sagaId), ct);
                return;
            }

            if (result.IsBusinessRejection)
            {
                // 业务性拒绝(如预算不足)→ 进入补偿
                await tx.CommitAsync(ct);
                await CompensateAsync(sagaId, reason: result.ErrorMessage, ct);
                return;
            }

            // 技术性失败(超时、5xx)→ 不补偿,记录失败等重试
            await LogStepAsync(instance, stepName, "FORWARD", "FAILED", result, ct);
            await tx.CommitAsync(ct);
            throw new SagaStepTransientException(stepName, result.ErrorMessage);
        }

        // 全部步骤成功
        instance.CurrentState = PurchaseSagaState.Completed.ToString();
        instance.CompletedAt = DateTimeOffset.UtcNow;
        await _db.SaveChangesAsync(ct);
        await tx.CommitAsync(ct);
    }

    /// <summary>
    /// 补偿:按已成功步骤的逆序逐一执行补偿动作
    /// </summary>
    public async Task CompensateAsync(string sagaId, string reason, CancellationToken ct)
    {
        var instance = await LockInstanceAsync(sagaId, ct)!;
        instance.CurrentState = PurchaseSagaState.Compensating.ToString();

        var succeededSteps = await _db.SagaStepLogs
            .Where(l => l.SagaId == sagaId && l.Direction == "FORWARD" && l.Status == "SUCCEEDED")
            .OrderByDescending(l => l.Id)   // 逆序!
            .Select(l => l.StepName).ToListAsync(ct);

        foreach (var stepName in succeededSteps)
        {
            // 已补偿的跳过(补偿本身也要幂等,见第 6 节)
            var alreadyCompensated = await _db.SagaStepLogs.AnyAsync(l =>
                l.SagaId == sagaId && l.StepName == stepName
                && l.Direction == "COMPENSATE" && l.Status == "SUCCEEDED", ct);
            if (alreadyCompensated) continue;

            var step = ResolveStep(stepName);
            var payload = JsonSerializer.Deserialize<PurchaseSagaPayload>(instance.Payload)!;
            var result = await step.CompensateAsync(new SagaContext(instance, payload), ct);

            if (result.IsSuccess)
            {
                await LogStepAsync(instance, stepName, "COMPENSATE", "SUCCEEDED", result, ct);
            }
            else
            {
                // 补偿失败!不能再自动处理,进入人工干预队列
                instance.CurrentState = PurchaseSagaState.Failed.ToString();
                await LogStepAsync(instance, stepName, "COMPENSATE", "FAILED", result, ct);
                await _db.SaveChangesAsync(ct);
                await _alertService.NotifySagaCompensationFailedAsync(sagaId, stepName, result.ErrorMessage);
                return;
            }
        }

        instance.CurrentState = PurchaseSagaState.Compensated.ToString();
        instance.CompletedAt = DateTimeOffset.UtcNow;
        await _db.SaveChangesAsync(ct);
    }

    private async Task<SagaInstance?> LockInstanceAsync(string sagaId, CancellationToken ct)
    {
        // 行锁防并发推进(同一 Saga 可能被多个事件/重试同时驱动)
        return await _db.SagaInstances.IgnoreQueryFilters()
            .FromSqlInterpolated(
                $"SELECT * FROM saga_instance WHERE saga_id = {sagaId} FOR UPDATE")
            .FirstOrDefaultAsync(ct);
    }

    private async Task<StepResult> ExecuteStepWithRetryAsync(
        ISagaStep step, SagaContext context, SagaInstance instance, CancellationToken ct)
    {
        // 步骤级重试:瞬时故障(网络抖动、服务重启)用 Polly 重试
        return await _retryPolicy.ExecuteAsync(async token =>
        {
            await IncrementAttemptAsync(instance, step.Name, ct);
            return await step.ExecuteAsync(context, token);
        }, ct);
    }

    private static bool IsTerminalState(string state) =>
        state is nameof(PurchaseSagaState.Completed)
              or nameof(PurchaseSagaState.Compensated)
              or nameof(PurchaseSagaState.Failed);

    private ISagaStep ResolveStep(string name) => name switch
    {
        "FreezeStock"       => _sp.GetRequiredService<FreezeStockStep>(),
        "CreateRequisition" => _sp.GetRequiredService<CreateRequisitionStep>(),
        "CheckBudget"       => _sp.GetRequiredService<CheckBudgetStep>(),
        "RequestQuote"      => _sp.GetRequiredService<RequestQuoteStep>(),
        "CreateOrder"       => _sp.GetRequiredService<CreateOrderStep>(),
        "NotifyParties"     => _sp.GetRequiredService<NotifyPartiesStep>(),
        _ => throw new InvalidOperationException($"未知步骤:{name}")
    };
}

4.4 一个具体步骤长什么样

每个步骤内部 = 本地事务 + 幂等检查 + Outbox 事件

csharp 复制代码
public class FreezeStockStep : ISagaStep
{
    public string Name => "FreezeStock";

    private readonly SparePartDbContext _spareDb;   // 备件服务的库
    private readonly ITenantContext _tenant;

    public async Task<StepResult> ExecuteAsync(SagaContext context, CancellationToken ct)
    {
        var payload = context.Payload;

        // ① 幂等:补偿后重跑/重复驱动时,先查这步是否已经做过
        var alreadyFrozen = await _spareDb.StockFreezeRecords
            .AnyAsync(f => f.SagaId == context.SagaId && f.Status == "FROZEN", ct);
        if (alreadyFrozen)
            return StepResult.Ok();

        // ② 本地事务:冻结库存(跨库?不------冻结动作在备件服务库内完成)
        await using var tx = await _spareDb.Database.BeginTransactionAsync(ct);

        var items = await _spareDb.SparePartApplies
            .Include(a => a.Items)
            .FirstAsync(a => a.Id == payload.ApplyId, ct);

        var freezeRecords = new List<StockFreezeRecord>();
        foreach (var item in items.Items.Where(i => i.HasStock))
        {
            var stock = await _spareDb.Stocks
                .FirstOrDefaultAsync(s => s.PartId == item.PartId && s.ShipId == payload.ShipId, ct);

            // 条件更新:库存充足才冻结(乐观并发)
            var affected = await _spareDb.Database.ExecuteSqlInterpolatedAsync($@"
                UPDATE stock SET available_qty = available_qty - {item.Qty},
                                 frozen_qty    = frozen_qty    + {item.Qty}
                 WHERE part_id = {item.PartId} AND ship_id = {payload.ShipId}
                   AND available_qty >= {item.Qty}", ct);

            if (affected == 0)
            {
                // 库存不足是"业务分支"而非失败:无库存项走采购,Saga 继续
                continue;
            }

            freezeRecords.Add(new StockFreezeRecord
            {
                SagaId = context.SagaId,      // 冻结记录挂 SagaId,补偿时按它找
                PartId = item.PartId,
                Qty = item.Qty,
                Status = "FROZEN"
            });
        }

        await _spareDb.StockFreezeRecords.AddRangeAsync(freezeRecords, ct);
        await _spareDb.SaveChangesAsync(ct);
        await tx.CommitAsync(ct);

        // ③ 产出写入 Saga 上下文
        payload.FrozenStockIds = freezeRecords.Select(f => f.Id).ToList();
        return StepResult.Ok();
    }

    public async Task<StepResult> CompensateAsync(SagaContext context, CancellationToken ct)
    {
        // 补偿 = 解冻。幂等键:SagaId + FROZEN 状态
        var affected = await _spareDb.Database.ExecuteSqlInterpolatedAsync($@"
            UPDATE stock s
               SET available_qty = s.available_qty + f.qty,
                   frozen_qty    = s.frozen_qty    - f.qty
              FROM stock_freeze_record f
             WHERE f.saga_id = {context.SagaId}
               AND f.status  = 'FROZEN'
               AND s.part_id = f.part_id
               AND s.ship_id = {context.ShipId};

            UPDATE stock_freeze_record
               SET status = 'RELEASED'
             WHERE saga_id = {context.SagaId} AND status = 'FROZEN'", ct);

        return StepResult.Ok();   // 重复执行时第二条 UPDATE 影响 0 行,无副作用
    }
}

5. 人工节点:Saga 如何"挂起等待"

询价步骤会卡住流程几天(等供应商报价)。Saga 天然支持这种长等待:编排器在 Quoting 状态停下,不占任何线程、不持有任何锁,状态持久化在库里。报价完成后,采购界面点击"比价完成、生成订单",发布一个恢复事件:

csharp 复制代码
// 报价完成回调(用户操作触发)
public async Task<IResult> CompleteQuote(Guid sagaId, List<QuoteSelection> selections)
{
    // 报价结果写入 Saga payload,然后驱动继续
    var saga = await _db.SagaInstances.FirstAsync(s => s.SagaId == sagaId.ToString("N"));
    var payload = JsonSerializer.Deserialize<PurchaseSagaPayload>(saga.Payload)!;
    payload.SupplierQuotes = selections;
    saga.Payload = JsonSerializer.Serialize(payload);
    await _db.SaveChangesAsync();

    await _bus.Publish(new SagaAdvanceCommand(saga.SagaId));  // 恢复推进
    return Results.Accepted();
}

超时守护:挂起的 Saga 不能永远等。Hangfire 定时扫描超时实例:

csharp 复制代码
// 每天扫描:询价超过 7 天未完成的 Saga → 提醒采购主管;超过 14 天 → 自动取消(触发补偿)
RecurringJob.AddOrUpdate<SagaTimeoutJob>("saga-timeout-sweep",
    job => job.SweepAsync(), "0 2 * * *");

这就是长事务在业务系统里的真实形态------大部分时间在等人,而不是在跑代码


6. Saga 的三大经典坑:空补偿、悬挂、消息幂等

这是分布式事务面试和实战的硬核区,逐个讲。

6.1 空补偿(Empty Compensation)

场景:正向请求因网络超时未到达(或执行失败未提交),但补偿请求先到了/到了。补偿动作执行时发现"根本没有可补偿的东西"。

处理原则:补偿前先查正向步骤是否成功过,没成功过就直接返回成功(空补偿),并且要在数据库留下"已补偿"标记,防止之后迟到的正向请求再执行。

csharp 复制代码
public async Task<StepResult> CompensateAsync(SagaContext context, CancellationToken ct)
{
    var freezeLog = await _spareDb.StockFreezeRecords
        .FirstOrDefaultAsync(f => f.SagaId == context.SagaId, ct);

    if (freezeLog is null)
    {
        // 正向从未成功 → 空补偿:插入一条 COMPENSATED 标记,挡住迟到的正向请求
        await _spareDb.StockFreezeRecords.AddAsync(new StockFreezeRecord
        {
            SagaId = context.SagaId,
            Status = "COMPENSATED"   // 正向请求看到这个状态会拒绝执行
        }, ct);
        await _spareDb.SaveChangesAsync(ct);
        return StepResult.Ok();
    }
    // ... 正常解冻
}

6.2 悬挂(Hanging)

场景 :补偿(回滚)已经执行完了,之前超时的正向请求才姗姗到达并执行成功------此时正向动作的影响再也没人补偿了(Saga 已经结束),资源被永久悬挂(库存永远冻结)。

复制代码
T1: 正向请求发出 ──网络超时──► (实际还在路上)
T2: 编排器判定失败,发起补偿 → 补偿成功(空补偿,打了 COMPENSATED 标记)
T3: Saga 标记 Compensated,流程结束
T4: 迟到的正向请求到达 → 如果不检查,冻结成功 → 库存永久冻结,无人解冻 ❌

处理原则:正向动作执行前,必须检查该 Saga 步骤是否已被补偿/已存在补偿记录;若已补偿,拒绝执行正向。

csharp 复制代码
public async Task<StepResult> ExecuteAsync(SagaContext context, CancellationToken ct)
{
    // 防悬挂:补偿已到(含空补偿标记),正向请求必须放弃
    var compensated = await _spareDb.StockFreezeRecords
        .AnyAsync(f => f.SagaId == context.SagaId && f.Status == "COMPENSATED", ct);
    if (compensated)
        return StepResult.Fail("流程已取消,拒绝执行迟到的正向请求");

    // ... 正常冻结
}

空补偿和悬挂是一体两面:空补偿时插的标记,正是用来防悬挂的。

6.3 消息幂等与重复

Saga 的每一步驱动都走消息(SagaAdvanceCommand),MQ 是 At-least-once,命令可能重复投递;编排器可能多实例部署。三重保障:

  1. Saga 实例行锁SELECT ... FOR UPDATE):同一 Saga 同一时刻只有一个线程在推进;
  2. 步骤日志表 :正向步骤以 (saga_id, step_name, direction, status) 去重,已 SUCCEEDED 的步骤重入时直接返回;
  3. 业务动作幂等:每个步骤的正向/补偿动作自身幂等(冻结记录带 SagaId、补偿按状态条件更新),与《API 幂等性设计实战》的原则一致。

6.4 补偿失败怎么办

补偿动作也可能失败(解冻时库存服务挂了)。策略:

  • 补偿重试:补偿失败进入指数退避重试(Polly + Hangfire),多数瞬时故障重试即恢复;
  • 重试 N 次仍失败 → 转人工 :Saga 进入 Failed 状态,写入"人工干预队列",告警通知运维/业务,提供管理后台:查看卡点步骤、错误详情、手动重试补偿、或人工确认数据已修正后标记关闭;
  • 绝不允许补偿失败后静默把 Saga 标记为 Compensated------那是数据不一致的开始。

7. Saga 与 Outbox、幂等、CQRS 的协作

Saga 不是孤立的,它建立在前面几篇博客的基础设施之上:

复制代码
审批通过事件(MediatR INotification)
    │
    ▼
Outbox 表可靠落库(与审批事务同事务)──► 后台 Relayer 投递 RabbitMQ
    │                                      (消息不丢,见消息队列篇)
    ▼
PurchaseSaga 消费者收到事件
    │  消费幂等:Inbox 表去重(消息 ID)
    ▼
Saga 编排器:状态机推进(saga_instance 行锁 + step_log 去重)
    │
    ├─ 每步命令通过 MQ 发给各服务(服务间异步解耦)
    ├─ 每个服务内部:本地事务 + 业务幂等键(SagaId)
    └─ 失败:逆序补偿(空补偿/防悬挂/补偿重试/人工兜底)
    │
    ▼
查询侧:Saga 状态实时投影到查询库(CQRS),采购看板显示
    "申领单 A1 → 已冻结库存 2 项 → 采购申请已建 → 等待预算确认(卡点 3 小时)"

三层幂等各司其职:消息层 靠 Inbox 防重复投递,Saga 层 靠步骤日志防重复推进,业务层靠 SagaId 条件更新防重复生效。少任何一层,极端时序下都可能出问题。


8. 可观测性:Saga 实例必须"看得见、管得住"

长流程最大的风险是"悄无声息地卡住"。我们做了三件事:

① Saga 看板:基于 saga_instance + saga_step_log 的查询投影,每张进行中的 Saga 显示当前状态、已完成步骤、卡点步骤耗时、最近错误。采购主管每天早上看的不是"采购单列表",而是"流程异常列表"。

② 关键指标(Prometheus)

csharp 复制代码
// 自定义指标
_sagaStarted.Add(1, tags);                                   // 启动数
_sagaDuration.Record(elapsed.TotalHours, tags);              // 完成时长分布
_sagaStateGauge.Set(activeCount, new KeyValuePair<string,object?>("state", state)); // 各状态在途数
_sagaCompensationFailed.Add(1);                              // 补偿失败数(告警阈值:>0 即告警)
_sagaStepDuration.Record(stepElapsed, stepTags);             // 各步骤耗时

核心告警:

  • saga_compensation_failed_total 出现增长 → P0 告警(数据一致性受损);
  • 某状态在途实例停留超过阈值(如 Quoting > 7 天、BudgetChecked > 24 小时)→ 业务卡顿告警;
  • Saga 启动数与"审批通过数"不一致 → 事件丢失告警。

③ 全链路追踪:Saga 启动时生成 TraceId,后续每条命令消息透传 TraceId(见可观测性篇),一次跨服务采购流程在 Jaeger/Tempo 里是一条完整瀑布链,每个步骤的耗时和失败点一目了然。


9. 测试:不做故障注入的 Saga 等于没测

Saga 的 bug 全在异常时序里,正常路径测试毫无意义。测试矩阵:

csharp 复制代码
public class PurchaseSagaTests : IClassFixture<TestcontainersFactory>
{
    [Fact]
    public async Task HappyPath_AllStepsSucceed_Completes()
    {
        await _saga.StartAsync(applyId, "SHIP_001", default);
        await _saga.AdvanceAsync(sagaId, default);   // 驱动到询价挂起
        await QuoteCompletedAsync(sagaId);            // 模拟报价完成
        await _saga.AdvanceAsync(sagaId, default);

        var instance = await GetInstanceAsync(sagaId);
        instance.CurrentState.Should().Be("Completed");
        // 库存冻结、采购单、通知都存在
    }

    [Fact]
    public async Task BudgetRejected_CompensatesInReverseOrder()
    {
        // Arrange:预算服务返回"预算不足"
        _financeStub.SetReject();

        await _saga.StartAsync(applyId, "SHIP_001", default);
        await WaitUntilStateAsync(sagaId, "Compensated");

        // 断言补偿结果:库存已解冻、采购申请已取消
        var stock = await GetStockAsync(partId);
        stock.FrozenQty.Should().Be(0);
        var requisition = await GetRequisitionAsync(payload.RequisitionId);
        requisition.Status.Should().Be("Cancelled");
    }

    [Fact]
    public async Task DuplicateAdvanceCommand_StepExecutedOnce()
    {
        // 消息重复投递:并发驱动 3 次
        await Task.WhenAll(
            _saga.AdvanceAsync(sagaId, default),
            _saga.AdvanceAsync(sagaId, default),
            _saga.AdvanceAsync(sagaId, default));

        _spareDb.StockFreezeRecords.Count(f => f.SagaId == sagaId && f.Status == "FROZEN")
            .Should().Be(1);   // 冻结只生效一次
    }

    [Fact]
    public async Task LateForwardRequest_AfterCompensation_IsRejected()
    {
        // 模拟悬挂:补偿先完成,迟到的正向请求到达
        await _saga.CompensateAsync(sagaId, "test", default);
        var result = await _freezeStep.ExecuteAsync(context, default);
        result.IsSuccess.Should().BeFalse();   // 防悬挂生效
    }

    [Fact]
    public async Task CompensationFails_GoesToFailedState_AndAlerts()
    {
        _stockStub.SetDown();   // 库存服务宕机,补偿也失败
        await _saga.CompensateAsync(sagaId, "test", default);
        var instance = await GetInstanceAsync(sagaId);
        instance.CurrentState.Should().Be("Failed");
        _alertSink.ReceivedAlerts.Should().Contain(a => a.Contains(sagaId));
    }

    [Theory]
    [InlineData("FreezeStock")]
    [InlineData("CreateRequisition")]
    [InlineData("CheckBudget")]
    public async Task StepFailsTransiently_RetryThenSucceeds(string failedStep)
    {
        // 每个步骤第一次失败、第二次成功(Polly 重试)
        _flakyStub.FailOnce(failedStep);
        await RunSagaToCompletionAsync(sagaId);
        (await GetInstanceAsync(sagaId)).CurrentState.Should().Be("Completed");
    }
}

必测清单:

  • 全流程成功
  • 每个步骤失败 → 补偿逆序执行、前置步骤全部撤销
  • 业务拒绝(预算不足)vs 技术故障(5xx)分支正确:前者补偿、后者重试
  • 重复消息/并发驱动 → 步骤只生效一次
  • 空补偿:正向未成功时收到补偿 → 标记成功且挡迟到正向
  • 悬挂:补偿后迟到正向 → 被拒绝
  • 补偿失败 → Failed 状态 + 告警 + 人工队列
  • 人工挂起(询价)→ 超时扫描 → 自动取消
  • 不可逆点之后拒绝补偿(已发货订单不能取消,只能走退货)

10. 十个实战经验

  1. 先画补偿对照表再写代码。每个正向动作问一句"这步失败了,业务上怎么撤销?"答不上来的步骤,说明流程设计有问题,别开工。
  2. 编排器不要直接操作业务库。编排器库只存 saga_instance/step_log;业务动作通过命令/接口调用各服务。否则又退回分布式事务的耦合。
  3. 状态机要显式,不要用布尔字段拼isFrozen && !isCancelled && orderId != null 这种隐式状态在补偿时必乱。
  4. 人工等待态要有超时。所有挂起状态配 Hangfire 扫描 + 超时动作(提醒或自动取消)。
  5. 不可逆点明确标注。供应商确认订单/发货/付款后,流程只许向前,补偿转为"退货/退款"新流程。
  6. 通知类动作放最后。越早发通知,取消时要发的更正通知越多;把"通知双方"放在流程末尾,前面失败时谁都不用打扰。
  7. SagaId 贯穿所有业务记录。冻结记录、采购申请、订单上都冗余 saga_id,补偿和排查靠它串全局。
  8. 补偿动作不要抛业务异常。"数据已经是补偿后状态"应当作成功(幂等),而不是报错。
  9. 协同式别硬上编排器,编排式也别退回事件 spaghetti。简单扇出用事件,长流程用编排器,混着用最痛苦。
  10. 上线初期准备"人工补偿后台"。理论上 Saga 自动恢复一切,现实中总有数据脏了需要人工修。提供按 Saga 查看状态、手动重试/跳过/标记的运维界面,比临时写 SQL 修数据安全一百倍。

11. 上线 Checklist

设计阶段

  • 流程步骤清单 + 补偿动作对照表(逐行评审)
  • 标注不可逆点,之后的失败走退货/退款流程而非补偿
  • 人工等待节点 + 超时策略
  • 编排式 vs 协同式选型(步骤数、分支、监控需求)

实现阶段

  • saga_instance / saga_step_log 表(乐观锁 version、行锁推进)
  • 启动幂等(business_key 唯一)、推进幂等(步骤日志)、业务幂等(SagaId 条件更新)
  • 空补偿标记 + 防悬挂检查
  • 技术故障 Polly 重试 / 业务拒绝触发补偿
  • 补偿失败 → Failed 状态 + 告警 + 人工队列
  • Outbox 保证命令/事件可靠投递,Inbox 保证消费幂等
  • 业务记录冗余 saga_id

运维阶段

  • Saga 看板(在途实例、卡点、错误)
  • 指标:在途数、时长分布、补偿失败数(P0 告警)、步骤耗时
  • TraceId 全链路透传
  • 超时扫描定时任务
  • 人工干预后台(重试/跳过/标记关闭,操作留审计)
  • 故障注入演练(上线前必做一轮全步骤失败演练)

12. 小结

Saga 模式的本质,是用业务语义的补偿 替代数据库语义的回滚 ,用最终一致 替代强原子

  • :大事务拆成一串短本地事务,每步独立提交、独立成功;
  • :每步预先设计补偿动作,失败时逆序补偿,系统回到业务起点;
  • :编排器无状态,Saga 进度全部持久化(实例表 + 步骤日志),崩溃可续跑;
  • :人工节点挂起等待,超时扫描兜底;
  • :空补偿打标记、防悬挂拒迟到、三层幂等(消息/编排/业务);
  • :Saga 看板 + 补偿失败 P0 告警 + 全链路追踪;
  • :补偿失败转人工干预,绝不静默。

一句话总结:分布式系统里没有"回滚"键,只有"怎么办才能让业务上当作没发生"的设计题。 Saga 就是这道题的标准答案------它不优雅,但它诚实:承认失败是常态,然后为每一种失败都准备好出路。

💬 互动一下

  1. 你们的跨服务流程现在是怎么处理中途失败的?有没有"库存扣了但订单没成"这类悬案?
  2. 编排式和协同式,你更倾向哪种?我们的经验是长流程一定要编排器,但很多团队反感"中心编排器",你怎么看?
  3. 补偿动作设计中,你遇到过最棘手的"无法撤销的现实动作"是什么?最后怎么解决的?
相关推荐
2601_9620710027 分钟前
【Java EE】SpringBoot的创建与简单使用
spring boot·后端·java-ee
XiYang-DING27 分钟前
地图城市缓存优化:ApplicationReadyEvent + Caffeine + Redis + Redisson 分布式锁
redis·分布式·缓存
摇滚侠44 分钟前
《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发进阶 阅读笔记 24
spring boot·笔记·后端
元界metalite1 小时前
SpringBoot开发企业后台-操作日志记录的最佳实践
后端
用户298698530141 小时前
PDF 转纯文本(TXT)免费攻略:轻松提取文字内容
人工智能·后端·c#
程序边界1 小时前
从RAG落地到MCP实战:向量数据库融合架构的工程化体验
后端
Rain的Java大神之路1 小时前
如何保证接口幂等
java·经验分享·后端·面试·架构
木木爱研究1 小时前
elpis-里程碑四-基于Vue完成动态组件库建设
前端·后端
一水行1 小时前
博客评论系统搭建回顾
javascript·后端