第10章:Sagaでの冪等性(冪等キーと二重補償防止)🔑🛡️

この章でできるようになること🎯✨
- 「同じ要求が2回以上来る」前提で、Sagaを安全に動かせるようになる🔁😊
- **冪等キー(Idempotency Key)**の作り方・スコープ・保存方法がわかる🧠🔑
- **二重補償(例:返金が2回走る😱)**を、設計とコードで防げるようになる🧯🛡️
1. Sagaで“冪等”が超重要な理由(進むも戻すも二重が起きる)😵💫🔁
Sagaはざっくり言うと、
- 進む(例:注文→決済→在庫確保)➡️
- 失敗したら戻す(例:返金→在庫戻し)⬅️
…って流れですよね😊
でも現実のシステムは、リトライや**重複メッセージ(at-least-once)**が普通に起きます🔁📨 つまり、こんな事故が起きやすいの…👇
- 決済が 2回 実行される💸💸(二重課金)
- 返金が 2回 走る💸➡️💸➡️(二重返金)
- 状態が 2回 遷移して、Sagaが迷子になる🌀
だから、Sagaでは「進む操作」だけじゃなくて、“補償(戻す操作)”も冪等にするのが超大事です🛡️✨ (冪等キーで「それ、もうやったよ」を判定できるようにする感じ!) ※APIのリトライを安全にする考え方は、AWSの解説がまさにこの話です。(Amazon Web Services, Inc.)
2. Sagaの冪等性は「3段重ね」で守る🧁🧁🧁
Sagaの冪等は、ここを押さえると強いよ💪✨
① 入口の冪等(“同じ注文開始”を2回やらない)🚪🔑
- HTTPのPOST(注文作成)や、Command(PlaceOrder)で重複が来る
- ここで 冪等キーを受け取り、結果を再利用できるようにする
② ステップの冪等(“同じステップ”を2回やらない)🧩🔑
- 決済API呼び出し、在庫確保など「外部に副作用」がある箇所
- ステップごとに ステップ用の冪等キーを作って渡す
③ 補償の冪等(“返金”を2回やらない)🧾🔑
- 補償は怖い:2回走るとお金や在庫が壊れる😱
- 補償にも 補償用の冪等キー+ “補償済み”記録 が必須!
Saga内の冪等キー層 🔑✨
3. 冪等キー設計のコツ(Saga向け🔑✨)
3.1 まず結論:キーは「業務の1回の意図」を表す💡
冪等キーは、こういう“意図”を表すのが正解に近いです👇
- ✅「この注文を作る」1回の意図
- ✅「この注文の決済を作る」1回の意図
- ✅「この決済を返金する」1回の意図
「同じ意図のリトライ」なら、同じキーを使う 「別の新しい操作」なら、別のキーを使う これが基本だよ😊🔁
3.2 形式はどうする?(UUIDが安牌🎲)
多くのAPIでは UUID(ランダム文字列)がよく使われます。Stripeも UUID v4 を推奨してます。(Stripe Documentation)
- ✅ 衝突しにくい(かぶりにくい)
- ✅ 推測されにくい(セキュリティ的にも安心)
3.3 “同じキーに別の内容”は事故るので、サーバー側で拒否する🙅♀️🧨
超大事ポイント! もし同じキーで 違うリクエスト内容 が来たら、それは「不正」か「バグ」か「誤用」です😇
Stripeも、同じ冪等キーでパラメータが違うとエラーにして事故を防ぎます。(Stripe Documentation)
なのでサーバー側はこうします👇
- 冪等キーと一緒に RequestHash(内容のハッシュ) を保存
- 同じキーで来たとき、ハッシュが違えば 409 Conflict などで拒否🚫
3.4 いつまで保存する?(TTLの考え方)⏳🧠
冪等キーは永遠に保存しなくてOKです😊 リトライに耐える期間があれば十分。
例:
- Stripeは「少なくとも24時間経ったキーは自動削除してOK」と書いてます。(Stripe Documentation)
- もっと短く「1時間だけ有効」にしている運用例もあります。(brandur.org)
目安(ざっくり):
- UIの二重クリック対策:数分〜数十分
- 分散処理の遅延や再送対策:数時間〜24時間
4. 実装パターン:Idempotency Store(保存して“同じ結果”を返す)💾🔁
4.1 最低限のテーブル(DBに保存する例)🗃️
入口の冪等を実現するために、こんなテーブルを作ります👇
Scope(どのAPI/どの操作か)IdempotencyKeyRequestHash(中身チェック用)Status(Processing / Completed)ResponseCode/ResponseBody(同じ結果を返すため)CreatedAt/CompletedAt
ポイント:(Scope, IdempotencyKey) に ユニーク制約を張るのが強いです💪✨
(同時に2個入れようとしても、DBが1個しか許さない=勝ち🏆)
5. C#コード例:入口の冪等(同じPOSTに同じレスポンスを返す)🧑💻🔁
ここでは、わかりやすさ優先で「Controller + ActionFilter」っぽく書きます😊 (本番では Middleware / Minimal API の Filter でもOK!)
5.1 モデル(EF Core想定)🧾
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public enum IdempotencyStatus
{
Processing = 0,
Completed = 1
}
public class IdempotencyRecord
{
[Key]
public long Id { get; set; }
[MaxLength(200)]
public required string Scope { get; set; } // 例: "POST:/orders"
[MaxLength(255)]
public required string IdempotencyKey { get; set; }
[MaxLength(64)]
public required string RequestHash { get; set; } // SHA-256 を hex などで
public IdempotencyStatus Status { get; set; } = IdempotencyStatus.Processing;
public int? ResponseStatusCode { get; set; }
public string? ResponseBodyJson { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? CompletedAt { get; set; }
// 競合対策(楽になる)
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
5.2 ストア(キーの開始・完了を管理)🔑✅
using System.Security.Cryptography;
using System.Text;
public sealed class IdempotencyDecision
{
private IdempotencyDecision() { }
public bool IsDuplicateCompleted { get; init; }
public int? SavedStatusCode { get; init; }
public string? SavedBodyJson { get; init; }
public bool IsDuplicateButProcessing { get; init; }
public bool RequestHashMismatch { get; init; }
public long? RecordId { get; init; }
public static IdempotencyDecision New(long recordId) => new() { RecordId = recordId };
public static IdempotencyDecision DuplicateCompleted(int code, string body) => new()
{
IsDuplicateCompleted = true,
SavedStatusCode = code,
SavedBodyJson = body
};
public static IdempotencyDecision DuplicateProcessing() => new() { IsDuplicateButProcessing = true };
public static IdempotencyDecision HashMismatch() => new() { RequestHashMismatch = true };
}
public interface IIdempotencyStore
{
Task<IdempotencyDecision> BeginAsync(string scope, string key, string requestHash, CancellationToken ct);
Task CompleteAsync(long recordId, int statusCode, string bodyJson, CancellationToken ct);
}
public static class HashUtil
{
public static string Sha256Hex(string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash); // 64 chars
}
}
※ BeginAsync の中身はDB都合で色々あるけど、考え方はこれ👇
- まず
(scope, key)で探す - 無ければ
Processingで新規作成(ユニーク制約で競合を止める) - あれば
RequestHashを比較 - 完了済みなら保存レスポンスを返す
- Processingなら「処理中だよ」返す
ここは擬似コードで示すね(雰囲気を掴めればOK!)😊✨
public sealed class EfIdempotencyStore : IIdempotencyStore
{
private readonly AppDbContext _db;
public EfIdempotencyStore(AppDbContext db) => _db = db;
public async Task<IdempotencyDecision> BeginAsync(string scope, string key, string requestHash, CancellationToken ct)
{
var existing = await _db.IdempotencyRecords
.FirstOrDefaultAsync(x => x.Scope == scope && x.IdempotencyKey == key, ct);
if (existing is null)
{
var record = new IdempotencyRecord
{
Scope = scope,
IdempotencyKey = key,
RequestHash = requestHash,
Status = IdempotencyStatus.Processing
};
_db.IdempotencyRecords.Add(record);
try
{
await _db.SaveChangesAsync(ct); // ユニーク制約で同時作成を防ぐ
return IdempotencyDecision.New(record.Id);
}
catch (DbUpdateException)
{
// 競合したら取り直す
}
existing = await _db.IdempotencyRecords
.FirstAsync(x => x.Scope == scope && x.IdempotencyKey == key, ct);
}
if (!string.Equals(existing.RequestHash, requestHash, StringComparison.Ordinal))
{
return IdempotencyDecision.HashMismatch();
}
if (existing.Status == IdempotencyStatus.Completed && existing.ResponseStatusCode is int code && existing.ResponseBodyJson is string body)
{
return IdempotencyDecision.DuplicateCompleted(code, body);
}
return IdempotencyDecision.DuplicateProcessing();
}
public async Task CompleteAsync(long recordId, int statusCode, string bodyJson, CancellationToken ct)
{
var record = await _db.IdempotencyRecords.FirstAsync(x => x.Id == recordId, ct);
record.Status = IdempotencyStatus.Completed;
record.ResponseStatusCode = statusCode;
record.ResponseBodyJson = bodyJson;
record.CompletedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
}
}
5.3 Controller側:冪等キーで結果を再利用する🌈
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
public record PlaceOrderRequest(string UserId, decimal Amount);
public record PlaceOrderResponse(string OrderId, string SagaId);
[ApiController]
public class OrdersController : ControllerBase
{
private readonly IIdempotencyStore _idem;
private readonly OrderSagaOrchestrator _orchestrator;
public OrdersController(IIdempotencyStore idem, OrderSagaOrchestrator orchestrator)
{
_idem = idem;
_orchestrator = orchestrator;
}
[HttpPost("/orders")]
public async Task<IActionResult> PlaceOrder([FromBody] PlaceOrderRequest req, CancellationToken ct)
{
// 1) 冪等キーを受け取る(ヘッダにするのが定番)
if (!Request.Headers.TryGetValue("Idempotency-Key", out var keyValues))
return BadRequest(new { message = "Idempotency-Key header is required." });
var key = keyValues.ToString();
if (key.Length is 0 or > 255) return BadRequest(new { message = "Invalid Idempotency-Key." });
// 2) スコープ(APIごとに分ける)
var scope = "POST:/orders";
// 3) 同じキーでも内容が違うのを防ぐ(ハッシュ)
var requestJson = JsonSerializer.Serialize(req);
var requestHash = HashUtil.Sha256Hex(requestJson);
// 4) 既に処理済みなら“同じ結果”を返す
var decision = await _idem.BeginAsync(scope, key, requestHash, ct);
if (decision.RequestHashMismatch)
return Conflict(new { message = "Same Idempotency-Key used with different request body." });
if (decision.IsDuplicateCompleted)
{
// 前回の結果をそのまま返す
return StatusCode(decision.SavedStatusCode!.Value, JsonSerializer.Deserialize<object>(decision.SavedBodyJson!));
}
if (decision.IsDuplicateButProcessing)
{
// 処理中(好きな方針でOK:409/202など)
return StatusCode(409, new { message = "Request is still processing. Please retry later with same Idempotency-Key." });
}
// 5) 新規:Saga開始(この章の主役はここから先の“二重補償防止”にも繋がる✨)
var result = await _orchestrator.StartAsync(req, idempotencyKey: key, ct);
var response = new PlaceOrderResponse(result.OrderId, result.SagaId);
// 6) レスポンス保存(次回同じキーが来たら同じ結果を返せる)
var bodyJson = JsonSerializer.Serialize(response);
await _idem.CompleteAsync(decision.RecordId!.Value, statusCode: 200, bodyJson, ct);
return Ok(response);
}
}
✅ これで「二重クリック」「タイムアウト後の再送」でも、同じキーなら同じレスポンスが返せます😊🔁 (Stripeも同じ発想で、同じキーなら同じ結果を返す設計を説明しています。(Stripe))
6. 二重補償(返金2回😱)を防ぐ設計:補償も“冪等”にする🧾🛡️
ここがSagaの本番🔥
6.1 まずやること:Sagaの中で「どこまで進んだか」を記録する📝
Sagaは、進んだステップだけ補償する必要があります。
例:
- 決済成功 ✅
- 在庫確保で失敗 ❌ → 補償は「返金」だけやればいい(在庫は確保してないので戻さない)
そして補償は、同じ補償が2回走っても1回しか効果が出ないようにするのがポイント!
7. C#コード例:ステップ&補償の二重実行を止める(超ミニSaga)🧑💻🛡️
7.1 Saga状態を持つ(DBに保存するイメージ)💾
SagaId(内部ID)OperationKey(入口の冪等キー、もしくは Saga起動ID)PaymentDone/PaymentCompensatedみたいなフラグ(本当はステップ表の方が綺麗✨)
public enum SagaState
{
Running = 0,
Completed = 1,
Compensating = 2,
Failed = 3
}
public class OrderSaga
{
[Key]
public string SagaId { get; set; } = Guid.NewGuid().ToString("N");
[MaxLength(255)]
public required string OperationKey { get; set; } // 入口の冪等キーなど
public SagaState State { get; set; } = SagaState.Running;
public bool PaymentDone { get; set; }
public bool PaymentCompensated { get; set; }
public bool InventoryReserved { get; set; }
public bool InventoryCompensated { get; set; }
public string? OrderId { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
7.2 Orchestrator:同じ操作が来たら“途中から再開 or 結果返す”🔁
public sealed class OrderSagaOrchestrator
{
private readonly AppDbContext _db;
private readonly PaymentService _payment;
private readonly InventoryService _inventory;
public OrderSagaOrchestrator(AppDbContext db, PaymentService payment, InventoryService inventory)
{
_db = db;
_payment = payment;
_inventory = inventory;
}
public async Task<(string OrderId, string SagaId)> StartAsync(PlaceOrderRequest req, string idempotencyKey, CancellationToken ct)
{
// ① OperationKey でSagaを引く(入口冪等の“次の層”)
var saga = await _db.OrderSagas.FirstOrDefaultAsync(x => x.OperationKey == idempotencyKey, ct);
if (saga is null)
{
saga = new OrderSaga
{
OperationKey = idempotencyKey,
OrderId = "ORD-" + Guid.NewGuid().ToString("N")[..12]
};
_db.OrderSagas.Add(saga);
await _db.SaveChangesAsync(ct);
}
// すでに完了してたら、その結果を返す(冪等✨)
if (saga.State == SagaState.Completed)
return (saga.OrderId!, saga.SagaId);
try
{
// ② 決済(副作用):ステップ冪等キーを作る
if (!saga.PaymentDone)
{
var stepKey = $"{idempotencyKey}:pay";
await _payment.CaptureAsync(req.Amount, stepKey, ct);
saga.PaymentDone = true;
await _db.SaveChangesAsync(ct);
}
// ③ 在庫確保(ここで失敗するかも)
if (!saga.InventoryReserved)
{
var stepKey = $"{idempotencyKey}:inv";
await _inventory.ReserveAsync(saga.OrderId!, stepKey, ct);
saga.InventoryReserved = true;
await _db.SaveChangesAsync(ct);
}
saga.State = SagaState.Completed;
await _db.SaveChangesAsync(ct);
return (saga.OrderId!, saga.SagaId);
}
catch
{
// 失敗したら補償へ
saga.State = SagaState.Compensating;
await _db.SaveChangesAsync(ct);
await CompensateAsync(saga, idempotencyKey, ct);
saga.State = SagaState.Failed;
await _db.SaveChangesAsync(ct);
throw;
}
}
private async Task CompensateAsync(OrderSaga saga, string operationKey, CancellationToken ct)
{
// 補償は“逆順”が基本(できる範囲でOK)
// 1) 在庫戻し(確保してたら)
if (saga.InventoryReserved && !saga.InventoryCompensated)
{
var compKey = $"{operationKey}:comp:inv";
await _inventory.ReleaseAsync(saga.OrderId!, compKey, ct);
saga.InventoryCompensated = true;
await _db.SaveChangesAsync(ct);
}
// 2) 返金(決済してたら)
if (saga.PaymentDone && !saga.PaymentCompensated)
{
var compKey = $"{operationKey}:comp:pay";
await _payment.RefundAsync(saga.OrderId!, compKey, ct);
saga.PaymentCompensated = true;
await _db.SaveChangesAsync(ct);
}
}
}
7.3 ここが「二重補償防止」の肝🫶🛡️
-
PaymentCompensatedを見て 2回目はスキップ✅ -
さらに
compKey(補償用冪等キー)を外部サービスに渡す✅- 決済系APIは冪等キー対応が一般的で、StripeもIdempotency-Keyの仕組みを明確に案内しています。(Stripe Documentation)
これで、「補償が2回呼ばれても、返金は1回だけ」になれる💪✨
8. ミニ演習(手を動かすパート)📝🎮
演習1:冪等キー候補を比べよう🔍
次のどれを採用する?理由も書いてね😊✍️
Guid.NewGuid()(UUID)OrderId(業務キー)UserId + 時刻UserId + 連番
ヒント💡
- かぶりにくい?
- 推測されにくい?
- 「同じ意図のリトライ」で同じ値になる?
演習2:同じキーで“違う内容”を送ってみる😈📨
1回目:Amount=1000、キー=abc
2回目:Amount=2000、キー=abc
👉 サーバーはどう返すべき?(おすすめは 409 Conflict など) ※この「同じキーで中身違いは拒否」は、Stripeも事故防止として採用してます。(Stripe Documentation)
演習3:補償を2回呼んでも“返金1回”になるのを確認🔁💸
- わざと在庫確保を失敗させる
CompensateAsyncが2回呼ばれても、RefundAsyncが1回しか実行されないようにする- ログに「refund skipped (already compensated)」みたいなのを出すと超わかりやすいよ😊🧾✨
9. AI活用(コピペで使えるプロンプト集🤖✨)
9.1 冪等キー設計レビューしてもらう🔍
あなたは分散システム設計レビュー担当です。
以下のSagaの冪等キー設計案をレビューしてください。
- 入口キーのスコープ(どの操作単位か)
- ステップキーの作り方
- 補償キーの作り方
- 同一キーで異なるリクエストを拒否できているか
- TTL(保存期間)の妥当性
- 同時実行(並列リクエスト)で二重実行にならないか(DB制約/ロック)
改善案とチェックリストも出してください。
(対象:C# / ASP.NET Core / Saga)
設計案:
<ここにあなたの案を貼る>
9.2 テーブル設計を整えてもらう🗃️
Sagaの冪等性を守るために、
IdempotencyRecords と OrderSagas を SQL Server 前提で設計してください。
- 必須カラム
- ユニーク制約
- インデックス
- TTL削除(古いキーを消す方法)
も提案してください。
10. 章末チェックリスト(これだけ守れば事故りにくい✅🛡️)
- 入口に冪等キーがある(POSTの重要操作)🔑
-
(Scope, Key)にユニーク制約がある🧱 - 同じキーで中身違いは拒否してる🙅♀️
- “前進ステップ”ごとにステップ冪等キーがある🧩
- “補償ステップ”にも冪等キー&補償済み記録がある🧾
- 補償が2回呼ばれても、返金などの副作用は1回になる💸🛡️
- TTL(保存期間)を決めて古いキーを掃除できる🧹⏳
🔍ちょいメモ(2026の最新スタック感)
いまどきの .NET は .NET 10 がLTSで、2025年11月にリリースされています。(Microsoft) C#も最新系列が更新され続けているので、コードは「素直に書いて読みやすく」がいちばん勝ちです😊📚(learn.microsoft.com)