/note/tech

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

要約:

■ 1. 背景と用語定義

  • 一休.comの宿泊予約のシステムで予約部分のリニューアルを進めている
  • リニューアルの中で取り組んだ予約処理の結果整合を実現するための実装について説明する
  • 用語の定義:
    • トランザクション:予約処理全体を指す
    • ローカルトランザクション:カード決済や在庫引当と言った個々の処理を指す
    • ロールバック:DBトランザクションのロールバックに限らずローカルトランザクションを補償トランザクションにより論理的にロールバックすることも指す
    • 補償トランザクション:ロールバックを実現するための手段として利用する

■ 2. 要件

  • 宿泊予約トランザクションの中で発行される主なローカルトランザクション:
    • 在庫引当
    • カード決済
    • 一休ポイント登録
    • サイトコントローラーへの通知
    • ユーザーへのメール通知
  • これに加えて予約データの永続化がある
  • 少なくともこのようなローカルトランザクションを予約全体として結果整合させる必要がある

■ 3. Sagaパターンの概要

  • 複数のローカルトランザクションを結果整合させるためのパターンとして有名なものにSagaパターンがある
  • Sagaパターンは補償トランザクションを利用してローカルトランザクションをロールバックすることそしてそのローカルトランザクションの実行やロールバックを全体で結果整合させるための設計パターンである
  • Sagaパターンにはいくつか種類がある:
    • コレオグラフィパターン:ローカルトランザクション同士が相互に協調しあって全体をコントロールする
    • オーケストレーションパターン:中央集権的なオーケストレータが全体のローカルトランザクションの実行やロールバックをコントロールする
  • 更に詳しく各ローカルトランザクションの通信の同期や非同期整合性が結果整合かアトミックかを加えた分類もある
  • 一方でその具体的な実装に踏み込んだ説明は多くない
  • この記事では具体的なパターンを網羅的に説明したりパターンの中で何に該当するのかと言った体系的な説明というよりは実際自分たちがどのような実装をしているのかというところを説明する

■ 4. リニューアルの実際

  • 予約リニューアルに伴いドメインモデルを捉えなおし合わせて技術的な詳細についても見直せる部分は見直してきた
  • 今回紹介する実装パターンについても既存のシステムで大きな問題なくここまで運用されてきたものであるため抜本的に設計しなおしたというものではない
  • 既存のシステムをあらためて解釈し整理できる部分は整理していき改善できる部分は改善したところこのような形に落ち着いたというのが実際のところである

■ 5. ピボットトランザクションの決定とローカルトランザクションの分類

  • トランザクションの成否を決定するローカルトランザクションのことをピボットトランザクションと呼ぶ
  • ピボットトランザクションが失敗した場合そのトランザクション全体も失敗として扱われる
  • その場合ピボットトランザクション以前に実行したローカルトランザクションも失敗として扱う必要がある
  • これを決定し各ローカルトランザクションはピボットトランザクションよりも前に実行されるのか後に実行されるのかを明確にすることで全体の設計が見通しやすくなる
  • ピボットトランザクションは予約データの永続化と捉えた
  • ローカルトランザクションの性格:
    • カード決済や在庫引当はそれが失敗したら予約も失敗として欲しい
    • ユーザーへの予約通知メール送信やサイトコントローラーへの予約通知についてはそもそも予約が失敗していたら実行して欲しくない
  • ピボットトランザクションよりも前に実行するローカルトランザクションは予約の成否に応じて補償トランザクションでロールバックする
  • ピボットトランザクションよりも後に実行するローカルトランザクションはピボットトランザクションが成功している以上は最終的に成功として扱いたいものになる
  • 後者の最終的に成功として扱いたいを実現するパターンとしてはTransactional Outboxパターンなどがある
  • このoutboxはいわゆるメールの送信トレイを意味していて送信時にはoutboxのみを作っておいてoutboxをもとにしてリトライするなどで最終的に送信されることを目指すというものである
  • サイトコントローラーへの送信などはこのTransactional Outboxパターンを利用している
  • 具体的にはピボットトランザクションとなる予約データの永続化のトランザクションの中でサイトコントローラー用のoutboxのデータを作成している
  • どうしようもないものは人手での運用にまわしているものもある

■ 6. 補償トランザクションの実装パターンと補償ログの導入

  • トランザクションが失敗として定義された場合実行されたローカルトランザクションに対し補償トランザクションを実行していくことになる
  • この際ローカルトランザクションが実行済みであるということを把握する必要が出てくる
  • そのために実際のローカルトランザクションを実行する前に補償ログというデータを登録する
  • 補償ログというのは一般用語ではなく造語である
  • 概念としてはデータベースのUNDOログに近い
  • ローカルトランザクションが成功した後にピボットトランザクションが失敗したケースを考える
  • この場合補償ログがあればそれに対応する補償トランザクションを実行するということになる
  • 補償ログ・補償トランザクションを実装する際に重要なポイント:
    • ポイント1:補償ログはローカルトランザクションの実行前に登録する:
      • 補償ログはローカルトランザクションの実行前に登録する必要がある
      • 仮にローカルトランザクションの実行の後に補償ログを登録するという実装にしていた場合ローカルトランザクションの実行には成功したが補償ログの登録には失敗したというシチュエーションを考える必要が出てきてしまう
      • これは基本的にローカルトランザクションのロールバックが不可能になってしまう
      • したがってローカルトランザクションの実行前である必要がある
      • UNDOログを例に出したが実行前に登録する必要があるというのもデータベースのWrite-Ahead Loggingに似た考え方である
      • 補償ログの登録に失敗した場合そのローカルトランザクションは実行せず失敗として扱う必要がある
    • ポイント2:ローカルトランザクション実行前に補償トランザクション実行に必要な情報がそろっている必要がある:
      • 補償ログには補償トランザクション実行に必要なIDなどの情報を登録しておく
      • したがってローカルトランザクション実行前に補償ログを登録するということはローカルトランザクション実行前に補償トランザクション実行に必要な情報がそろっている必要があるということになる
      • 例えばローカルトランザクションの実行結果としてあるリソースのIDが手に入りそのIDが補償トランザクションのリクエストパラメータとして要求されるようなAPIではこの要件を満たすことが出来ない
      • 補償ログには補償トランザクション実行に必要十分なIDなどの保存にとどめ逆に個人情報等は保存しないようにする
    • ポイント3:補償ログはピボットトランザクションの成功後に削除する:
      • 補償トランザクションを実行する場合補償ログは補償トランザクションの実行後に削除する必要がある
      • 補償ログの登録の話と同じく仮に補償ログを削除してから補償トランザクションを実行するようにした場合補償トランザクション実行に失敗した場合に補償トランザクションを再度実行できなくなってしまう
      • さらに補償ログの削除はピボットトランザクションの成功後に削除する必要がある
      • ピボットトランザクションがトランザクション全体の成否を決定するためピボットトランザクションが成功するまではローカルトランザクションをロールバックする必要がある可能性があるため
      • したがってピボットトランザクションが成功するまでは補償ログを削除することは出来ない
    • ポイント4:補償トランザクションは冪等にする:
      • 補償トランザクションは冪等である必要がある
      • これは補償トランザクション実行に失敗した場合や補償トランザクションに成功した後補償ログの削除に失敗した場合などで再度補償トランザクションが実行されうる状態になるためである

■ 7. ピボットトランザクションとピボットマーカーの導入

  • ローカルトランザクションの補償ログとそれを利用した補償トランザクションの実行のための実装パターンを説明した
  • ピボットトランザクションとローカルトランザクションを関係づけることでトランザクション全体の結果整合性を実現することが出来るようになる
  • ピボットトランザクションに対してもローカルトランザクションと同様それが進行中であることを示す必要がある
  • ピボットトランザクションに対する補償トランザクションは存在しないため補償ログではなくピボットマーカーと呼ぶことにする
  • このピボットマーカーも一般用語ではなく今回導入した造語である
  • このピボットマーカーをローカルトランザクションの開始前にまず作成しそしてピボットトランザクションとアトミックに削除することで全体としての結果整合性が実現できることになる
  • 重要な点:
    • ポイント1:ピボットマーカーはピボットトランザクションとアトミックに削除する:
      • トランザクション全体で結果整合性を担保する上でこれが最も重要である
      • ピボットマーカーはピボットトランザクションとアトミックに削除する必要がある
      • これによりピボットマーカーが存在している=ピボットトランザクションが完了していないピボットマーカーが存在しない=ピボットトランザクションが成功したと解釈出来るようになる
      • ピボットマーカーを予約データ永続化先と同じDBに保存し予約データ永続化と同じDBトランザクションでピボットマーカーを削除することでこの要件を満たしている
    • ポイント2:補償ログとピボットマーカーに親子関係を設ける:
      • 補償ログとピボットマーカーに親子関係を設けることでローカルトランザクションの補償トランザクションの実行要否とピボットトランザクション成否を結びつけることが出来る
      • これによりトランザクション全体の結果整合性を担保することが出来る
      • ピボットマーカーが存在していれば実行済みのローカルトランザクションが存在する可能性がありロールバックする場合はローカルトランザクションに対して補償トランザクションを実行する必要がある
      • ピボットマーカーが存在しなければトランザクション全体を成功とみなすためローカルトランザクションに対して補償トランザクションを実行する必要はないと解釈することが出来る
    • ポイント3:補償トランザクションを実行する際は常にピボットマーカーを起点に実行する:
      • 常にピボットマーカーから補償ログを辿って補償トランザクションを実行するようにする
      • こうすることでピボットマーカーが存在している場合にのみ補償トランザクションが実行されるようになる
      • つまりピボットトランザクションが成功した場合は絶対に補償トランザクションが実行されることはないとすることが出来る

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

  • ここまでの実装でトランザクション全体としてロールバックを冪等に実行することが出来るようになる
  • ローカルトランザクションの一部が失敗した場合を考える
  • ロールバックの流れ:
    • ピボットマーカーが作成され
    • その後のローカルトランザクション1と2と3と実行されるがローカルトランザクション3が失敗し
    • ローカルトランザクション1と2に対して補償トランザクションを実行してロールバック
    • 補償ログ削除
    • 最後にピボットマーカーを削除
  • このようにしてトランザクション全体をロールバックすることが出来た
  • このプロセスは冪等に実行することが可能である
  • ロールバック処理では複数ある補償トランザクションのうちのひとつの実行に失敗したりすることがあり得る
  • そのほかサーバーのプロセスごと落ちたなどでロールバック全体が完了しなかった場合にも実行する必要のある補償トランザクションを確実に実行する必要がある
  • そのため一連のロールバック処理を予約の失敗時にサーバーから同期的に実行することに加えて定期的に残っているピボットマーカーを見てサーバーから実行したものと同じロールバック処理を再実行するジョブをCloud Run Jobsで用意している
  • ロールバック処理を冪等に実行出来るようにすることでこのように確実にロールバックが完了するように実装することが出来る

■ 9. 制約

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

■ 10. この実装パターンの特徴と利点

  • 紹介した実装パターンの特徴や利点:
    • Sagaパターンなどを意識せずドメインロジックの実装が可能:
      • アプリケーションロジックを実装する際はこのようなことを気にせずに進められるならそれに越したことはない
      • ここまで説明してきた実装パターンは主にI/Oを実行するレイヤでのみ気にすれば良いものになっている
      • したがってドメインロジックとI/Oを適切に分離できていればここまでの補償トランザクション周りの実装についてもドメインロジックを実装する際に意識する必要はなくなる
    • ローカルトランザクションの追加が容易:
      • ローカルトランザクション毎に補償ログ・補償トランザクションの実装を用意すればローカルトランザクションを追加することは比較的容易である
      • 実際に予約リニューアルプロジェクトを進める中で段階的にローカルトランザクションを大きな労力なく追加していくことが出来た
    • ローカルトランザクションの変更が容易:
      • ローカルトランザクションそれぞれの独立性が高いためローカルトランザクションの実行タイミングや順序などが変更しやすくなる
      • 例えば在庫引当はもっと早いタイミングに実行してしまいたいと言った変更である