🔥

速度と整合性を両立するアーキテクチャ。Pub/SubとSpannerの組み合わせの検証

に公開

はじめに

こんにちは。クラウドエース株式会社 第一開発部のダッフィです。
アプリケーション開発を中心に活動しています。

今回は、アプリケーションを設計する上で多くの開発者が直面する 「処理のスピード」「データの正確さ」ジレンマについて、僕が注目した一つの解決策を紹介したいと思います。

その鍵となるのが 「イベント駆動型アーキテクチャ(EDA)」 です。EDAは、システムを小さな部品に分け、それらが「イベント」という合図をきっかけに連携する仕組みです。このおかげで、システムは変化に強く、たくさんの仕事を効率よくさばけるようになります。

しかし、この便利な仕組みには大きなジレンマが潜んでいます。それは、「処理のスピード」と「データの正確さ」をどう両立させるか、という問題です。

各部品がバラバラに動くことで処理はとても速くなります。でも、例えば「注文を受けて在庫を減らし、決済する」という一連の仕事で、決済だけが失敗すると、在庫だけが減った中途半端な状態になり、データのつじつまが合わなくなります。

この「データの正確さ」を守ろうとすると、大きな一つのデータベースで全部を管理する方法になりがちで、これだとスピードが出せません。そのため、これまでのシステム設計では、「スピードを優先して、データのズレは後で修正する」か、「データの正確さを優先して、スピードは我慢する」という、どちらかを諦めるざるを得ない状況でした。

この記事では、Google Cloudの Pub/SubSpannerという2つのサービスを組み合わせることで、このジレンマをいかにして解決するのか、具体的な実験を通じて確認したいと思います。

なぜこの組み合わせなのか?

では、どうしてこのジレンマを解決するために、たくさんの選択肢の中からPub/SubSpannerを選ぶのでしょうか?それぞれに、他のサービスでは真似できない特別な理由があります。

Spannerを選ぶ理由:「正確さ」を世界規模で手に入れる

「データの正確さを守るだけなら、もっと身近なデータベース、例えば Cloud SQL でも十分じゃないか?
そう思った方もいるかもしれません。その通りです。もしあなたのビジネスが日本国内の1店舗だけなら、それで全く問題ありません。

では、もしビジネスが急成長し、日本全国に数十、数百の店舗を展開することになったらどうでしょう?
例えば、全店舗で一斉に 「1日限定のタイムセール」を実施したとします。開店と同時に、数百の店舗から、数万、数十万のお客様が一斉に在庫の確認や商品の購入を行うかもしれません。

こうなると、たった一つのデータベース(Cloud SQL)では、すべての処理をさばききれなくなる 「性能の壁」 にぶつかる可能性があります。データベースの書き込み処理がボトルネックとなり、お客様の画面が固まってしまったり、決済がタイムアウトしてしまったりします。

これを従来のデータベースで解決しようとすると、データベースを複数に分割して負荷を分散させる「手作業でのシャーディング」という非常に高度で複雑な対応が必要になり、「運用地獄」 の始まりとなります。

Spanner は、このような爆発的なアクセス増にも対応できます。アクセスが増えれば、裏側で自動的に処理能力をスケールさせてくれるため、開発者は「性能の壁」を心配する必要がありません。数百店舗からの同時アクセスであっても、あたかも一つの巨大なデータベースが余裕で処理しているかのように振る舞います。

もちろん、その際もデータの整合性は保たれます。Spannerが持つ 「外部整合性」 という高性能な保証が、「A店とB店、どちらの決済が1000分の1秒でも先に完了したか」を、世界共通の正確な時間で判定してくれるからです。そのため、「A店で最後の在庫が売れたのに、B店でも同じ商品が売れてしまった」といった在庫の矛盾は起こりません。

つまりSpannerは、「データの正確さが保てる」というだけではなく、「ビジネスが大規模化しても、グローバルな展開においても、運用の複雑性を大幅に低減しつつ、高いレベルの正確性を保証します」 からこそ、お金を払ってでも選ぶ価値があります。

Pub/Subを選ぶ理由:管理の手間を大幅に削減できる使いやすさ

次に、システム間の「イベント」のやり取りを担うPub/Subです。
この役割にはKafkaという有名なサービスもあります。Kafkaは非常に高性能ですが、例えるなら 「たくさんの車線がある高速道路」 のようなものです。どの車線を使うか、交通量に応じて車線数をどう調整するか、すべて自分で管理しなくてはならず、専門的な知識と手間がかかります。

一方、Pub/Subは 「自動調整される道路」 のように振る舞います。車(イベント)が増えれば道が自動的に広がり、減れば元に戻る。交通渋滞が起きないように、常に自動で調整してくれます。開発者は、道路の管理をあまり気にすることなく、車を走らせることだけに集中できます。

この「管理の手間があまりかからない」というシンプルさが、Spannerと組み合わせたときに効果を発揮します。

「相性の良いコンビ」:失敗が起こりづらい連携プレイ

この2つを組み合わせることで、システム開発で起こりがちな、データのつじつまが合わなくなる致命的な問題を防ぐことができます。

それは、「①データベースに『注文を記録』して、②その後に『発送部門へ通知』を送る」 という2つの仕事を別々に行う際に起こる事故です。もし①の記録は成功したのに、②の通知で失敗してしまうと、データベースにだけ注文が記録され、商品は発送されない…という最悪の事態が発生してしまいます。

しかし、Spannerの「変更ストリーム」という機能を使えば、この問題は起こり得ません。これは、「データベースへの記録が100%成功したら、それを合図に、自動的に通知が送り出される」 という仕組みだからです。記録と通知が一体化しているため、「途中で失敗する」という問題を防げます。

Spannerの正確さと、Pub/Subのシンプルで確実な通知システム。この2つが組み合わさることで、システム全体で「矛盾が起きない」という連携プレイが生まれます。

検証:その「理論」、本当に信じてもいいですか?

理論上は理想的に見えますが、実際のところはどうなのでしょうか?ここからは、本当にこの組み合わせがうまく機能するのかを、様々な実験で確かめていきましょう。

検証環境の概要

今回の検証環境は、以下の図に示すような構成です。Producerが発行したイベントをPub/Subが受け取り、ConsumerがSpannerに書き込む、というシンプルな流れになっています。

  • Producer: テスト用の注文イベントを生成し、Pub/Subトピックに発行するシンプルなSpring Bootアプリケーション。ローカル環境で実行。
  • Pub/Sub: Producerからのイベントを受け取り、Consumerへの配信を行うメッセージングサービス。
  • 1つのトピック (order-events)
  • 1つのサブスクリプション (order-events-sub)
  • Consumer: Pub/Subからイベントを受信し、Spannerデータベースへの書き込み処理を行うSpring Bootアプリケーション。ローカル環境で実行。
  • Spanner: 書き込まれた注文データを永続化する、グローバル分散型のリレーショナルデータベース。
  • 1つのインスタンスで1つのデータベースに
    order-events (注文イベント) および counters (トランザクション競合テスト用のイベントカウンター) テーブル

検証環境のアーキテクチャ
図:検証環境のアーキテクチャ図

パフォーマンス検証:スピードは本当に速いのか?

基本テスト:まず、注文1件を処理してみる

目的:

まず、お客様からの注文が1件、システムをスムーズに通り抜けられるか、基本的な処理速度を見てみましょう。

結果と考察:

以下のログを見ると、メッセージの受信から始まり、Spannerへの書き込み(message.processing.transactional)、そしてPub/Subへの応答(Message acknowledged successfully)までの一連の流れが確認できます。
最終的な合計処理時間(Total message processing execution time)は、わずか 132ミリ秒(0.132秒) でした。お客様が注文ボタンを押してから、システム内部で「処理完了!」と確認が取れるまでの時間です。Spannerがデータの正確さをしっかりチェックしながらも、このスピードを実現できることが分かりました。これならお客様を待たせることはありませんね。

以下は、このトランザクション処理が行われた際のログです

INFO  [pubsub-subscriber] c.e.c.s.MessageProcessingServiceWithFaultSimulation - トランザクション処理開始: {"orderId":99999,...,"eventId":"EVENT_01_TRANSACTIONAL",...}
INFO  [pubsub-subscriber] c.e.consumer.service.MetricsService - spanner.read.idempotency_check execution time: 39 ms
INFO  [pubsub-subscriber] c.e.consumer.service.MetricsService - spanner.write.save_order execution time: 0 ms --Spannerへの個別の書き込み操作にかかる時間 
INFO  [pubsub-subscriber] c.e.c.s.MessageProcessingServiceWithFaultSimulation - 注文を保存しました: 99999   --注文ID
INFO  [pubsub-subscriber] c.e.consumer.service.MetricsService - message.processing.transactional execution time: 131 ms --トランザクション全体の処理時間
INFO  [pubsub-subscriber] c.e.consumer.config.PubSubConfig - Message acknowledged successfully --メッセージACK成功
INFO  [pubsub-subscriber] c.e.consumer.service.MetricsService - Total message processing execution time: 132 ms --総処理時間

高負荷処理とスループット

目的:

では、もしお店がテレビで紹介されて、注文が殺到したらどうなるでしょう?毎秒300件、合計5000件の注文をシステムに送り込んでみます。

結果と考察:

結果は期待通りのものでした。下のグラフが示す通り、秒間300件もの注文が殺到しているにもかかわらず、Pub/Subの処理待ち行列(Unacked messages)は全く増加せず、「0」のままです。そして、Spannerは5000件すべての注文を、ミスをすることなく、正確にデータベースに記録してくれました。

これは、Pub/Subが自動でスケールし、Spannerが高い負荷に柔軟に対応できる、という理論が正しかったことの証明です。

Cloud Monitoringでサブスクリプションのunacked_messages数がほぼ0のまま推移した様子
Cloud Monitoringでサブスクリプションのunacked_messages数がほぼ0のまま推移した様子

競合テスト:数店舗から最後の1個を同時に注文!

目的:

これが一番大事なテストです。もし、複数のお店で、たった一つの限定商品を同時に注文しようとしたら どうなるか?データの正確さが本当に守られるのかを見てみましょう。

結果と考察:

Spannerは、この同時注文を適切にさばきました。 まず、どちらかの注文を瞬時に確定させ、以下のログには、まさにその瞬間のやり取りが記録されています。Transaction conflict detected (AbortedException)という警告が出力され、Spannerが競合を検知し、処理を安全にやり直すよう指示している様子が分かります。やり直しになった注文は、Pub/Subが「かしこまりました」ともう一度トライしてくれます。
ConsumerログでSpannerException: ABORTED が出力された様子
ConsumerログでSpannerException: ABORTED が出力された様子

この連携により、最終的にデータに矛盾が起きることはないでしょう。 限定商品はきちんと1人にだけ販売され、「在庫がないのに売ってしまった」という事故は防がれました。
もしSpannerのこの仕組みがなければ、「ロストアップデート」 データの不整合を引き起こす深刻な現象が簡単に起きてしまいます。このテストは、Spannerの「正確さ」が、ビジネスの信頼を守る上でいかに重要かを物語っています。

ロストアップデート

在庫があるように見えたのに、実は売れていた、というデータの食い違い

耐久性テスト:もし、システムの一部が壊れたら?

処理担当者(Consumer)がダウンしたら?

目的:

注文をさばく処理担当のコンピュータ(Consumer)が、突然故障したらどうなるでしょう?お客様からの注文は消えてしまうのでしょうか?

結果と考察:

心配は無用でした。処理担当がダウンしても、Pub/Subが「頼れる郵便屋さん」のように、届いた手紙(イベント)を失くさないよう大事に預かってくれます。
その証拠に、下のグラフを見てください。左のグラフでは、処理されずに残っているメッセージの中で最も古いものの経過時間(Oldest unacked message age)がどんどん伸びていき、右のグラフでは未配信のメッセージ数(Unacked messages)が積み上がっていくのがわかります。
Pub/Subが私たちの代わりに、大切な注文をしっかりと保管してくれています。担当者が復旧したら、預かっていた手紙をすぐに配達し直してくれるので、仕事がなくなることはありません。

Pub/Subで、Consumer停止中に「最も古い未確認メッセージの経過時間」と「未配信メッセージ数」が増加する様子
Pub/Subで、Consumer停止中に「最も古い未確認メッセージの経過時間」と「未配信メッセージ数」が増加する様子

データベース(Spanner)が応答しなくなったら?

目的:

今度は、データの保管庫であるSpannerが、一時的に応答しなくなったらどうなるかを見てみます。

結果と考察:

これも問題ありません。処理担当は「保管庫に異常あり!」と検知すると、処理を中断し、Pub/Subに「この手紙、今は届けられないから一旦持ち帰って!」と伝えます。Pub/Subはそれを預かり、Spannerが復旧したら、また配達を試みてくれます。
以下のログは、まさにその状況を捉えたものです。意図的にSpannerへの接続障害を発生させると(障害シミュレーション)、Consumerは即座にエラーを検知し、メッセージを安易に処理済みとせず、Pub/Subへ差し戻している(Message nacked due to error)ことが分かります。Pub/Subはそれを預かり、Spannerが復旧したら、また配達を試みてくれます。

この実験で、データが中途半端な状態で記録されたり、処理中の注文が消えてしまったりすることはないので安全性が証明されました。

Consumer ログで SpannerException やトランザクションロールバック、NACK が出力された例
Consumer ログで SpannerException やトランザクションロールバック、NACK が出力された例

間違えて同じ注文が2回来たら?

目的:

Pub/Subは非常に信頼性が高いですが、「念のため」同じ手紙を2回届けてしまうことがあります。その場合、同じ注文が2回登録されてしまわないか確認します。

結果と考察:

これも大丈夫です。システムには 「この注文番号、前に一度受け取ったな」とチェックする仕組み(冪等性) がきちんと組み込まれています。以下のログがその動きをはっきりと示しています。最初のメッセージは成功していますが(1回目で成功)、全く同じeventIdを持つ2回目以降のメッセージは、冪等性チェックによって重複と判断され、処理がスキップされているのが分かります。なので、お客様に商品を2重に発送してしまうようなミスは起こりません。

Consumerログで重複イベントを検知しスキップしたログの例
Consumerログで重複イベントを検知しスキップしたログの例

おわりに

今回の実験を通じて、Pub/SubとSpannerの組み合わせが、ただの理論ではなく、現実の厳しい要求に応えられるソリューションであることがお分かりいただけたと思います。

  • スピード:たくさんの注文が殺到しても、お客様を待たせることなく処理できる。
  • 正確さ:世界中どこからアクセスしても、データのつじつまが合う。
  • 耐久性:システムの一部が故障しても、データが失われることなく、自動で復旧する。

では、この強力な組み合わせは、どんな時に選ぶべき なのでしょうか?
答えはシンプルです。 あなたのビジネスが急成長して、日本全国のどこからでも、どんなに多くのアクセスが集中しても、お客様を待たせることなく、いつでも正確なサービスを届けたい、と本気で思うなら。そして、開発チームには面倒な裏方の管理作業ではなく、お客様を笑顔にする新しいサービスの開発に集中してほしい、と思うならです。
この組み合わせは、単なる技術の選択肢ではありません。あなたのビジネスを未来へ加速させる、信頼できる「戦略的パートナー」 になるはずです。

Discussion