この記事は一休.com Advent Calendar 2025の13日目の記事です。
宿泊開発チームでエンジニアをしている@kosuke1012 です。
本記事では、予約処理の中で必要な在庫引当、カード決済などの各処理について、予約処理全体として成功/失敗の結果整合を実現するための実装パターンを紹介します。
現在、一休.com の宿泊予約のシステムでは、予約部分のリニューアルを進めています。
予約リニューアルプロジェクトの全体感もどこかで是非説明したいのですが、アドベントカレンダーの期日も迫ってきているため、リニューアルの中で取り組んだ、予約処理の結果整合を実現するための実装について書いてみたいと思います。
この記事内での用語の定義をしておきます。
この記事の中で「トランザクション」と言った際には、予約処理全体を指すことにしたいと思います。
また、「カード決済」「在庫引当」と言った個々の処理は「ローカルトランザクション」という言葉で表現したいと思います。
またこの記事では「ロールバック」という言葉を、DBトランザクションのロールバックに限らず、ローカルトランザクションを補償トランザクションにより論理的にロールバックすることも指して使いたいと思います。
「補償トランザクション」はロールバックを実現するための手段として利用します。
宿泊予約トランザクションの中で発行される主なローカルトランザクションは以下の通りです。
これに加えて、予約データの永続化があります。省略したものもありますが、少なくともこのようなローカルトランザクションを、予約全体として結果整合させる必要があります。
複数のローカルトランザクションを結果整合させるためのパターンとして有名なものに Saga パターン (詳しくはlearn.microsoft.com の記事 やmicroservices.io の記事 参照) があります。
自分の理解で簡単に説明すると、補償トランザクションを利用してローカルトランザクションをロールバックすること、そしてそのローカルトランザクションの実行/ロールバックを全体で結果整合させるための設計パターンのことです。今回我々も、この Saga パターンを利用しました。と言っても、Saga パターンにはいくつか種類があります。
たとえば、前述の記事にあるようなコレオグラフィパターン(ローカルトランザクション同士が相互に協調しあって全体をコントロールする)、オーケストレーションパターン(中央集権的なオーケストレータが全体のローカルトランザクションの実行/ロールバックをコントロールする) といった分類があります。
更に詳しく、各ローカルトランザクションの通信の同期/非同期、整合性が結果整合かアトミックか、を加えた分類もあります。(参考:ソフトウェアアーキテクチャ・ハードパーツ 表2-1)1
しかし一方で、(自分が調べた限り) その具体的な実装に踏み込んだ説明は多くありませんでした。
したがってこの記事では、具体的なパターンを網羅的に説明したり、パターンの中で何に該当するのかと言った体系的な説明というよりは、実際自分たちがどのような実装をしているのかというところを説明してみたいと思います。
今回、予約リニューアルに伴いドメインモデルを捉えなおし、合わせて技術的な詳細についても見直せる部分は見直してきました。
しかし、今回紹介する実装パターンについても、既存のシステムで大きな問題なくここまで運用されてきたものであるため、抜本的に設計しなおした、というものではありません。
既存のシステムをあらためて解釈し、整理できる部分は整理していき、改善できる部分は改善したところ、このような形に落ち着いた、というのが実際のところです。
トランザクションの成否を決定するローカルトランザクションのことを、「ピボットトランザクション」と呼びます。(参考:マイクロサービスパターン 4.3.2)
ピボットトランザクションが失敗した場合、そのトランザクション全体も「失敗」として扱われます。その場合、ピボットトランザクション以前に実行したローカルトランザクションも「失敗」として扱う必要があります。
これを決定し、各ローカルトランザクションはピボットトランザクションよりも前に実行されるのか、後に実行されるのかを明確にすることで、全体の設計が見通しやすくなります。我々の場合はピボットトランザクションは「予約データの永続化」と捉えました。
そして、ローカルトランザクションをピボットトランザクションの前後に並べてみると以下の図のようになります。

たとえばカード決済や在庫引当は、それが失敗したら予約も失敗として欲しい、ユーザーへの予約通知メール送信やサイトコントローラーへの予約通知については、そもそも予約が失敗していたら実行して欲しくない、と言った性格のものになります。
ピボットトランザクションよりも前に実行するローカルトランザクションは予約の成否に応じて補償トランザクションでロールバックし、ピボットトランザクションよりも後に実行するローカルトランザクションは、ピボットトランザクションが成功している以上は最終的に成功として扱いたいものになります。
後者の「最終的に成功として扱いたい」を実現するパターンとしては、Transactional Outbox パターン などがあります。この outbox はいわゆるメールの「送信トレイ」を意味していて2送信時には outbox のみを作っておいて、outbox をもとにしてリトライするなどで最終的に送信されることを目指す、というものです。
自分たちも、サイトコントローラーへの送信などはこの Transactional Outbox パターンを利用していています。具体的にはピボットトランザクションとなる予約データの永続化のトランザクションの中で、サイトコントローラー用の outbox のデータを作成しています。(それを意図して実装したというよりは、実装されているものを解釈すると Transactional Outbox パターンになっていた、という方が正確かもしれません)
そのほか、どうしようもないものは人手での運用にまわしているものもあったりします。
トランザクションが「失敗」として定義された場合、実行されたローカルトランザクションに対し補償トランザクションを実行していくことになります。この際、「ローカルトランザクションが実行済みである」ということを把握する必要が出てくると思います。
そのために、実際のローカルトランザクションを実行する前に、「補償ログ」というデータを登録します。※「補償ログ」というのは一般用語ではなく造語です。概念としては、データベースの UNDO ログに近いかもしれないです。
ピボットトランザクションが成功した場合
ピボットトランザクションが失敗した場合
たとえば、ローカルトランザクションが成功した後に、ピボットトランザクションが失敗したケースを考えます。この場合、補償ログがあればそれに対応する補償トランザクションを実行する、ということになります。
以降、補償ログ・補償トランザクションを実装する際に重要なポイントをあげていきます。
前述の通り、補償ログはローカルトランザクションの実行"前"に登録する必要があります。仮にローカルトランザクションの実行の後に補償ログを登録する、という実装にしていた場合、ローカルトランザクションの実行には成功したが、補償ログの登録には失敗した、というシチュエーションを考える必要が出てきてしまいます。これは基本的にローカルトランザクションのロールバックが不可能になってしまうはずです。
したがって、ローカルトランザクションの実行"前"である必要があるのです。UNDO ログを例に出しましたが、実行"前"に登録する必要があるというのも、データベースの Write-Ahead Logging (WAL) に似た考え方かなと思います。
また、補償ログの登録に失敗した場合、そのローカルトランザクションは実行せず、「失敗」として扱う必要があります。(その後にロールバックできなくなるため)
補償ログには、補償トランザクション実行に必要な ID などの情報を登録しておきます。したがって、ローカルトランザクション実行前に補償ログを登録するということは、ローカルトランザクション実行前に補償トランザクション実行に必要な情報がそろっている必要があるということになります。
例えば、ローカルトランザクションの実行結果としてあるリソースの ID が手に入り、その ID が補償トランザクションのリクエストパラメータとして要求されるような API では、この要件を満たすことが出来ません。
なお補償ログには補償トランザクション実行に必要十分な ID などの保存にとどめ、逆に個人情報等は保存しないようにします。
補償トランザクションを実行する場合、補償ログは補償トランザクションの実行後に削除する必要があります。補償ログの登録の話と同じく、仮に補償ログを削除してから補償トランザクションを実行するようにした場合、補償トランザクション実行に失敗した場合に、補償トランザクションを再度実行できなくなってしまいます。
またさらに、補償ログの削除はピボットトランザクションの成功後に削除する必要があります。ピボットトランザクションがトランザクション全体の成否を決定するため、ピボットトランザクションが成功するまでは、ローカルトランザクションをロールバックする必要がある可能性があるためです。したがって、ピボットトランザクションが成功するまでは補償ログを削除することは出来ません。
補償トランザクションは冪等である必要があります。3これは、
などで、再度補償トランザクションが実行されうる状態になるためです。
ここまでで、ローカルトランザクションの補償ログと、それを利用した補償トランザクションの実行のための実装パターンを説明しました。ピボットトランザクションと、ローカルトランザクションを関係づけることで、トランザクション全体の結果整合性を実現することが出来るようになります。
まず、ピボットトランザクションに対しても、ローカルトランザクションと同様、それが進行中であることを示す必要があります。ピボットトランザクションに対する補償トランザクションは存在しないため、補償ログではなく「ピボットマーカー」と呼ぶことにします。※この「ピボットマーカー」も一般用語ではなく今回導入した造語です。
このピボットマーカーを、ローカルトランザクションの開始前にまず作成し、そして、ピボットトランザクションとアトミックに削除することで、全体としての結果整合性が実現できることになります。
イメージは以下の通りです。
重要な点は以下になります。
トランザクション全体で結果整合性を担保する上で、これが最も重要です。ピボットマーカーは、ピボットトランザクションとアトミックに削除する必要があります。
これにより、
と解釈出来るようになります。
我々は、ピボットマーカーを予約データ永続化先と同じ DB に保存し、予約データ永続化と同じ DB トランザクションでピボットマーカーを削除することで、この要件を満たしています。
補償ログとピボットマーカーに親子関係を設けることで、ローカルトランザクションの補償トランザクションの実行要否とピボットトランザクション成否を結びつけることが出来ます。これにより、トランザクション全体の結果整合性を担保することが出来ます。
と解釈することが出来ます。
常にピボットマーカーから補償ログを辿って補償トランザクションを実行するようにします。こうすることで、ピボットマーカーが存在している場合にのみ補償トランザクションが実行されるようになります。つまり、ピボットトランザクションが成功した場合は絶対に補償トランザクションが実行されることはない、とすることが出来ます。
ここまでの実装で、トランザクション全体としてロールバックを冪等に実行することが出来るようになります。ローカルトランザクションの一部が失敗した場合を考えてみます。

このようにして、トランザクション全体をロールバックすることが出来ました。またこのプロセスは、冪等に実行することが可能です。
ロールバック処理では、複数ある補償トランザクションのうちのひとつの実行に失敗したりすることがあり得ます。そのほか、サーバーのプロセスごと落ちたなどでロールバック全体が完了しなかった場合にも、実行する必要のある補償トランザクションを確実に実行する必要があります。
そのため我々は、一連のロールバック処理を予約の失敗時にサーバーから同期的に実行することに加えて、定期的に残っているピボットマーカーを見て、サーバーから実行したものと同じロールバック処理を再実行するジョブを Cloud Run Jobs で用意しています。
ロールバック処理を冪等に実行出来るようにすることで、このように確実にロールバックが完了するように実装することが出来ます。
ここまで説明してきた実装パターンが適用出来る前提として、以下があります。4
これよりも厳しい要件が必要な場合、この実装パターンそのままは適用できません。
紹介した実装パターンの特徴や利点として以下の点があげられると思います。これらについては、既存のシステムからも、実際実装していて改善しているなというところを感じることが出来ています。
ここまで書いておいてなんですが、アプリケーションロジックを実装する際は、このようなことを気にせずに進められるならそれに越したことはないと思います。
ここまで説明してきた実装パターンは、主に I/O を実行するレイヤでのみ気にすれば良いものになっています。したがって、ドメインロジックと I/O を適切に分離できていれば、ここまでの補償トランザクション周りの実装についても、ドメインロジックを実装する際に意識する必要はなくなります。
ローカルトランザクション毎に補償ログ・補償トランザクションの実装を用意すれば、ローカルトランザクションを追加することは比較的容易です。実際に予約リニューアルプロジェクトを進める中で、段階的にローカルトランザクションを大きな労力なく追加していくことが出来ました。
ローカルトランザクションそれぞれの独立性が高いため、ローカルトランザクションの実行タイミングや順序などが変更しやすくなります。例えば在庫引当はもっと早いタイミングに実行してしまいたい、と言った変更です。
一休では、ユーザーにより良い体験を提供するため、より良いシステムを一緒につくっていくエンジニアを募集しています。
まずはカジュアル面談からお気軽にご応募ください!
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。