非同期バルクワーカーで全件を単一トランザクションに包むと内側の非トランザクショナル副作用が不整合になる
バックエンド
設計判断
非同期処理
知識
判断
運用
バックグラウンドのバルク処理ワーカーで「全レコードの更新を1つの DB トランザクションで囲んでアトミックにする」と整合性が上がるように見えるが、そのトランザクションの内側で実行される非トランザクショナルな副作用は DB のロールバック対象外であり、途中失敗時に DB だけ巻き戻って外部状態が残る不整合を生む。
非トランザクショナルな副作用の例
- 検索インデックス(OpenSearch/Elasticsearch 等)への upsert/delete
- 外部 API 連携(カレンダー、会議、決済、通知など)
- 別コネクション/別サービスへの書き込み
なぜ危険が増幅されるか(単一更新との差)
- 単一レコード更新でも同じ構造はあるが、巻き込まれるのは1件。バルクでは1件の失敗で数千〜数万件分の DB がロールバックされる一方、それまでに反映済みの外部副作用は全部残る。
- さらにキュー消費ワーカーが「失敗時もメッセージを無条件削除」していると、リトライで再実行されて外部状態が正しい値に上書き収束する余地も消え、恒久不整合になる。
判断基準・対処
- トランザクション内では DB 書込のみに限定し、検索インデックス更新・外部 API 連携はコミット後の別フェーズに出す(commit→外部反映の順)。
- 外部反映は冪等化し、失敗は記録して後追い再実行できるようにする(outbox / 失敗レコード化)。
- トランザクション境界を全件で1つにせず chunk 単位で切ると、ロールバック範囲と保持時間を限定でき、中断後再開もしやすい。
- 「失敗=即終了・リトライなし」を選ぶなら、その前提と外部副作用の不整合可能性を設計として明示する。一時障害(インデックスの 429、デッドロック、lock wait timeout)でリトライしたいなら、失敗時はメッセージを削除せず再配信/DLQ 経路を残す。
検証
処理の途中(外部副作用を出した後)で意図的に例外を投げ、(1) DB がロールバックされること、(2) 外部システム(検索インデックス・外部 API)に部分反映が残ること、(3) リトライ経路があるなら再実行で収束し、無いなら不整合が残ることを確認する。