作者: jiangbodev 发布时间: 2026-05-07 技术栈: .NET 10 + PostgreSQL + OpenTelemetry + k3s 阅读时长: 10 分钟
很多团队做 Saga 会陷入一个循环:
第一个服务能跑,第二个服务重写一遍,第三个服务再踩一遍老坑。
最后你会看到这些熟悉场景:
- 每个服务 Outbox 写法都不一样;
- 有的消费端做了幂等,有的没做;
- 补偿策略全靠个人习惯;
- CI 只跑 happy path,线上再"随机开奖"。
我在项目里做了一个转折:
把 Saga + Outbox 从"方案"升级成"模板 + CI 守门"。
结果很直接:
- 新服务接入时间从 3-5 天降到 0.5-1 天;
- 一致性事故从"月内多次"降到"月均 0-1 次";
- 排障路径从"日志大海捞针"变成"状态机 + Trace 定位"。
下面只讲可落地做法。
1. 模板化到底要解决什么问题
我给模板定了 5 个硬目标:
- 新服务 1 天内接入 Saga + Outbox;
- 业务同学只改 Handler,不重复造基础设施;
- 默认透传
saga_id+trace_id; - 消费端幂等默认开启;
- CI 自动拦截高风险一致性改动。
一句话:把一致性能力变成团队默认配置,而不是少数人经验。
2. 模板拆成 4 个组件
我把能力拆成 4 个模块(共享项目或私有 NuGet 都可以):
Saga.Core:状态机、步骤编排、补偿入口Outbox.Core:同事务落库 + relay 发布Messaging.Idempotency:消费端去重Observability.Conventions:统一日志和 OTel 标签
核心接口(示意)
csharp
public interface ISagaOrchestrator<TCommand>
{
Task<SagaResult> ExecuteAsync(TCommand command, CancellationToken ct = default);
}
public interface ISagaStep<TContext>
{
string Name { get; }
Task ExecuteAsync(TContext context, CancellationToken ct);
Task CompensateAsync(TContext context, CancellationToken ct);
}
csharp
public interface IOutboxWriter
{
Task AddAsync<TEvent>(TEvent @event, IDictionary<string, string>? headers, CancellationToken ct);
}
public interface IMessageIdempotencyStore
{
Task<bool> ExistsAsync(string messageId, CancellationToken ct);
Task MarkProcessedAsync(string messageId, CancellationToken ct);
}
3. 业务侧最小接入代码
模板接入后,业务代码基本只剩"执行/补偿"本身:
csharp
public class CreatePlanSagaOrchestrator : ISagaOrchestrator<CreatePlanCommand>
{
private readonly IReadOnlyList<ISagaStep<CreatePlanContext>> _steps;
public CreatePlanSagaOrchestrator(IReadOnlyList<ISagaStep<CreatePlanContext>> steps)
=> _steps = steps;
public async Task<SagaResult> ExecuteAsync(CreatePlanCommand cmd, CancellationToken ct = default)
{
var ctx = CreatePlanContext.From(cmd);
var executed = new Stack<ISagaStep<CreatePlanContext>>();
try
{
foreach (var step in _steps)
{
await step.ExecuteAsync(ctx, ct);
executed.Push(step);
}
return SagaResult.Success(ctx.SagaId);
}
catch (Exception ex)
{
while (executed.Count > 0)
{
var step = executed.Pop();
await step.CompensateAsync(ctx, ct);
}
return SagaResult.Failed(ctx.SagaId, ex.Message);
}
}
}
这段代码不是炫技,而是为了统一"失败就按逆序补偿"的团队行为。
4. CI 四层守门:把事故挡在 PR 阶段
我把一致性检查分成 4 层:
第 1 层:静态规则
- 消费 Handler 必须调用幂等检查;
- Saga Step 必须有
ExecuteAsync与CompensateAsync; - 不允许绕过 Outbox 直接发布消息。
第 2 层:单元测试
- 第二步失败时,第一步补偿必须触发;
- 同一
message_id重复消费只执行业务一次; - Saga 超时能进入补偿态。
第 3 层:集成测试(Testcontainers)
- 落库后服务崩溃,重启后 Outbox 仍可发布;
- 重复投递不产生重复业务数据;
- 补偿事件链路完整。
第 4 层:轻量回归压测
关注 4 个指标:
- Outbox 积压量
- 平均重试次数
- Saga 超时率
- 死信队列增速
5. GitHub Actions 示例
yaml
name: saga-consistency-check
on:
pull_request:
branches: [ main ]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- run: dotnet restore
- run: dotnet build --configuration Release --no-restore
- run: dotnet test tests/UnitTests --configuration Release --no-build
- run: dotnet test tests/IntegrationTests --configuration Release --no-build
- run: dotnet test tests/ConsistencyGuards --configuration Release --no-build
我单独保留 ConsistencyGuards 测试项目,失败原因会比混在普通业务测试里更清晰。
6. 模板化最容易踩的 4 个坑
-
模板过度抽象:接入反而变慢。
策略:模板只管骨架,业务流程顺序可配置。
-
只有代码模板,没有约定模板。
策略:补齐消息命名、重试策略、死信处理 SOP。
-
CI 只测 happy path。
策略:必须加入失败注入,验证补偿链路。
-
告警阈值粗暴。
策略:使用滑窗阈值,避免短抖动触发告警风暴。
7. 结果对比
| 指标 | 模板化前 | 模板化后 |
|---|---|---|
| 新服务接入 Saga 时间 | 3-5 天 | 0.5-1 天 |
| 一致性相关缺陷 | 每月多次 | 月均 0-1 次 |
| 排障平均耗时 | 30 分钟+ | 10 分钟内 |
| 团队实现风格 | 离散 | 统一 |
真正的收益不是"少写了几百行代码",而是分布式事务从"个人手艺"变成"团队工业化能力"。
结语
如果你已经做完 Saga 第一版,不要再继续手搓。
下一步就三件事:
- 抽模板
- 建约定
- 接 CI
做完这三步,你的一致性治理才算真正进入可规模化阶段。
持续更新 .NET 云原生实战:
- 公众号: jiangbodev
- 掘金: juejin.cn/user/640371...