アプリケーションにおけるデータ不整合との戦い
これは Aizu Advent Calendar 2019 の 15 日目の記事です。14 日目は uzimaru0000 さん、16 日目は kacky__917 さんです。
はじめに
世の中には日々たくさんの価値ある Web サービスが生まれていますが、その価値を正しく提供するにはアプリケーションが正しく動かなければなりません。
たとえばアプリケーションは適切なユーザに適切なリソースを提供しなければならず、エラーを返す際は十分に定義された仕様に沿って返し、UI 側ではユーザに適切なメッセージを表示しなければなりません。
実際のところ、これらを厳密に実現するのは非常に困難ですが、アプリケーションにはこれら以上に複雑な問題が常につきまといます。
現在の Web アプリケーションはほとんどが分散システムの一形態です。例えばクライアントとサーバや、サーバとデータベースがネットワークを介して接続されていればそれは分散システムと呼べますし、分散システム特有の問題と対峙しないといけないことを意味します。分散コンピューティングの落とし穴 には「ネットワークは信頼できる (The network is reliable)」という落とし穴が挙げられていますが、多くのアプリケーションはネットワークを無条件に信頼しています。
エラー
データベースを持つシステムや、複数のバックエンドサービスがネットワークを介して接続されているようなシステムでは非常に様々なエラーが発生します。たとえばデータベースへの負荷が高まったことにより書き込みを受け付けられなくなったり、データベースのクラッシュ、サーバとデータベース間の通信でネットワークエラーが発生し、書き込みを行えなかったりといった問題が挙げられます。 バックエンドのサービスを提供するサーバがただ一つしかない場合、バックエンドは内部エラーを返すだけですが、サービスが複数になると途端に問題は複雑になります。
フロントエンドからの API リクエストを受け付けるバックエンドのサービス A がサービス B へ内部的に API リクエストを行い、サービス B がデータベースへ書き込みを行い、書き込んだデータオブジェクトをクライアントに返すようなシーケンスを考えてみます。ちょうど以下のような図になります。
どういったエラーケースがあるか一つ考えてみましょう。
たとえばデータベースが何らかの問題によりダウンしており、サービス B がデータの書き込みに失敗し、エラーが返るようなケースはすぐに思いつくと思います。このケースを見るだけだとそこまで複雑には感じませんが、実際には以下のようなケースが起こりえます。
- サービス A へのリクエストに失敗した
- サービス A からサービス B へのリクエストに失敗した
- データベースへの書き込みが失敗した (上記の例)
- データベースへの書き込みは成功したがネットワークエラーによりサービス B には失敗として返った
- データベースへの書き込みは成功したがサービス B がサービス A にレスポンスを返す途中でネットワークエラーにより失敗として返った
- データベースへの書き込みが成功したがサービス A がクライアントにレスポンスを返す途中でネットワークエラーにより失敗として返った
このようにさまざまなケースでエラーが起こることがわかるかと思います。 さらに、データベースへの書き込みが成功しているにも関わらず失敗となってしまっている 4 から 6 のケースは、データベースに保存された結果とレスポンスの内容に不一致が生じてしまっています。もしこの結果をユーザが見た場合、強い混乱を与えることになるでしょう。
当然ながらアプリケーションで事前に定義したエラー *1 やデータベースエラー・ネットワークエラー以外のエラー *2 も発生しうるため、列挙したエラーケースはほんの一部に過ぎません。
複数のデータベースとデータ整合性
先程の例ではデータベースはただ一つしかありませんでしたが、サービス A もデータベースを持ち、サービス A がデータベース A へ書き込みを行ったあとにサービス B へリクエストを行うものとしてみると複雑さはさらに増加します。
- サービス A へのリクエストに失敗した
- データベース A への書き込みに失敗した
- サービス B へのリクエストに失敗した
- データベース B への書き込みに失敗した
- データベース A への書き込みが成功したがサービス A には失敗として返った
- データベース B への書き込みは成功したがネットワークエラーによりサービス B には失敗として返った
- サービス B がサービス A にレスポンスを返す途中でネットワークエラーにより失敗として返った
- データベース A の書き込みに失敗し、サービス A がクライアントにエラーレスポンスを返す途中でネットワークエラーにより失敗として返った
- すべて正常に処理されたが、サービス A がクライアントにレスポンスを返す途中でネットワークエラーにより失敗として返った
先程の例では状態を保持しているデータベースが一つであり、その他のコンポーネントはステートレスでしたが、状態が複数のデータベースに保存されるようになるとデータ不整合の問題がかならず発生します。
たとえば 3、4、5、8 のケースでは、データベース A にはデータが書き込まれているのにも関わらずデータベース B にはデータが書き込まれておらず、データ不整合が発生しています。本来であれば、リクエストが失敗として返ったのであればデータは一切書き込まれておらず、リクエストが成功として返ったのであれば両方のデータベースにデータが書き込まれているという状態であるべきです。
不整合を許容できるデータ *3 なのであれば問題ありませんが、そうではない場合、どうにかして不整合を正しい状態に修復 (repairing) する必要が出てきます。
データ不整合の修復
Write Repair
Write Repair は書き込み処理を行う際にデータ不整合の修復を行う仕組みです。上記のようなシステムで Write Repair を実現する最もメジャーな手法はリトライを行うことです。
リトライ
リトライは可用性を高めるためにもしばしば使用されるため馴染みが深いかと思います *4。
発生したエラーがタイムアウト等の一時的なものの場合、API リクエストのリトライを行うことで解決し、正常なレスポンスを受け取れる可能性があります。当然ながらクライアントエラーなどのリトライ不可能なエラーはリトライしても意味がないため、バックエンドサービスにはリトライ可能なエラーと不可能なエラーを明確に区別し、ハンドリングする責務があります。
多重リクエストと同一性
しかし、タイムアウト等のエラーでは、バックエンドの処理が成功した直後にクライアントがタイムアウトと判断してしまい、リトライを行ってしまうようなケースが発生することがあります。 その結果、一度目のリクエストが正常に処理されているにも関わらず再度 API リクエストをしてしまうため、二重にリクエストされることになります。 これがリソースの作成、例えばブログでいう記事の投稿であればまったく同じ記事が二重に投稿されてしまうことになります。 そのくらいであればユーザは許容できるかもしれませんが、これが決済システムであれば多重決済となり深刻な問題となります。
また、サーバが全く同じボディやパラメータを持つ複数の API リクエストを受け取った際、それらが別々なものなのかリトライや重複して送られたもの (e.g. ボタンを短期間で複数回クリックした) ものなのかを判別する手段はありません。
リトライを行いつつ、多重にリクエストが処理されるのを防ぐ方法はいくつかあります。たとえば冪等性 *5 を担保するための ID を用意し、その ID が一致していれば同一のリクエストとみなす方法があります。
これは Stripe 等では Idempotent Requests と呼ばれるパターンで、ID は Idempotency Key と呼ばれます。
サーバはどのリクエストが同一かを判断する術を持たないため、Idempotency Key 生成の責務は常にクライアントが持つことになります。
Idempotent Requests
クライアントはユーザ等からの入力をもとに Idempotency Key を生成します。キーの同一性さえ担保できていれば良いため、入力のハッシュ値でも良いですし、UUID v4、ユニークな入力など、特に制約はありません。
サーバはクライアントから受け取った Idempotency Key をチェックし、すでに同じ Idempotency Key を持つリクエストを受け取っていた場合、冪等性を担保できるように処理を行います。例えばブログの記事を新規に作成し、レスポンスにその内容・詳細を返す API へのリクエストであれば、冪等性チェックを行い、すでに記事が作成されていた場合はデータベースに保存されているデータをもとにレスポンスを生成し返却します。Idempotency Key が同一であるため新たに記事の作成は行いません。
このようにシステム全体で冪等性が担保された設計になっており、各バックエンドサービスがそれらのエラーを適切にハンドリングし、リトライ可能なレスポンスステータスを返していれば、そのクライアントはリトライを行うことにより正常に処理できるようになります。各リクエストには安全にリトライができるように Idempotency Key を付与します。
トランザクションの状態管理
バックエンドサービス内で冪等性を担保した実装を行うにあたって、どのように冪等性のチェックを行えばいいのか、という問題があります。
例えば、トランザクション (原子性を担保したい処理群) に含まれる操作が 1 つのデータベースへの書き込みのみで、そのプライマリキーを決定論的に求めることができる (例えば入力から求められる) のであれば事は簡単です。単純にプライマリキーでデータベースから参照し、データが存在すれば以前に処理されている (= 今のリクエストは多重に送られたもの) ということになります。
ただし、現実のシステムはそんなに単純ではなく、処理の途中で外部のシステムへアクセスしたり、データベースへの書き込みや更新を複数回行うものがほとんどです。データベースへの永続化以外のすべての処理が終わってから永続化すれば原子性を担保することはできますが、トランザクションが非常に大きくなってしまうので、ある一つの処理が失敗しただけではじめからやり直すことになってしまい現実的ではありません。
そこで、ステートマシンパターンを使うとトランザクションをより粒度の細かい単位に分割して、リトライの単位をそれぞれに分離することができます。 まずトランザクション開始時に初期状態をデータベースに永続化します。次の状態 (dst) へ遷移するためのアクションの処理を行い、すべての処理が成功した場合に dst へ状態を更新します。
クライアントがリトライをしてきた場合は、まずトランザクションの今の状態を取り出します。状態が終了状態ではない場合、以前の処理が正常に完了しなかったことを意味するので今の状態から次の状態に遷移できるように処理を再開します。冪等性もステートの単位で担保すれば良くなります。
Asynchronous Repair
Write Repair によりデータ不整合を修復できるようにはなりましたが、それでもリトライ試行時間内で解決できなかったケースは発生します。また、エラーハンドリングにバグがありリトライ不可能なエラーとしてクライアントに返してしまったためリトライされなかったようなケースもあるかもしれません。
そこで Write Repair に加えて Asynchronous Repair が必要になります。Asynchronous Repair は通常の処理とは独立して非同期で動き、データ不整合を修復する仕組みです。 Asynchronous Repair では例えば定期的に実行されるバッチを用意し、バッチは処理されたデータ (上記の例であればデータベース A と B のデータ) を確認し、不整合があれば修復します。
また、上記のようにトランザクションの状態管理をステートマシンにより行っている場合は終了状態になっていないトランザクションを取り出し、終了状態に進めるように処理を再開することでデータの修復を行うことができるケースもあります。
データ競合の回避
Asynchronous Repair の考え方は非常にシンプルですが、システムが提供しているメインの処理とのデータ競合に気をつける必要があります。特定のデータに対してバッチと API リクエストによる処理が同時に書き込もうとしている場合、API リクエストの処理結果をバッチが上書きしてしまうといったことが起こりえます。そのため、バッチの実装や実行タイミングを設計するときはデータ競合を十分に考えなければなりません。
また、バッチのスケジューラが exactly once *6 な実行をしているとも限りません。たとえば Cloud Scheduler のドキュメントには以下のような記述があります。
Cloud Scheduler は、「少なくとも 1 回」を基本に処理を行うよう設計されています。つまり、ジョブがスケジュールされると、Cloud Scheduler はそのジョブのリクエストを少なくとも 1 回は送信します。まれに、同じジョブの複数のインスタンスがリクエストされる可能性があります。このためリクエスト ハンドラはべき等である必要があります。またコードを記述する際は、このような状態が発生した場合に有害な副作用が発生しないようにする必要があります。
バッチが複数同時に起動したことにより新たなデータ不整合が発生したら元も子もありません。
TCC パターン
アプリケーションのユースケースの都合により、「確実に処理できる」と事前に分かった上で別のタイミングでその処理を実行したいようなケースがあります。
こういったケースを実現するためのパターンとしては TCC パターンが有名です。TCC は Try/Confirm/Cancel の略で、実行できるかのチェック (仮処理) を行う Try、本処理を行う Confirm、仮処理を取り消す Cancel の 3 つのインターフェースです。Try が成功した場合、Confirm は最終的には必ず成功しなければなりません。例えば Confirm 時にデータベースがダウンしていたとしても、データベースが復帰した際の処理の確定を保証しなければなりません。アプリケーションの都合上、長時間に渡る失敗が許容できない場合は (たとえば Asynchous Repair で) Cancel を呼び出し、処理を中止することになります。
Stripe の API では charge (請求) を作成するための API と charge を確定するための API の 2 つが用意されていますが、これは TCC パターンの Try と Confirm を満たす API です。
おわりに
この記事では一般的な Web サービスを模した図を元に、分散システムではいかに予測できないエラーが起こりうるかを見てきました。また、データ不整合が発生した場合、どのようにしてそれを修復し、結果整合性を担保するかの一例を紹介しました。
自分の働いている会社ではマイクロサービスアーキテクチャを採用しており、上記で紹介したことのほぼ全てを実際に経験してきています。
マイクロサービスアーキテクチャはまさに数多くのサービスがネットワークを通じて接続されている分散システムです。マイクロサービスアーキテクチャを採用するということはこういった大きな複雑さと常に向き合っていかないといけないことを意味しています。
とはいえ、これらの複雑さと向き合うということは難しくも非常にチャレンジングで楽しいのでぜひ体験してみてください。IT 系の会社で働いているのであれば必ず遭遇することになると思います。
参考
最後にこれらへの知識を深めるために役に立った記事や本を紹介します。
最近生えてきた "The Amazon Builders' Library" という知見の塊のようなサイトの一記事です。シングルマシンでは単純に動くアプリケーションであっても分散システム上に構築するとどれだけ複雑になるかが書いてあります。マイクロサービスを開発する前に読んでおくとコーディングの際に意識できるようになるかもしれません。
Airbnb において多重決済を防ぐためにどういった基盤を構築しているかを紹介した記事です。Idempotency Requests に関する詳細な説明もあるのでぜひ一読してみることをおすすめします。
クラウドネイティブなアプリケーションをつくる上で役に立つ設計パターン集です。それぞれのパターンの使い所、メリットとデメリットが詳しく書いてあります。たくさんあるので聞いたことがあるパターンや興味のあるパターンをかいつまんで見ていくといいかもしれません。
データ指向アプリケーションデザイン ―信頼性、拡張性、保守性の高い分散システム設計の原理
- 作者:Martin Kleppmann
- 出版社/メーカー: オライリージャパン
- 発売日: 2019/07/18
- メディア: 単行本(ソフトカバー)
データベースをはじめとするデータシステムのアーキテクチャについて非常に広い範囲にわたって書かれている本です。5 章の「レプリケーション」、7 章の「トランザクション」、8 章の「分散システムの問題」、9 章の「一貫性と合意」あたりを読むと分散システムの難しさと面白さが嫌というほどわかると思います。自分はわかりました。