イベント駆動設計:データ整合性と冪等性の完全保証

分散システムにおける悪夢は、サービスダウンではありません。データの不整合です。以下のようなログが深夜3時に発生するシナリオを想像してください。決済サービスが PaymentProcessed イベントを消費しましたが、ネットワークの瞬断によりブローカーへのACK(確認応答)がタイムアウトしました。ブローカーは未処理と判断し、同じイベントを再送します。

Critical Incident: Double Spending
UserId: 59201, TransactionId: tx_9921
03:14:01 - Balance deducted: -5000 JPY (Event offset: 1042)
03:14:03 - Balance deducted: -5000 JPY (Event offset: 1042) [RETRY]
Result: ユーザーの残高が二重に減算され、法的コンプライアンス違反が発生。

Apache KafkaやRabbitMQなどのメッセージブローカーは、デフォルトで「At-Least-Once(少なくとも一回)」の配信を保証します。これは、重複排除をアプリケーション層で実装しなければ、データの破損が不可避であることを意味します。

冪等性(Idempotency)の実装戦略

数学的定義において、冪等性とは $f(f(x)) = f(x)$ が成立する性質を指します。システム工学においては、「同じリクエストを何度適用しても、システムの状態が変わらないこと」を意味します。

冪等性キー(Idempotency Key)の活用

最も堅牢なアプローチは、メッセージヘッダーに含まれる一意のID(UUIDやTransaction ID)を追跡することです。しかし、ここには致命的な落とし穴があります。単純な「確認してから書き込む(Check-then-Act)」パターンは、競合状態(Race Condition)を引き起こします。

アンチパターン:アプリケーションレベルのチェック
複数のコンシューマーが並行して同じメッセージ(リバランス時など)を処理する場合、DBへのSELECTとINSERTの間にタイムラグが生じ、両方のスレッドが「未処理」と判断する可能性があります。

解決策は、データベースの一意性制約(Unique Constraint)を利用して、原子性(Atomicity)を保証することです。

// 冪等性を保証するコンシューマー実装例 (Java/Spring Boot)
@KafkaListener(topics = "payment-events", groupId = "billing-service")
public void listen(PaymentEvent event, Acknowledgment ack) {
    try {
        // トランザクション内で処理済みテーブルへの挿入とビジネスロジックを実行
        transactionTemplate.execute(status -> {
            // 1. 冪等性キーの保存 (重複時はDuplicateKeyExceptionが発生)
            processedEventRepository.saveAndFlush(
                new ProcessedEvent(event.getTransactionId())
            );
            
            // 2. ビジネスロジックの実行
            accountService.deductBalance(event.getUserId(), event.getAmount());
            return null;
        });
        
        // 3. ブローカーへのコミット
        ack.acknowledge();
        
    } catch (DataIntegrityViolationException e) {
        // 既に処理済みのため、エラーではなく正常終了として扱いスキップする
        logger.warn("Duplicate event detected: " + event.getTransactionId());
        ack.acknowledge(); 
    } catch (Exception e) {
        // その他のエラーはリトライ対象
        throw e;
    }
}

Dual-Write問題とTransactional Outboxパターン

イベント駆動アーキテクチャにおけるもう一つの主要な課題は、ローカルデータベースへのコミットとメッセージブローカーへのイベント発行という「2つの異なるシステムへの書き込み」を原子的に行えないことです(Dual-Write問題)。

DBコミット後にイベント発行が失敗すると、下流サービスとの不整合が発生します。これを解決するのがTransactional Outboxパターンです。

アプローチ 仕組み リスク・メリット
直接発行 (Direct Publish) DBトランザクション完了直後にpublish 危険: DBコミット成功後、ネットワークエラーでpublish失敗時にデータ欠損。
Transactional Outbox 同一DBトランザクション内にイベントを保存 安全: DBとOutboxテーブルの整合性が保証される。CDC (Change Data Capture) 等で非同期にpublish。

Sagaパターンによる分散トランザクション管理

マイクロサービス間を跨ぐ一連の処理(例:注文 → 決済 → 在庫確保 → 配送)において、ACID特性を持つ単一のトランザクションは使用できません。代わりに、Sagaパターンを用いて「結果整合性(Eventual Consistency)」を実現します。

補償トランザクション(Compensating Transaction)
Saga内のステップが失敗した場合、それ以前に完了したステップを取り消すための逆操作(例:返金処理、在庫戻し)をトリガーする必要があります。

Orchestration vs Choreography

  • Choreography(振付): 各サービスが自律的にイベントを購読・発行します。疎結合ですが、プロセス全体の状態把握が困難になりがちです。
  • Orchestration(指揮): 中央のOrchestrator(例:Camunda, Netflix Conductor)がプロセスフローを制御します。複雑なワークフローや可視化が必要な場合に適しています。
Sagaパターンの詳細リファレンスを見る

結論

非同期分散システムにおいて、データ整合性は「偶然」保たれるものではありません。冪等性キーによる重複排除の強制、Transactional Outboxパターンによる発行保証、そしてSagaパターンによる補償トランザクションの設計が、堅牢なアーキテクチャの最低条件となります。

Post a Comment