事例:泥沼にはまるウェブアプリ開発者たち

要約

ウェブアプリ開発でありがちなあれこれ。

詳細

あなたは今、ウェブアプリを開発している。ありがちな三階層アーキテクチャのやつだ(図2-1)。 ウェブブラウザやモバイルアプリなどのさまざまなクライアントからのリクエストを処理する。 アプリケーションのコード(ビジネスロジック)をこのウェブアプリで実装することになる。

msos_0201.png

図2-1:ひとつのウェブアプリとひとつのデータベースのシンプルな構成

アプリケーション側で何かを記憶しておきたいときはいつも、それをデータベースに格納することになる。 またそれにあわせて、以前に格納したデータをデータベースに問い合わせる処理も必要になる。 ごくごく単純な話だし、問題なく動くだろう。

msos_0202.png

図2-2:シンプルな状態はそう長続きしない

しかし、シンプルな状態はそうそう長続きするものではない(図2-2)。 ユーザー数が増えるとリクエストも増え、データベースが遅くなる。memcachedやRedisなどのキャッシュを投入して対応することになるだろう。 全文検索を導入する必要に迫られるかもしれない。 データベースの標準の検索機能では不十分なので、ElasticsearchやSolrといったインデキシングサービスを導入することになるだろう。

いわゆるソーシャル機能やリコメンド機能のために、グラフ操作が必要になるかもしれない。 リレーショナルデータベースやドキュメントデータベースはこの手の操作が得意ではないので、 さらにグラフインデックスを追加することになるだろう。 また、重い処理をウェブリクエストのフローから追い出して、バックグラウンドで非同期処理させたくなるかもしれない。 メッセージキューを導入して、バックグラウンドのワーカーにジョブを送ることになるだろう。

そして状況は悪化する……(図2-3)。

msos_0203.png

図2-3:機能追加や新たな要件の発生などでアプリが育つにつれて、各種ツールが増殖する

こんどは別のところが遅くなりはじめたので、さらにキャッシュを追加した。 キャッシュを足せば足すほど速くなる……ほんとに? 今やこのアプリにはさまざまなシステムやサービスが追加されているので、メトリクスの取得や監視を行ってキャッシュが機能しているかどうかを確認しなければいけない。 もちろん、メトリクスを取るためのシステムそれ自身も調査の対象になる。

さて。メールやプッシュ通知などのような通知機能が欲しくなってきた。 バックグラウンドのワーカー用のジョブキューに通知システムをつないで、さらにその通知システム用に何らかのデータベースが必要になってくるだろう。 このアプリが生成するデータはどんどん増えてきた。分析の必要があるだろう。でも、メインデータベース上でビジネスアナリストに重たいクエリを実行されたりしたらたまったものじゃない。そこでHadoopや何らかのデータウェアハウスを導入して、メインデータベースからそこにデータを流し込むようにする。

業務分析は動くようになった。でもこんどは検索システムの処理が追いついていないっぽい。 原因はわかっている。いずれにせよすべてのデータはHDFS上にあるのだから、 Hadoopの検索インデックスをビルドして、それを検索サーバーに送り出せばいい。 そしてさらに、システムは込み入ったものになっていく。

その結果がこれ(図2-4)。狂気の沙汰としかいいようがない。

msos_0204.png

図2-4:さまざまな依存関係で結ばれた大量のコンポーネント。システムは複雑化して手に負えなくなる

どうしてこうなった? 誰もが誰かを呼び出していて、誰も全体像を把握していない。いったいなぜ?

「あのときの選択が間違いだった」などと特定できるものでもない。 アプリケーションの要件をすべて満たしてくれる万能のデータベースやツールなど存在しない*1。 そんな中で最善のツールを使うわけで、さまざまな機能を必要とするアプリケーションならさまざまなツールを使うことになるのは仕方がない。

また、システムが成長するにつれて、より小さなコンポーネントに分割する手段が必要になってくる。管理しやすくするためだ。 そこで登場するのがmicroservicesだ(第4章を参照)。 でも、そのせいでシステムが相互依存だらけのごちゃごちゃしたものになってしまうのなら、管理しやすくなんかならない。

いろいろなストレージシステムを併用すること自体が問題だというわけではない。 それらがお互いに独立しているのであればそんなに問題にはならないだろう。 問題になるのは、それぞれが同じようなデータ(関連するデータ)を別々の形式で保持しているということ(図2-5)。

msos_0205.png

図2-5:非正規化、キャッシュ、インデックス、集約などでデータが冗長化する。読み込み速度を上げるために、同じデータをさまざまな形式で保持することになる。

たとえば、全文検索インデックスにはいっているドキュメントはデータベースにも格納されちえるだろう。 検索インデックスは、そもそも何かを記録するために使うものではないのだから。 キャッシュにはいっているデータだって、どこかのデータベースに入っている内容のレプリカだ (まあ他のデータと連結したりHTML形式になっていたりするかもしれないけど)。 キャッシュってそもそもそういうものでしょ?

非正規化もまた、キャッシュと同じくデータの重複の一因になる。 読み込み時に毎回算出するのはコストがかかるので計算結果をどこかに格納しておくなどとすると、 元データが変わったときに計算結果も更新しなければいけなくなる。 第1章で紹介したマテリアライズドビューも同じ。

そういった重複がだめだと言うわけじゃない。むしろその反対。 キャッシュやインデックスなどの冗長データは、読み込みのパフォーマンスを上げるためには欠かせない。 でも、そういったデータたちをきちんと同期させつづけるのはとてもとても大変(図2-6)。

msos_0206.png

図2-6:データインテグレーション問題 - データの同期を保つのは大変

うまい言葉が思い浮かばないけど、この問題のことを「データインテグレーション」と呼ぶことにする。 すべてのデータはあるべきところに収まるものだということ。 どこかのデータが変更されたら、そこから派生するさまざまなデータもすべて変更する必要がある。

さて、さまざまなデータシステムの同期を保つにはどうすればいいのだろう? そのためのテクニックをいくつか紹介する。

デュアル書き込み

よく知られているのがデュアル書き込み方式(図2-7)。 仕組みは単純で、アプリケーションのコードが、必要なデータをすべて更新する責務を負うようにするものだ。 ウェブアプリのユーザーが何かのデータを投稿したとすると、ウェブアプリのコードはまずデータベースに書き込んで、 キャッシュの無効化やリフレッシュをして、それから全文検索エンジンのインデックスを再作成するなとどいう動きになる。

msos_0207.png

図2-7:デュアル書き込みは、適切なデータをすべて更新する責務をアプリケーションが負う

この方法がよく使われているのは、簡単に構築できて、とりあえずなんとなくは動くからだ。 しかし、ここで敢えて言っておきたい。このやりかたはまずい。根本的な問題があるからだ。 たとえばレースコンディション。

図2-8は、ふたつのクライアントがふたつのデータベースにデュアル書き込みを行う例。左から右へと矢印に沿って時間が進む。

msos_0208.png

図2-8:ふたつのクライアントが同時に同じキーによるデュアル書き込みをしようとしたときの流れ

あるクライアント(青)がまず、Xに値Aをセットする。 まずは最初のデータストア(おそらくデータベースだろう)に、X=Aを設定するようリクエストする。 データストアからは、書き込みに成功したという応答が戻ってくる。 それを受けて、もうひとつのデータストア(検索インデックスなど)に同じリクエストを送る。

いっぽうそのころ、別のクライアント(赤)も動いていた。 同じキーXを操作しようとしていたけれど、設定したい値はBだった。 こちらも同様に処理を進める。まず最初のデータストアにX=Bを設定するようリクエストして、 その結果を受けてもうひとつのデータストアに同じリクエストを送る。

これらの書き込みはすべて成功した。さて、それぞれのデータストアに書き込まれた値はどうなっただろうか(図2-9)。

msos_0209.png

図2-9:デュアル書き込みのレースコンディションが、二つのデータストアの不整合を引き起こす

最初のデータストアは、まず青がAを設定した後で赤がBを設定した。最終的な値はBだ。

もう一方のデータストアは、リクエストを受け取った順番が違う。まずBが設定されて、その後でAが設定された。最終的な値はAだ。 ふたつのデータストアの値が一致しなくなってしまった。誰かがもう一度Xを変更しようとしない限り、永遠にそのままだ。

最悪なのは、この不整合が発生したときに何もエラーが発生しないこと。データベースと検索インデックスの値が食い違っているという通知さえこない。 半年後にまったく別の作業をしているときにたまたま不整合に気づいたとしても、もはや何が悪かったのかわかるわけがない。 結果整合性などといって片付けられる問題じゃない。永遠の不整合だ。

これだけでも、デュアル書き込みを避ける理由としては十分だと思う。

でもね。他にもあるのですよ……。

非正規化データ

非正規化データを考える。 たとえば、ユーザーどうしでメールやメッセージをやりとりできるアプリで、各自が受信箱を持っているものとする。 新しいメッセージを送ったときにやりたいことはふたつ。 そのメッセージをユーザーの受信箱のメッセージリストに追加することと、未読件数をひとつふやすこと(図2-10)。

msos_0210.png

図2-10:未読件数は新しいメッセージが届くたびに更新しなければいけない

ユーザーインターフェイス上に常に未読件数を表示させたいし、表示を更新するたびに計算しなおしていたら遅すぎるので、個別のカウンタが必要になる。 でも、このカウンタは非正規化情報になる。受信箱の実際の件数から導出される値であり、メッセージの数が変わるたびにカウンタも更新しなければいけない。

話を単純にしたいので、とりあえずクライアントもデータベースもひとつだけだとしよう。 新しいメッセージが届いたら、クライアントはまず受信者の受信箱にメッセージを追加する。 そして、未読カウンタの件数をひとつ増やすようリクエストする。

まさにその瞬間に、何らかの問題(データベースが落ちた・プロセスがクラッシュした・間違えてLANケーブルを抜いてしまったなど)が発生したとしよう(図2-11)。 原因が何であれ、カウンタの更新は失敗する。

msos_0211.png

図2-11:片方の書き込みは成功したけどもう片方は失敗した。さあどうなる?

はい。またもデータベースに不整合が発生。メッセージが届いたのに、カウンタは変わらないまま。 カウンタの値を計算しなおしたりメッセージの追加を取り消したりしない限り、この不整合が永遠に続く。 「こういうこともありえるよね」という仮説なんかじゃない。実際に発生するのだ*2

「そんな問題、とっくの昔に解決済みでしょう。トランザクションを使うだけのことでは?」 確かにそう。ACIDの「A」、すなわち原子性とはまさにそういうことだった。 ひとつのトランザクションの中に複数の変更がある場合、全部成功するか何も起こらないかの二択になる(図2-12)。

msos_0212.png

図2-12:トランザクションの原子性とは、複数の変更がすべて成功するか何も起こらないかのどちらかになるということ。

まさに今回の問題を解決するためにあるかのような特性だ。書き込みが途中で失敗したとしても、中途半端な状態になって不整合を起こす心配はなくなる。

トランザクションに対応したデータベースなら、ふたつの書き込みをトランザクションにまとめてしまうのが王道だ。 でも、今どきのデータベース("NoSQL")はトランザクションに対応していないものが多い。 そんな場合は自前でトランザクションを作りこむ必要がある。

それに、非正規化情報が別のデータベースに格納されていることだってある。 たとえばメールそのものはデータベースに入れるかれども未読カウンタはRedisで管理するなど。 こんな場合は、ふたつの書き込みをトランザクションにまとめることができない。 一方の書き込みが成功したのにもう一方の書き込みが失敗したりしたら、不整合を解消するために苦労することになるだろう。

2相コミットを用いた分散トランザクションに対応したシステムもある*3。 でも、サポートしていないデータストアも多いし、仮にサポートしていたとしても、そもそも分散トランザクションが適しているのかどうかもわからない*4。 なので、デュアル書き込みを行う場合は部分的な失敗があり得ることを想定すべきで、その対応は難しい。


担当者のつぶやき

みんなの突っ込み



*1 Michael Stonebraker and Uğur Çetintemel: "'One Size Fits All': An Idea Whose Time Has Come and Gone," at 21st International Conference on Data Engineering (ICDE), April 2005.
*2 Martin Kleppmann: "Eventual consistency? More like perpetual inconsistency," twitter.com, 17 November 2014.
*3 Henry Robinson: "Consensus Protocols: Two-Phase Commit," the-paper-trail.org, 27 November 2008.
*4 Pat Helland: "Life beyond Distributed Transactions: an Apostate's Opinion," at 3rd Biennial Conference on Innovative Data Systems Research (CIDR), pages 132-141, January 2007.