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!(全部回滚)
在互联网业务里它几乎被放弃,原因:
- 同步阻塞:Prepare 后到 Commit 前,所有参与者持锁等待。采购流程跑几分钟到几天(询价要人工),锁几分钟甚至几天,系统直接瘫痪;
- 协调者单点:协调者在 Commit 阶段挂掉,参与者处于"不知道该提交还是回滚"的悬置状态,需要人工介入;
- 数据库支持受限:PostgreSQL 有 XA 但很少用,MongoDB/Redis/MQ 根本不参与 XA;
- 与微服务自治冲突:每个服务自己的库、自己的发布节奏,被一个跨库事务绑定在一起。
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,命令可能重复投递;编排器可能多实例部署。三重保障:
- Saga 实例行锁 (
SELECT ... FOR UPDATE):同一 Saga 同一时刻只有一个线程在推进; - 步骤日志表 :正向步骤以
(saga_id, step_name, direction, status)去重,已 SUCCEEDED 的步骤重入时直接返回; - 业务动作幂等:每个步骤的正向/补偿动作自身幂等(冻结记录带 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. 十个实战经验
- 先画补偿对照表再写代码。每个正向动作问一句"这步失败了,业务上怎么撤销?"答不上来的步骤,说明流程设计有问题,别开工。
- 编排器不要直接操作业务库。编排器库只存 saga_instance/step_log;业务动作通过命令/接口调用各服务。否则又退回分布式事务的耦合。
- 状态机要显式,不要用布尔字段拼 。
isFrozen && !isCancelled && orderId != null这种隐式状态在补偿时必乱。 - 人工等待态要有超时。所有挂起状态配 Hangfire 扫描 + 超时动作(提醒或自动取消)。
- 不可逆点明确标注。供应商确认订单/发货/付款后,流程只许向前,补偿转为"退货/退款"新流程。
- 通知类动作放最后。越早发通知,取消时要发的更正通知越多;把"通知双方"放在流程末尾,前面失败时谁都不用打扰。
- SagaId 贯穿所有业务记录。冻结记录、采购申请、订单上都冗余 saga_id,补偿和排查靠它串全局。
- 补偿动作不要抛业务异常。"数据已经是补偿后状态"应当作成功(幂等),而不是报错。
- 协同式别硬上编排器,编排式也别退回事件 spaghetti。简单扇出用事件,长流程用编排器,混着用最痛苦。
- 上线初期准备"人工补偿后台"。理论上 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 就是这道题的标准答案------它不优雅,但它诚实:承认失败是常态,然后为每一种失败都准备好出路。
💬 互动一下:
- 你们的跨服务流程现在是怎么处理中途失败的?有没有"库存扣了但订单没成"这类悬案?
- 编排式和协同式,你更倾向哪种?我们的经验是长流程一定要编排器,但很多团队反感"中心编排器",你怎么看?
- 补偿动作设计中,你遇到过最棘手的"无法撤销的现实动作"是什么?最后怎么解决的?