RethinkDBとPostgreSQLの比較:私の個人的な経験より – 後編

前編はこちら:RethinkDBとPostgreSQLの比較:私の個人的な経験より – 前編

PostgreSQL

私はHacker Newsのこのディスカッションで、PostgreSQLにRethinkDBのchangefeedのようなものを実装するための基本的なビルディングブロックがあるということを知りました。オンラインでNOTIFY/LISTENの使用法を検索していると、比較的シンプルな(しかし分かりやすい)デモが見つかりましたが、これは本当にありがたかったです。多くのベンチマークを経て、これはうまくいきそうだという結論に達しました。

完成には何が必要なのか、本番環境でRethinkDBを使っていた経験から、私には正確に分かっていました。大した設計が必要になるわけではありません。問題は明確でした。全く同じことを、代わりにPostgreSQLのLISTEN/NOTIFY、そしてトリガを使ってやればいいのです。

PostgreSQLについては、私は1990年代後半から断続的に使用していました(バークレーでPostgreSQLが開発された年と、私がそこの大学院に通い始めた年は実は同じなのです!)。PostgreSQLは長年にわたって着実に改善されてきましたが、その中には非常に優れたJSONドキュメントのサポート、レプリケーションなどが含まれます。また、LISTEN/NOTIFY機能を高速に動作させるための改善もなされました。この改善をしてくれた人が誰であれ、感謝せずにはいられません。

MySQLについては、LISTEN/NOTIFYがなく、GPLのライセンスもあるため、真剣には検討しませんでした。一方、PostgreSQLは非常にリベラルなライセンスです。

テストを実行しAPIを学んだ後で私は、”1カ月集中して”作業をすれば、SMCをPostgreSQLベースに書き直せるという算段を立てました。

実装

当初、12月を丸々使ってSMCをPostgreSQLベースに書き直すという計画でしたが、蓋を開けてみれば、基本的な書き直しで実際に丸1カ月かかりました。

私が用いた設計は、少数のLISTEN/NOTIFYチャネルをセットアップし、それらがテーブル上の変更をリッスンして、主キー、およびオプションで他の小さな列を、接続されたそれぞれのWebサーバに送信するというものです。こうすると、データベースが管理するトリガとLISTEN/NOTIFYチャネルの総数は多くても100程度と、非常に少なくて済みます。Webサーバのクライアントが通知を受け取ると、その記録が必要なものかどうかを判断し、必要な場合にはSELECTをデータベースに返して残りのデータを要求し、それからクライアントに送信します(更なるSELECTを実行するかどうかの問題は、現在のところ等価性をチェックする一連の関数のO(N)呼び出しに関係しており、ブルームフィルタやハッシュテーブルを使った方が効率的です)。

私は以前、RethinkDBのCPU使用率に不満を感じた時に、上記のようなものをRethinkDB上で実装することを考えましたが、そもそもRethinkDBは必要なプロセスを文字通りこなしているだけなので、その考えは実行に移しませんでした。今回の作業に当たり、詳細を漠然と考え出すと、複雑で難しく思えてきて、ひょっとするとRethinkDBよりも効率が悪くなるかもしれないという心配も生まれました。そんな時、ちょうどあれは2016年8月にDaniel Mewesと夕食を取った時でしたが、私は彼の言葉に驚かされたのです。彼が言うには、RethinkDBのプロキシノードは、changefeedがあった全てのテーブルの全てのアップデートに関する全てのデータを、全て受信(そして何らかの処理を)するということでした。どおりで非効率的なわけです…。

とにかく、私は、LISTEN/NOTIFYを実行するトリガを自動的に生成するコードを書きました。必要なもの全てが実行できるように、テーブルを調べて、changefeedスタイルの機能が十分に実装されているかも確認しました。また、事前に何を構築しているか分かっていたので(そして本番環境でデバッグが難しい問題に遭遇するという問題も避けたかったので)、膨大の量のユニットテストも書きました。

RethinkDBでは、多くの問題を引き起こした複雑な”プロジェクトの全ての協力者”の”グラフスタイル”クエリもあり、多くのプロジェクトを持つような特定のユーザ(例えば私のように500以上のプロジェクトを持つユーザ)に対しては10秒かかるようなこともしばしばありました。これは、2つのテーブルにわたる結合に関連し、効率的に表現するのが難しいクエリです。また、RethinkDBはそのクエリでchangefeedを実行できないため、共有しているプロジェクトに変更があった場合、そのchangefeedを抹消し作り直さなければなりませんでした。全てを書き換えるに当たり、私は可能な限り正確さを維持できる構造にしたいと思い、最終的に全体のアカウントとプロジェクトテーブルを見るだけで、特定のユーザの全てのプロジェクトと協力者を適切に追跡し、一部のデータ構造を適切に更新する単一データ構造を思い付きました。コードはここのProjectAndUserTrackerにあり、実用する上で非常にうまく機能します。もちろん、これと同じコードはRethinkDBでも書くことができたはずですし、そうしていればきっと役に立っていたと思います。

いずれにしても、PostgreSQL上でアプリケーションを構築するためには、解決すべき小さな問題が山積みでしたし、そのために没頭した日々を送ることになりました。幸いゼロから書き直したおかげで、多くの問題を解決できたと思います。

なお、上記のchangefeed機能で、マルチマスタ非同期レプリケーションを構築したいと考えています。これは、SMCが地理的に分散している場合に重要になるでしょう。更に、メインのパブリックSMCとユーザがオフラインで実行する個別のdockerイメージの間で、部分的なマルチマスタ非同期を実行する計画もあります。これにより、真に完全なオフラインモードを提供できます。加えて、全ての計算は、(独自の小さなローカルPostgreSQLインスタンスを持つ)dockerコンテナ内のユーザのローカルマシン上で行われながら、(複数のカーソルなどで)ファイルの同時編集ができるようにもしたいと考えていますが、それは恐らく2018年になるでしょう。

マイグレーション

1月に入り、私は、ダウンタイムを最小限に抑えつつ(目標は大きなマイグレーションが1回で、残りはインクリメンタルなアップデート)全てのデータをRethinkDBからPostgreSQLに移行できるよう、コードを書き始めました。数時間はかかるだろうと思って始めたのですが、結果的にその予想を大幅に超え、1カ月近くもかかりました。データが大量で、1億5000万ものレコードを持つテーブルもあったのです。そして、もう1つの障害は、PostgreSQLが静的に型付けされているのに対し、RethinkDBはそうではないということです。これにより、データ内の細々とした問題が数多く露見しました。更にPostgreSQLでは、例えばアカウントテーブルの全てのEメールアドレスは一意であるというような条件をデータに課すことは自明なことなので、もちろんその制約を課しましたが、私のRethinkDBデータには同じ電子メールアドレスを持つ複数のアカウントがあったために競合状態が起こり、それに対処するために(おびただしい量の)コードを書かなければならなくなったのです。また、JSON文字列のnullバイトのようなものやネストされたJSONデータ構造内のタイムスタンプ、その他多くの問題に対処しなければなりませんでした。場合によっては、リレーショナル列とJSONBの組み合わせを使用したりもしましたが、それについては今後、触れたいと思います。

ちなみに、PostgreSQL Node.jsドライバのタイムスタンプ精度に関する、分かりにくい致命的なバグを見逃したのですが、そのおかげでデバッグに辛い数日を費やす羽目になりました。

速度比較

データをPostgreSQLから移行するに際しての私の立場は、特有だと言えるでしょう。私は何年にもわたりRethinkDBとPostgreSQLの両方で、本番環境におけるデータ作成の経験がありますし、この時点で両方のクエリ言語についても、かなり理解していました。そして両方のデータベースで、データのランダムなクエリを何度も行い、結果にかかる時間を調べました。PostgreSQLの方が速く、おおむね5倍、遅い時には2倍で速い時には10倍の違いがあります。確かに私の場合、SQLでクエリを書く方が、ReQLで書くよりも(ReQLを1年以上、真剣に使ってきたにもかかわらず)はるかに速いですが、その話を別にしても、SQLには本当に自然で強力な何かがあります。また、PostgreSQLにはたくさんの機能が組み込まれており、クエリで使用することもできますし、Pythonや他の多くの言語を使って、より多くの機能を追加することも可能です(まだ着手はしていませんが、SageをPostgreSQLにつなげてみたいと考えています)。

データやスピードに関する証明資料などはないので、あしからずご了承ください。さて、次の話に移りましょう。仕事に戻るまでのあと数時間、皆さんと経験を共有できたらと思っています。

バックアップは、データベースからテーブル全体をダンプすることに関係しますが、速度はPostgreSQLの方が1桁(10倍)高速でした。

逆にディスクスペースの合計使用量は1桁小さい(800GB対80GB)結果でした。テーブルにはTEXTフィールドが多くありましたが、PostgreSQLは自動的にそれらを圧縮することで大きな差がついたのです。また、公平を期すために言っておくと、PostgreSQLでは冗長性はありませんでしたが、RethinkDBでは3倍の冗長性がありました。GCE上のSSDディスクスペースは非常に高価なため、ディスク使用量の削減は、お金の節約につながります。

また、私(や他のSMC開発者)は、開発目的で多くのシングルユーザRethinkDBデータベースを実行しますが、PostgreSQLではおおむね、同じ作業を(少なくとも)1桁少ないRAM使用量で実行できるようです。

Sageのような数学ソフトウェアでは、長年にわたる度重なるアルゴリズムの実装で、こうした”速度の大きな違い”が生じるのを見てきました。多くの場合、Pythonの最初のアルゴリズム実装でも実例的で、動作にも問題はありませんが、それをその後、Cythonに再実装したりアルゴリズムに変更を加えたりすることで、最終的には100倍も高速な代物へと形を変えるのです。こうしたことは、数学のソフトウェアではよく見られる現象です。思うに、データベースもこれと似ているのではないでしょうか。10倍のディスク容量を使用すれば、ディスクへの読み書き量は10倍になります。ましてや、RAMよりも(はるかに)10倍以上遅いディスクであれば大きな差が付くのは仕方ありません。

コネクションプーリング

私は、RethinkDBでのコネクションプーリングについて、より良好な並列性を得るために多くの時間をかけて頭を悩ませましたが、それについて書きます。PostgreSQLでは、私はそうしたことを気にすることはありません。各WebサーバはPostgreSQLに対して1つの接続を持っており、その接続はPostgreSQLサーバ内のシングルスレッドプロセスによって提供されます。問題の本質は、”ユーザにとって結果を速くする”ことであり、”同時接続がたくさんある”ことではありません。全てを最適化することで、データベースとWebサーバの負荷は全体的に非常に軽くなり、単一の接続でも簡単に処理できるようになるのです。PostgreSQLが非常に高速なため、もはやアプリケーション用のコネクションプールは必要ありません。また、1つのクライアントWebサーバが、データベース全体の速度を低下させることができないというのは、実際のところ、本当にいいものです。

本稼働:目を覆うほどの失敗が始まった

いろいろありましたが、上述の素晴らしいマイクロベンチマーキングなので、本稼働時には、RethinkDBよりもはるかに効率が良くなると思っていました。そわそわした静かな日曜日の午前、稼働中の本番環境サイトを切り替えます。すると、しばらくの間は、全てがなかなか順調なように見えました。

その後、目を覆うほどの失敗が始まった

データベースへの全ての接続で、SELECTクエリ実行時にCPUの使用率が100パーセントになってしまいました。どうすればよいか分かりません。めちゃくちゃでした。私は、データベースサーバの速度を上げ、はるかに多くのWebサーバを起動しました。すると、基本的には動いているのですが、変な感じがしました。私はしばらく慌ててしまい、その問題に思案を巡らせ、Webサーバなどの数を増やし続けました。これが最悪でした。少しの間、changefeedの中のSELECTの数を大幅に減らすことだけが、解決策になるだろうと思っていました。changefeedは、必要な場合にのみより多くのデータを入手するためにSELECTを実行することにより、動作することを思い出したからです。

諦めないようにそしてSMCを永久にシャットダウンしないように自己暗示をかけた後、気持ちを静めて、大量のログを調べ、あるPostgreSQLのクエリを見つけました。それは時折15秒かかり、他のクエリをロックしていました。それは、サブクエリを含むクエリで、ユーザの全ての協力者を見つけます。これは、RethinkDBで、上で述べたchangefeedを作成することが出来なかったクエリと全く同じものでした。そこで、直接PSQLでクエリのインスタンスを試してみたところ、ほんの数ミリ秒しか、かかりませんでした。理解に苦しみます。次に、幾つか別のパラメータを使って試してみました。すると、突然CPUの使用率が100パーセントになり、実行に15秒かかりました。PostgreSQLで幾つかの線形走査を実行している時のことです。完全な本番環境データでEXPLAINを使うと、場合によっては、クエリプランナは何かばかげたことを実行しているようでした。このクエリプランナを制御する方法が分かったので、何を入力しても、このクエリはほんの数ミリ秒で戻ってくるようになりました。クエリプランナを制御する(正しく作成したインデックスを実際にいつも使うための)この1つの変更で、物事が劇的に速く進むようになりました。基本的に、データサーバ上の負荷は、100パーセントから5パーセント以下にまで改善しました。

Node.js PostgreSQL ドライバ

Node.js PostgreSQLドライバは、ネイティブのバインディングはパースの速度は20~30パーセント増加させるとうたっています。私の作業負荷の場合、特にBYTEA型のデータ(BLOB)の読み込みで、速度は600パーセント向上しますです。これは、ログファイルを見ていて分かったもう1つの観測結果です。

これら全ての最適化で、Webサーバとデータベースの負荷は、同時に600を超えるユーザがいる時でさえも、ほとんど気にならなくなりました。

オープンソース

この投稿に関連した、私が書いた全てのコードは、皮肉にもAGPLです。基本的に、こちらpostgres-で始まるものが全てです。

SageMath社が全ての著作権を有しているので、誰かが、changefeedを提供するためにPostgreSQLベースのnodejsプロジェクトを作りたいと真面目に考えているなら、何か別の形で認可を受けることができるかもしれません。私自身は仕事があるので、それをすることはできませんが、応援はしようと思います。

まとめ

私はよく、”最初に気に入ったデータベースはRethinkDBだ“と言っています。実際、私好みのchangefeedに基づいたデータベースへのリアクティブアプローチです(React.jsを使うのが好きなのとそっくりです)。今でも、この問題を解くのは大好きです。RethinkDBのプロジェクトに参加していれば、多くのコードを削除する代わりに、この問題、changefeedに注力して、PostgreSQL(あるいは他のデータベース)の最上位に解決策をビルドしていたでしょう。DBに対してこのようなアプローチを用いる時間を費やす機会を与えてくれた、RethinkDBプロジェクトに感謝しています。だから気持ちが分かります。

自動的なフェイルオーバと多重ノードに関して言えば、本当に大切なことは、サイトがユーザのために動作することなのです。Google Compute Engineはとても信頼性が高いので、単一のVMが何百日(!)も動いたままになる傾向になります。また万が一停止しても、あっという間に復帰します。PostgreSQLにも今では、とても優れたマスタ/スレーブの話があります。それは6重ノードである可能性が高く、その中の1つで何かがおかしくなると、RethinkDBは自動的にフェイルオーバを起こしますが、それには少し時間がかかり、クライアントを悪い状態のまま放っておきます。また、現在の成長率と負荷では、1つのVMが全てを完了させるまでに長い時間がかかるでしょう。今回の作業負荷は90パーセントが読み込みで、10パーセントが書き込みなので、PostgreSQLのマスタ/スレーブはスケールアウトにとって非常に効果的です。

最後に、この投稿で、データベースについて伝えるのと同じように、SMCについて伝えられればと思っています。その他の留意点は以下の通りです。問題の本質にもっと集中する。大量のコードを捨て去る準備をする。最初のバージョンを書くことは、無駄な努力ではなく、本質的な洞察力を与えてくれる。いったんコードが何をするのかが分かれば、テストやリファクタリングをサポートするようなコードを書くことがはるかに容易になる。オープンソースは切実な問題を解くのに重要である。代替アーキテクチャへの挑戦を恐れてはいけない。