ストーリー
補償トランザクションとは
データベースのロールバックと異なり、ビジネスロジックとして「打ち消す操作」を実行することです。
データベースロールバック:
INSERT → 物理的に行を削除(なかったことにする)
補償トランザクション:
注文作成 → 注文をキャンセル状態に更新(履歴は残る)
決済完了 → 返金処理を実行(決済履歴 + 返金履歴が残る)
在庫確保 → 在庫を解放(確保履歴 + 解放履歴が残る)
補償の設計原則
1. すべてのステップに補償を定義
// 各Sagaステップに対応する補償を必ず定義
interface SagaStepDefinition {
name: string;
execute: (ctx: SagaContext) => Promise<StepResult>;
compensate: (ctx: SagaContext) => Promise<void>;
// 補償が不要なステップ(読み取り専用など)はnoopを設定
}
const orderSagaSteps: SagaStepDefinition[] = [
{
name: "createOrder",
execute: async (ctx) => {
const order = await orderService.create(ctx.orderData);
return { orderId: order.id };
},
compensate: async (ctx) => {
await orderService.cancel(ctx.orderId);
// 注文をCANCELLED状態に更新(削除ではない)
},
},
{
name: "processPayment",
execute: async (ctx) => {
const payment = await paymentService.charge({
orderId: ctx.orderId,
amount: ctx.amount,
});
return { paymentId: payment.id };
},
compensate: async (ctx) => {
await paymentService.refund(ctx.paymentId);
// 返金処理を実行(決済の取り消しではなく新たな返金)
},
},
{
name: "reserveInventory",
execute: async (ctx) => {
await inventoryService.reserve(ctx.orderId, ctx.items);
return {};
},
compensate: async (ctx) => {
await inventoryService.release(ctx.orderId);
// 確保した在庫を解放
},
},
];
2. 補償の冪等性
補償トランザクション自体も冪等でなければなりません。
class PaymentService {
async refund(paymentId: string): Promise<void> {
const payment = await this.paymentRepo.findById(paymentId);
// 冪等性: 既に返金済みならスキップ
if (payment.status === "REFUNDED") {
console.log(`Payment ${paymentId} already refunded`);
return;
}
// 返金処理
await this.paymentGateway.refund(payment.transactionId, payment.amount);
await this.paymentRepo.updateStatus(paymentId, "REFUNDED");
}
}
3. 補償不可能な操作への対処
// 物理的に元に戻せない操作の例
const irreversibleActions = {
// メール送信 → 送信済みメールは取り消せない
emailSent: {
compensation: "お詫びメールを送信する",
// または: メール送信をSagaの最後のステップにして、
// 失敗リスクを最小化する
},
// 外部API呼び出し → 相手側の状態変更は取り消せない場合がある
externalApiCall: {
compensation: "相手のキャンセルAPIを呼ぶ(あれば)",
fallback: "手動対応のアラートを発報",
},
// SMS送信 → 送信済みは取り消せない
smsSent: {
compensation: "キャンセル通知のSMSを送る",
},
};
// 対策: 不可逆な操作はSagaの最後に配置
const reorderedSteps = [
"createOrder", // 可逆
"processPayment", // 可逆(返金可能)
"reserveInventory", // 可逆(解放可能)
"createShipment", // 可逆(出荷前なら)
"sendNotification", // 不可逆 → 最後に配置
];
補償の失敗への対処
補償自体が失敗する場合の戦略です。
class SagaCompensator {
async compensateWithRetry(
step: SagaStepDefinition,
context: SagaContext,
maxRetries: number = 5
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await step.compensate(context);
console.log(`Compensation "${step.name}" succeeded on attempt ${attempt}`);
return;
} catch (error) {
console.error(`Compensation "${step.name}" failed (attempt ${attempt}):`, error);
if (attempt === maxRetries) {
// 最大リトライ回数に達した → 手動対応へエスカレーション
await this.escalateToManual(step, context, error);
return;
}
// 指数バックオフで待機
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
private async escalateToManual(
step: SagaStepDefinition,
context: SagaContext,
error: Error
): Promise<void> {
// 運用チームに通知
await alertService.sendCritical({
title: `Saga補償失敗: ${step.name}`,
sagaId: context.sagaId,
context: JSON.stringify(context),
error: error.message,
action: "手動での補償処理が必要です",
});
// 補償失敗をDBに記録(後で手動リカバリ)
await sagaRepo.markCompensationFailed(context.sagaId, step.name);
}
}
セマンティックロック
Sagaの中間状態を管理するカウンターメジャーです。
// セマンティックロック: 中間状態をフラグで管理
interface Order {
id: string;
status: "APPROVAL_PENDING" | "APPROVED" | "REJECTED";
// APPROVAL_PENDING = Sagaが進行中
// → 他の操作(変更、キャンセル)をブロック
sagaStatus: "IN_PROGRESS" | "COMPLETED" | "COMPENSATING";
}
// 別のリクエストが注文を変更しようとした場合
async function modifyOrder(orderId: string, changes: OrderChanges): Promise<void> {
const order = await orderRepo.findById(orderId);
if (order.sagaStatus === "IN_PROGRESS") {
throw new ConflictError(
"注文はSaga処理中のため変更できません。しばらくお待ちください。"
);
}
await orderRepo.update(orderId, changes);
}
まとめ
| ポイント | 内容 |
|---|---|
| 補償 vs ロールバック | ビジネス的な打ち消し操作(履歴は残る) |
| 設計原則 | 全ステップに補償定義、補償も冪等に |
| 不可逆な操作 | Sagaの最後に配置して失敗リスクを最小化 |
| 補償の失敗 | リトライ → 手動エスカレーション |
| セマンティックロック | 中間状態のフラグで並行操作を制御 |
チェックリスト
- 補償トランザクションとDBロールバックの違いを説明できる
- 補償の冪等性が必要な理由を理解した
- 不可逆な操作の配置戦略を説明できる
- 補償失敗時のエスカレーション戦略を理解した
次のステップへ
次はOutboxパターンを学びます。「DBへの書き込み」と「イベントの発行」を確実に両方成功させる技術です。
推定読了時間: 40分