⚖️

外部DBとの整合性を守るためのトレードオフ:技術的に「正しい」解決策が、必ずしもビジネス的に「最適」ではない

に公開

こんにちは!アルダグラムでエンジニアをしている森下霞です。

外部DBとの同期処理、どこで呼び出すのが正解?
LockWaitTimeoutの原因を追う中で、外部サービスへの呼び出しをトランザクション内で行っていたことが分かりました。
そこから、LockWaitTimeout を避けるための対策を検討する中で、整合性・UX・実装コスト、それぞれのトレードオフをどう整理し、どんな判断に至ったのかを紹介します。

LockWaitTimeoutから始まった調査

ある処理で ActiveRecord::LockWaitTimeout が発生しました。調査を進めると、DBトランザクション内で外部DBサービスを呼び出していたことが原因だと判明。なお、このとき呼び出していた外部サービスは Firebase でした。

外部APIの応答を待つ間もトランザクションがロックを保持し続け、メインエンティティと関連データを外部キーで結んでいたため、関連データのロックが他の処理をブロックし、同じテーブルを操作する別の処理が待機状態となり、最終的にタイムアウトに至っていました。

当時の処理フローは、次のようなイメージです。

つまり、外部サービスの遅延がロック保持時間を延ばし、他の操作をブロックしていたのです。これをきっかけに、「外部DBサービスの呼び出しをトランザクション内に含めるべきか?」という根本的な設計判断に直面しました。

まず想定される理想解:二相コミット

外部サービスとデータを同期するとき、最初に思い浮かぶのは「二相コミット」です。

これは、複数のデータストアをまたいで1つのトランザクションとして扱うための古典的な手法で、理論的には「どちらか一方だけが成功する」といった不整合を防ぐことができます。二相コミットでは、すべての参加者が「コミット可能か?」を確認し、全員がOKならコミット、1人でもNGなら全員ロールバックという流れで進みます。

結果として、全体成功 or 全体失敗が保証され、強整合性を実現できます。

理想的にはこの仕組みで「どちらも確実に成功/失敗」を保証できますが、Firebaseのような外部サービスは二相コミットに非対応です。

そのため、理想論としては正しいものの、現実的には採用が難しいケースが多いです。

次の選択肢:補償トランザクション

もう一つの選択肢は、外部呼び出しをDBトランザクションの外に出し、失敗時に逆操作で巻き戻す「補償トランザクション」です。二相コミットと目的は似ていますが、補償トランザクションは後から逆操作で整合性を回復する仕組みであり、二相コミットのように同時コミットで整合性を担保する方式とは異なります。

たとえば、外部DBにデータを作成した後、アプリ側の登録処理が失敗した場合に、外部DBのデータを削除して整合性を保つ、といったやり方です。

メリット

  • ロック時間を短縮でき、LockWaitTimeout を解消しやすい
  • 補償が成功すれば、DB側の整合性を保てる

デメリット

  • 補償処理の設計・運用が複雑(リトライ/再送/デッドキュー/監査など)
  • すべてのケースを網羅できず、完全な整合性の保証が難しい
  • 実装・テストコストが大きい

理論的には筋が通っていますが、「現実解」にはなりにくいのが実情です。補償処理が失敗すれば再送や監査ジョブが必要になり、結果的に「LockWaitTimeoutは解消できても、設計と運用の複雑さが跳ね返ってくる」ことになります。

現実解:ジョブ化とUXトレードオフ

外部呼び出しは、非同期ジョブ化で切り出しました。DBトランザクションを短く保つことで、LockWaitTimeout を根絶。Firebase処理はバックグラウンドで実行し、完了を待たずにレスポンスを返します。

メリット

  • ロック競合を解消できる
  • トランザクションがシンプルで安全になる

デメリット

  • 即時性が下がる

  • ジョブ処理が終わる前にUI操作を可能にする

    → 「***準備中」といったメッセージを表示するなど、UXの工夫が必要

つまり、UXと整合性のトレードオフをどう取るかを、明示的に設計で選ぶことが大切です。

ただし「すべてジョブ化すべき」ではない

LockWaitTimeout を避けたいからといって、すべての外部呼び出しをジョブにするのはUX低下につながりやすいです。

たとえば、競合が起きない操作や、一瞬で終わる処理なら、ジョブ化せずにトランザクション内で呼ぶ方がシンプルです。

  • ユーザー作成時に外部DB(Firebaseなど)へ同期

    → 新規レコードのINSERTなので競合しにくい

  • ミリ秒〜数百msで終わるAPI呼び出し

    → ロック待ちのリスクがほぼない

つまり、外部呼び出し=常にトランザクション外ではありません。

整合性・競合・コスト、それぞれのバランスを見ながら、どこで呼ぶかを決めるのが大事です。必要に応じて、補償トランザクションの実装も最適になります。

設計判断の指針

観点 トランザクション内呼び出し トランザクション外(補償トランザクション) トランザクション外(ジョブ化)
整合性 場合による(補償設計次第)
LockWaitTimeoutリスク あり なし なし
実装コスト
即時性 低(非同期処理)
UX スムーズ スムーズだが、遅め 「準備中」メッセージなど必要
適用場面 競合しない・短時間処理 競合する・やや短時間処理 競合する・遅い処理

まとめ

技術的に「正しい」二相コミットや補償設計が、ビジネス的に「最適」とは限らない。
重要なのは、「どのトレードオフを取ったのか」を明示し、監視・再試行・UXで補うこと。
そう考えると、LockWaitTimeout は単なるDBのエラーではなく、「整合性・可用性・UX」のバランスを問い直すシグナルです。
「技術的正解」に固執せず、現場のビジネス要件・UX・チーム体制にとっての最適解を選ぶことこそ、プロダクトエンジニアリングの本質です。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion