Cloud Tasks向けのTransactional Outboxワーカーを実装する

背景

現代的なデータベース駆動アプリケーションでは、メインとなるデータベースに加えて、Web API等を利用して他のシステムと連携することが避けられません。 例えば、外部決済システムと接続することを考えると、まず決済をAPI経由等で実行した上で、その結果に応じてデータベースの更新を行う必要があります。

このような場合に発生する典型的な懸念として、メインデータベースと外部システムのリソース間の不整合があります。 外部システムのリソース更新とメインデータベースのトランザクションコミットのどちらか片方が失敗するケースが考えられるからです。 例えば、先程の決済システムの例では、決済が成功したがデータベースのコミットに失敗した場合が考えられます。 このケースでは、ユーザーの残高は引き落とされているがアプリケーションとしてはそれを認識していない(=未決済状態)という過払いの状態になるため、問題です。

このようなリソース不整合問題に対して、トランザクションマネージャやSaga, TCCパターンなどのさまざまな解決策が知られています。 その中の1つの手軽な解決策として、冪等キー等を用いてロジック全体をリトライ可能にした上で、無限リトライを実施するという方法が存在します。 しかし、データベース駆動アプリケーションがWeb APIの場合、無限リトライを期待することはできません。ユーザーの離脱により、リトライを実行するコンポーネントがなくなってしまうためです。

このようなケースで無限リトライをトリガーできるSaaSとして、Google Cloud Tasksがあります。 Cloud Tasksを利用することで、HTTPリクエストを無限リトライするキューを作ることができます。

課題

Cloud Tasksにエンキューをするためには、TasksリソースをAPI経由で作成する必要があります。 そのため、UX都合などで、ユーザーリクエスト契機でデータベースをすぐに更新する必要がある場合、 ユーザーリクエスト同期でデータベースを更新した上でCloud TasksのAPIをコールする必要があります。1

そのため、このようなケースにおいては、データベースとCloud Tasks間のリソース整合性の検討をする必要があります。

実装

この問題を解決するために、Transactional Outboxパターンを用いてTaskエンキューを行うワーカーを作成しました。 ざっくりいうと、タスク作成キューをデータベースに設け、そのキューを監視してCloud Tasks APIをコールする常時起動ワーカーを作成するという方法です。

大まかな仕組みを説明します。 APIハンドラはタスクエンキューのために同期的にCloud TaskのAPIを叩く代わりに、ステータス=未処理でタスク作成依頼用テーブルに1行インサートするようにします。 常時起動ワーカーは定期的(100ms等)にステータス=未処理のタスク作成依頼用レコードを全件フェッチし、1行ごとに1TasksをCloud Tasks APIをコールしてエンキューするようにした後、ステータスを処理済みに更新します。

シーケンス図

評価

このワーカーにより、データベース状態とTask作成のリソース整合性が担保されるようになりました。 Taskでは無限リトライが発生するので、Task内をリトライ可能に実装することができれば、外部API呼び出しやDB更新のリソース整合性をケアできるようになりました。

副次的な効果として、バックグラウンドタスクを気軽に実行できるようになりました。 Cloud Runを使用している場合、リクエストを処理していない時間はCPUが止まってしまいます。 そのため、リクエスト非同期のタスクを処理する仕組みが必要です。この方法はデータベースに値挿入するだけでタスクのエンキューを行うことができるため、 リクエスト非同期のタスクを簡単にエンキューすることができるようになりました。

また、トランザクション時間を短くできるというメリットもあることがわかりました。 トランザクション中に外部システムに同期的に通信をしている場合、外部システムへの通信が完了したタイミングでトランザクション完了となります。 そのため、トランザクション時間に外部システムへの通信時間が含まれることになります。 一般に、トランザクション時間は短いほうがデータベースシステムのパフォーマンスは向上します。 ここでは、具体的なパフォーマンス改善値は計測していませんが、上記の改善にも有用そうであることがわかりました。

一方で、常時起動インスタンスを利用している関係上、ランニングコストが高くなっています。現状、Cloud Runでは常時起動インスタンスを利用すると70USD/月が課金されていまいます。 リアルタイム性がさほど重要でないシステムであれば、Taskエンキューを行うワーカーはバッチとして実装することで、インフラ費用を削減することができます。



  1. リアルタイム性が強く求められない場合、APIハンドラで同期的にDBを更新せずCloud Tasksにエンキューするだけで十分です。このケースでは、Taskハンドラ側でDB更新や外部リソース作成の処理を担当させます。 ↩︎