一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

予約処理で結果整合を実現するための実装パターン

この記事は一休.com Advent Calendar 2025の13日目の記事です。

宿泊開発チームでエンジニアをしている @kosuke1012 です。

本記事では、予約処理の中で必要な在庫引当、カード決済などの各処理について、予約処理全体として成功/失敗の結果整合を実現するための実装パターンを紹介します。

背景

現在、一休.com の宿泊予約のシステムでは、予約部分のリニューアルを進めています。

予約リニューアルプロジェクトの全体感もどこかで是非説明したいのですが、アドベントカレンダーの期日も迫ってきているため、 リニューアルの中で取り組んだ、予約処理の結果整合を実現するための実装について書いてみたいと思います。

用語

この記事内での用語の定義をしておきます。

この記事の中で「トランザクション」と言った際には、予約処理全体を指すことにしたいと思います。

また、「カード決済」「在庫引当」と言った個々の処理は「ローカルトランザクション」という言葉で表現したいと思います。

またこの記事では「ロールバック」という言葉を、DBトランザクションのロールバックに限らず、ローカルトランザクションを補償トランザクションにより論理的にロールバックすることも指して使いたいと思います。

「補償トランザクション」はロールバックを実現するための手段として利用します。

要件

宿泊予約トランザクションの中で発行される主なローカルトランザクションは以下の通りです。

  • 在庫引当
  • カード決済
  • 一休ポイント登録
  • サイトコントローラーへの通知
  • ユーザーへのメール通知

これに加えて、予約データの永続化があります。 省略したものもありますが、少なくともこのようなローカルトランザクションを、予約全体として結果整合させる必要があります。

Saga パターン

複数のローカルトランザクションを結果整合させるためのパターンとして有名なものに 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 ログに近いかもしれないです。

ピボットトランザクションが成功した場合

ピボットトランザクションが失敗した場合

たとえば、ローカルトランザクションが成功した後に、ピボットトランザクションが失敗したケースを考えます。 この場合、補償ログがあればそれに対応する補償トランザクションを実行する、ということになります。

以降、補償ログ・補償トランザクションを実装する際に重要なポイントをあげていきます。

1. 補償ログはローカルトランザクションの実行前に登録する

前述の通り、補償ログはローカルトランザクションの実行"前"に登録する必要があります。 仮にローカルトランザクションの実行の後に補償ログを登録する、という実装にしていた場合、 ローカルトランザクションの実行には成功したが、補償ログの登録には失敗した、というシチュエーションを考える必要が出てきてしまいます。これは基本的にローカルトランザクションのロールバックが不可能になってしまうはずです。

したがって、ローカルトランザクションの実行"前"である必要があるのです。 UNDO ログを例に出しましたが、実行"前"に登録する必要があるというのも、 データベースの Write-Ahead Logging (WAL) に似た考え方かなと思います。

また、補償ログの登録に失敗した場合、そのローカルトランザクションは実行せず、「失敗」として扱う必要があります。 (その後にロールバックできなくなるため)

2. ローカルトランザクション実行前に補償トランザクション実行に必要な情報がそろっている必要がある

補償ログには、補償トランザクション実行に必要な ID などの情報を登録しておきます。 したがって、ローカルトランザクション実行前に補償ログを登録するということは、 ローカルトランザクション実行前に補償トランザクション実行に必要な情報がそろっている必要があるということになります。

例えば、ローカルトランザクションの実行結果としてあるリソースの ID が手に入り、その ID が補償トランザクションのリクエストパラメータとして 要求されるような API では、この要件を満たすことが出来ません。

なお補償ログには補償トランザクション実行に必要十分な ID などの保存にとどめ、逆に個人情報等は保存しないようにします。

3. 補償ログはピボットトランザクションの成功後に削除する

補償トランザクションを実行する場合、補償ログは補償トランザクションの実行後に削除する必要があります。 補償ログの登録の話と同じく、仮に補償ログを削除してから補償トランザクションを実行するようにした場合、 補償トランザクション実行に失敗した場合に、補償トランザクションを再度実行できなくなってしまいます。

またさらに、補償ログの削除はピボットトランザクションの成功後に削除する必要があります。 ピボットトランザクションがトランザクション全体の成否を決定するため、ピボットトランザクションが成功するまでは、 ローカルトランザクションをロールバックする必要がある可能性があるためです。 したがって、ピボットトランザクションが成功するまでは補償ログを削除することは出来ません。

4. 補償トランザクションは冪等にする

補償トランザクションは冪等である必要があります。3 これは、

  • 補償トランザクション実行に失敗した場合
  • 補償トランザクションに成功した後、補償ログの削除に失敗した場合

などで、再度補償トランザクションが実行されうる状態になるためです。

ピボットトランザクションと「ピボットマーカー」の導入

ここまでで、ローカルトランザクションの補償ログと、それを利用した補償トランザクションの実行のための実装パターンを説明しました。 ピボットトランザクションと、ローカルトランザクションを関係づけることで、トランザクション全体の結果整合性を実現することが出来るようになります。

まず、ピボットトランザクションに対しても、ローカルトランザクションと同様、それが進行中であることを示す必要があります。 ピボットトランザクションに対する補償トランザクションは存在しないため、補償ログではなく「ピボットマーカー」と呼ぶことにします。 ※この「ピボットマーカー」も一般用語ではなく今回導入した造語です。

このピボットマーカーを、ローカルトランザクションの開始前にまず作成し、そして、ピボットトランザクションとアトミックに削除することで、 全体としての結果整合性が実現できることになります。

イメージは以下の通りです。

重要な点は以下になります。

1. ピボットマーカーはピボットトランザクションとアトミックに削除する

トランザクション全体で結果整合性を担保する上で、これが最も重要です。 ピボットマーカーは、ピボットトランザクションとアトミックに削除する必要があります。

これにより、

  • ピボットマーカーが存在している=ピボットトランザクションが完了していない
  • ピボットマーカーが存在しない=ピボットトランザクションが成功した

と解釈出来るようになります。

我々は、ピボットマーカーを予約データ永続化先と同じ DB に保存し、予約データ永続化と同じ DB トランザクションでピボットマーカーを削除することで、 この要件を満たしています。

2. 補償ログとピボットマーカーに親子関係を設ける

補償ログとピボットマーカーに親子関係を設けることで、 ローカルトランザクションの補償トランザクションの実行要否と ピボットトランザクション成否を結びつけることが出来ます。 これにより、トランザクション全体の結果整合性を担保することが出来ます。

  • ピボットマーカーが存在していれば、実行済みのローカルトランザクションが存在する可能性があり、ロールバックする場合はローカルトランザクションに対して補償トランザクションを実行する必要がある
  • ピボットマーカーが存在しなければ、トランザクション全体を成功とみなすため、ローカルトランザクションに対して補償トランザクションを実行する必要はない

と解釈することが出来ます。

3. 補償トランザクションを実行する際は常にピボットマーカーを起点に実行する

常にピボットマーカーから補償ログを辿って補償トランザクションを実行するようにします。 こうすることで、ピボットマーカーが存在している場合にのみ補償トランザクションが実行されるようになります。 つまり、ピボットトランザクションが成功した場合は絶対に補償トランザクションが実行されることはない、とすることが出来ます。

トランザクション全体のロールバックの例

ここまでの実装で、トランザクション全体としてロールバックを冪等に実行することが出来るようになります。 ローカルトランザクションの一部が失敗した場合を考えてみます。

  1. ピボットマーカーが作成され、
  2. その後のローカルトランザクション1, 2, 3 と実行されるが ローカルトランザクション3 が失敗し、
  3. ローカルトランザクション1, 2 に対して補償トランザクションを実行してロールバック
  4. 補償ログ削除
  5. 最後にピボットマーカーを削除

このようにして、トランザクション全体をロールバックすることが出来ました。 またこのプロセスは、冪等に実行することが可能です。

ロールバック処理では、複数ある補償トランザクションのうちのひとつの実行に失敗したりすることがあり得ます。 そのほか、サーバーのプロセスごと落ちたなどでロールバック全体が完了しなかった場合にも、 実行する必要のある補償トランザクションを確実に実行する必要があります。

そのため我々は、一連のロールバック処理を予約の失敗時にサーバーから同期的に実行することに加えて、 定期的に残っているピボットマーカーを見て、サーバーから実行したものと同じロールバック処理を再実行するジョブを Cloud Run Jobs で用意しています。

ロールバック処理を冪等に実行出来るようにすることで、このように確実にロールバックが完了するように実装することが出来ます。

制約

ここまで説明してきた実装パターンが適用出来る前提として、以下があります。 4

  • ローカルトランザクションが同期的に実行できること
    • ここでいう「同期的」とは、ローカルトランザクションの成否がピボットトランザクション実行までに確定していることを指します
  • ローカルトランザクション同士が強く結合していないこと
    • 順序制約はあってもよいが、補償トランザクションに必要な情報が前段の実行結果に依存しない(=実行前に補償ログへ必要情報を確定できる)こと
  • トランザクション全体としての一貫性は結果整合で良いこと
    • 予約失敗の場合には一時的にでも在庫が引当されてはいけない、と言った制約がある場合はこの実装パターンには向きません

これよりも厳しい要件が必要な場合、この実装パターンそのままは適用できません。

この実装パターンの特徴・利点

紹介した実装パターンの特徴や利点として以下の点があげられると思います。 これらについては、既存のシステムからも、実際実装していて改善しているなというところを感じることが出来ています。

Saga パターンなどを意識せずドメインロジックの実装が可能

ここまで書いておいてなんですが、アプリケーションロジックを実装する際は、このようなことを気にせずに進められるならそれに越したことはないと思います。

ここまで説明してきた実装パターンは、主に I/O を実行するレイヤでのみ気にすれば良いものになっています。 したがって、ドメインロジックと I/O を適切に分離できていれば、ここまでの補償トランザクション周りの実装についても、 ドメインロジックを実装する際に意識する必要はなくなります。

ローカルトランザクションの追加が容易

ローカルトランザクション毎に補償ログ・補償トランザクションの実装を用意すれば、ローカルトランザクションを追加することは比較的容易です。 実際に予約リニューアルプロジェクトを進める中で、段階的にローカルトランザクションを大きな労力なく追加していくことが出来ました。

ローカルトランザクションの変更が容易

ローカルトランザクションそれぞれの独立性が高いため、ローカルトランザクションの実行タイミングや順序などが変更しやすくなります。 例えば在庫引当はもっと早いタイミングに実行してしまいたい、と言った変更です。

おわりに

一休では、ユーザーにより良い体験を提供するため、より良いシステムを一緒につくっていくエンジニアを募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

job.persona-ats.com


  1. 我々はオーケストレーションパターン、同期通信、結果整合ということで「おとぎ話 Saga」というものに分類されるようです
  2. 世代を選ぶ話題かもしれないですが
  3. ローカルトランザクションが外部 I/O を含む場合、ローカルトランザクションも冪等であることを必要とされることが多いと思います
  4. ここでいう制約が、まさに 1. で紹介した「おとぎ話 Saga」の特徴です