Webシステムにおけるデータベース接続アーキテクチャ概論

先月投稿した2015年Webサーバアーキテクチャ序論では、Webサーバアーキテクチャを学ぶ道のりと代表的な実装モデルの概要を紹介しました。

今回は、前回同様、主に新卒Webエンジニア向けに、Webアプリケーションサーバとデータベースサーバ間の接続管理モデルと運用事情について紹介します。 データベース接続の永続化やコネクションプーリングとは何なのか、なぜ必要なのかといったことが主な話題です。

背景

2015年Webサーバアーキテクチャ序論では、Webサーバアーキテクチャの話とWebアプリケーションサーバ・Redis間の接続管理の話が冒頭にあった。該当部分を下記に引用してみる。

この春に入社した新卒のWebアプリケーションエンジニアと話をしていて、「preforkってなんですか」という話になった。 アプリケーションサーバからRedisへ接続しているコネクション数のグラフを眺めていて、だいたいアプリケーションサーバのワーカープロセス数に等しいから妥当な数値だよね、とかそういう話をしていたときだったと思う。ちょっと前までAnyEvent::Redisを使っていたときに、アプリケーションサーバ・Redis間のコネクション数が異常な数値になっていて、AnyEvent::Redisをやめたら、通常値に戻ったという背景がある。

この話には2つの暗黙的な知識が前提にある。1つは対象のアプリケーションサーバがprefork型のアーキテクチャで動作しているということ、もう1つはワーカープロセス単位でRedisへのコネクションをキャッシュしているということだ。

前者のWebサーバアーキテクチャについては、UNIXのプロセスとネットワークAPIの話から、Webサーバの代表的なネットワークI/Oモデルまで議論した。 このとき、サーバサイドのリクエスト処理の内容については特に言及しなかった。 一方、後者のWebアプリケーションサーバ・Redis間の接続管理は、リクエスト処理中にRedisとのやりとりを含む話になる。

Redisに限らず、MySQL、Memcachedなど、他のサーバ上で動作するデータベースプロセスとWebアプリケーションの接続について考えてみよう。 非常に地味なテーマだ。しかし、システムを運用している立場からすると、システムの安定運用のためには意外と重要な技術ではある。 Webアプリケーションサーバだけでなく、データベースサーバのアーキテクチャも意識することになるため、奥が深い。 WebアプリケーションフレームワークやORMの選定にも影響するため、オペレーションエンジニアだけでなく、アプリケーションエンジニアも意識する必要がある。

ところが、データベースの接続モデルを体系的に勉強しようと試みてもなかなかうまくいかない。 Webサーバアーキテクチャ以上に体系化されたドキュメントが見つからず、特定のデータベースや、特定のクライアント実装、特定のワークロードに限定した話に振り回されがちだった。

特に、コネクションプーリングが難しい。過去には、コネクションプーリング都市伝説というものがあったようだ。 「コネクションプーリング都市伝説」はほんとに都市伝説?(その1) - 最速配信研究会(@yamaz) ここ数年だと、RDBMSでコネクションプールが必要な理由、わからない。 - Togetter という議論があった。 正直に言うと、これらの資料、特に後者を読んでも初見ではわけがわからないと思う。Twitterまとめなのでまとまっていないのは当たり前だが。

naoyaさんの記事 (コネクションプーリングの話 - naoyaのはてなダイアリー) がLL言語出身からすると最もわかりやすいと思う。ただし、ある程度、事情がわかってきた人向けではある気がする。

自分の中で腑に落ちない状態が続いていた。しかし、PerlとMySQL、Scala(JVM)とPostgreSQLの組み合わせをそれぞれ運用をしていく中で、データベース接続モデルについて、多少なりとも把握できてきたように思う。

永続化やコネクションプーリングが必要かどうかは、クライアントとデータベースサーバの実装とデータベースに対するワークロード次第だと考えている。頭に思い描くシステムのワークロードやデータベース製品が異なる人たちが集まれば、意見が別れるのは当然だ。 PerlかJava、MySQLかPostgreSQL、Webかエンタープライズかで話が違ってくる。 ひとまず、今回は比較的多数のユーザから同時にアクセスされるWebシステムにおけるデータベース接続事情に話を限定したい。 PerlとJVMの世界、PostgreSQLとMySQLの世界の組み合わせを考えると、だいたいのケースは抑えられるのではないかと思う。

とはいえ、細かい違いはあれど、結局は、アプリケーションサーバからデータベースサーバに対して、接続を維持するかそれとも都度接続するか、接続を維持するならどのように接続を維持するのかという問題でしかない。これを念頭に置きつつ、本題に入ろう。

データベース接続の永続化とはなにか

接続を考える上で、都度接続するというのは自然な発想だと思う。 その一方で、データベース接続の永続化とはなにか、なぜデータベース接続を永続化するのかということを改めて考えてみる。

データベース接続のオーバヘッド

データベース接続処理には一定のオーバヘッドがある。 データベース、特にRDBMSの接続オーバヘッドとして、TCPコネクションを確立する(TCP 3-way handshake)ためのオーバヘッド、データベース層のハンドシェイクオーバヘッド、データベース層の認証オーバヘッド、データベースプロセス側の接続用プロセス/スレッド生成オーバヘッドなどがある。接続を破棄するオーバヘッドも当然ある。 ここでいうオーバヘッドとは、接続に要するレイテンシと、接続を受け付けるデータベースサーバの主にCPUとメモリ消費を指す。

もちろんデータベース製品により詳細は異なると思う。例えば、Redisはイベント駆動モデルのアーキテクチャなので、接続用のプロセスやスレッドを生成したりはしない。

オーバヘッドについて補足する。TCPは3-way handshakeを経てコネクションを確立したとみなす。3-way handshakeには最低1往復のパケットのやりとりが必要だ。 これに加えて、MySQLやPostgreSQLはデータベース層においても、接続確立のためにメッセージのやりとりをする。 具体的なハンドシェイクや認証の内容については、MySQLの場合は MySQL :: MySQL Internals Manual :: 14.2 Connection Phase、PostgreSQLの場合は PostgreSQL: Documentation: 9.4: Message Flow を参照してほしい。 これらのハンドシェークを経て、SQLなど、本体となるメッセージを送信する。

接続確立そのものとは関係ないが、TCPのフロー制御は、パケットロスしない限りは同時に送信するパケット数(ウィンドウサイズ)を徐々に増やしていくため、都度接続より常時接続のほうがネットワークスループットが大きくなりやすい。都度接続していると、ウィンドウサイズが初期サイズからのスタートになってしまう。

ちなみに、HTTPには前者のTCPの接続オーバヘッドは当然あるが、HTTP自体には接続確立のためのプロトコルはない。 あまりRDBMSのプロトコルに明るいわけではないが、この辺りから、RDBMSのプロトコルはもともと大量のクライアントからの接続を想定してはいなかったのではないかということが伺える。 接続ごとにプロセスやスレッドを生成しているところも、大量接続を意識してPreforkやスレッドプール、イベント駆動モデルを採用していることが多いWebサーバアーキテクチャの世界とは異なる。

データベース接続の永続化手法

これらの接続オーバヘッドを節約する手法が、接続の永続化だ。接続の使い回し、接続のキャッシュといってもよい。 接続を使いまわすことにより、初回以外の接続確立のオーバヘッドを削減できる。

接続の永続化と一口に言っても、大きく2つの種類があると考えている。

1つ目は、Webアプリケーションサーバのリクエスト処理中の間だけ、データベースとの接続を永続化するというものだ。 ここでは、これをリクエスト都度接続モデルと呼ぶことにする。 リクエスト都度接続モデルは、Webアプリケーションサーバがリクエスト処理を開始してから、最初のデータベース接続後にDBオブジェクトのようなものをメモリ上に保持しておき、リクエスト処理終了前にDBオブジェクトを破棄して、接続をクローズする。 もちろん、リクエスト処理中の間だけ接続を永続化できればよいので、実装パターンはいくつかあり得ると思う。言語により事情が異なるということもあるだろう。

モダンPerlの場合は、リクエストの開始と終了の破棄に、Plack::Middlewareという仕組みでフックを仕込める。 Scope::Container::DBIを書いた - blog.nomadscafe.jp のようにして、リクエスト処理の終了時に、DB接続を破棄することができる。 RubyのRackやPythonのWSGIでももちろん同じようなことができると思う。

2つ目は、Webアプリケーションサーバのリクエスト処理に関わらず、常時接続を永続化するモデルだ。 これを常時接続モデルと呼ぶことにする。 常時接続モデルは、アプリケーションのグローバル空間に、データベースとの接続したままのデータベースハンドラオブジェクトを保持しておき、破棄せずに使いまわしていくというのが基本だ。 PreforkモデルのWebサーバであれば、各ワーカープロセスがそれぞれのプロセス内にデータベースハンドラを保持しておき、後続のリクエスト処理にも、再利用できるようにするという実装になる。

ここで、Webアプリケーションを書いているときに、大抵ハマるのが、接続をキャッシュしようとして、フレームワークのコンテキストオブジェクトなどにデータベースハンドラオブジェクトを雑にキャッシュしてしまうというものだ。 少なくとも、PerlのDBIの場合、DBI->connectの返り値であるデータベースハンドラオブジェクトをキャッシュしても、うまくいかない。 キャッシュしている間に、データベースとの接続が切れると、再接続せずにエラーを吐く。 データベース接続まわりのオブジェクトをキャッシュするときは、キャッシュして意図どおりに動作するのかをよく調査したほうがよい。

Perlで常時接続するなら、DBIのconnect_cached 、やScope::Conainer::DBIを用いて、再接続やfork安全かどうかを考慮しつつ、グローバル空間に接続ハンドラオブジェクトをキャッシュしておき、それを使いまわす。

RubyのActiveRecordであれば、後述するコネクションプーリングにより、ORM側でよしなに永続化してくれるようだ。

ここまでみると、接続のオーバヘッドを削減できる常時接続モデルのメリットが大きいようにみえる。 しかし、当然常時接続モデルにもデメリットはある。

まず、常時接続モデルはクライアント側のリソースリークなどのバグを作りやすいと思う。接続を維持するということは、複数の接続オブジェクト、スレッドなどをグローバルメモリ上に保持することになり、メモリリークの温床になる。接続が切れたときに、切断を検出して再接続することも必要だ。 一方で、都度接続モデルは、リクエストごとに接続オブジェクトを作成・破棄するため、オブジェクトの管理は比較的容易になる。 ただし、Perlの場合、大抵がPrefork型のWebサーバであり、MaxRequestsPerChildにより、定期的にワーカープロセスが死ぬ。したがって、常時接続とはいっても、長時間接続し続けるということがないため、リソースのリークについてはさほど問題にならないことが多い。 Rails界隈では、Introduce max_requests parameter to clear connections per some requests. by ryotarai · Pull Request #1 · sonots/activerecord-refresh_connection · GitHub のように、ActiveRecordでNリクエスト後に接続を切るというオプションを付けているらしい。

次に、接続を永続化すると、LVS/HAProxyなどのL4ロードバランサを経由する場合、均等にバランスされないという問題がある。 一旦ロードバランサにより振り分けられると、接続が切れないため、振り分け先のサーバは固定されたままだ。 同様に、ロードバランサ配下のサーバのメンテナンスも面倒になる。 例えば、LVSを用いる場合、コネクション振り分けの重みを0にして、コネクションが切れるのを待ち、メンテナンスを開始する。

さらに、接続の永続化により、データベースがフェイルオーバすると、アプリケーションサーバがフェイルオーバ先に再接続するまで、時間がかかることがある。 これは接続切断の検出などの再接続まわりの実装次第ではある。TCPの接続が切れるまで待たされる可能性もある。 大量のリクエストをさばいている環境だと、一瞬詰まると、システム全体が詰まってしまうこともあり得る。 アプリケーションサーバの再起動が必要な場合もあるだろう。

コネクションプーリングとはなにか

コネクションプーリングは常時接続モデルの実装の一種だ。 接続を永続化するだけでなく、接続の個数を厳密に管理しやすい。 主にJVMのエコシステムで使用されている印象がある。

接続数を管理できて何がうれしいのか。 データベースサーバ側の同時に接続できるコネクション数には様々な制限があり、これらの制限を超えてしまうと接続を受け付けなくなる。 クライアント側で接続数を管理できれば、データベース側の制限に引っかかなくてすむというわけだ。

データベースの接続制限には2種類あると考えている。 まず、Linuxカーネルでは、ポート数の上限や、ファイルディスクリプタ数などOSが管理するリソースの上限がある。 これは、カーネルオプションのip_local_port_rangeやデータベース側のopen_files_limitのようなオプションで上限を調整できる。アプリケーションサーバ側のtcp_tw_reuseの調整でTIME_WAIT状態のポートを早めに再利用することもできる。(注: パラメータ名の通り、tcp_fin_timeoutTIME_WAITは関係ないものでした。@namikawa さんご指摘ありがとうございます。) 次に、データベース層では、メモリのスワップなどを避けるために、接続数の上限をユーザ設定値により定めている。 PostgreSQLの場合、max_connectionsがそれに相当する。

カーネルにせよ、データベース層にせよ、結局はメモリなどのハードウェアリソースをいたずらに消費しないための制限である。 メモリが溢れてディスクへのスワップが発生し、データベースサーバが停止すると、システム全体に影響する。 システム全体に影響を与えるくらいなら、クライアント側で上限を設けておいて、多少の接続待ちが起きてもよいことにする。 つまり、通常の常時接続では、単にコネクション数を節約できるというメリットがあり、それに対して、クライアント側でコネクションプーリングを用いていると、一線を超えないようにデータベースサーバの負荷をコントロールできるということだと考えている。

コネクションプーリングの実装は大きく2種類ある。これらの2種類は、どこで接続をプーリングするのかが異なる。

コネクションプーリング: ドライバ型

1つ目は、JVMの世界のJDBCのようなアプリケーションサーバのデータベース接続クライアントが接続オブジェクトをプーリングするというものだ。 これをドライバ型と呼ぶことにしよう。 JDBCのコネクションプーリング(BoneCPHikariCP など)は、大雑把には、リクエスト処理用のスレッドプール以外に、データベース接続用のスレッドプールを作成して、各スレッドのローカル変数に接続オブジェクトを持たせるような形になっている。BoneCPやHikariCPの実装を眺めた程度で、JDBC全体について明るいわけではないため、異なるアーキテクチャの実装があるかもしれない。 scala-redisの場合は、スレッドプールではなく、Redisのクライアントオブジェクトをグローバル空間に保持する実装になっている。 プールのデータ構造には、スタック、キュー、連結リストなど様々な構造が用いられているようだ。

コネクションプーリング: プロキシ型

2つ目は、アプリケーションサーバとデータベースサーバの間にPgpoolPgBouncerなどの接続管理のためのプロキシを挟んで、プロキシにプーリングさせるというものだ。 こちらは、プロキシ型と呼ぶことにする。 プロキシ型では、アプリケーションサーバとプロキシの間は都度接続でもよい。 したがって、Perlのようにドライバ型のコネクションプーリングの実装がほとんどないような言語でも、コネクションプーリングが使えるというメリットがある。

そもそも、Perlではまともなスレッドがないため、ほとんどのWebサーバがPreforkモデルである。Preforkモデルでコネクションプーリングしようとすると、各ワーカープロセス間でプールを共有するために、プールを保持する場所としてのプロセスが必要となる。無理やりコネクションプーリングを実装できないことはないと思う。しかし、1プロセスの中で完結するJVM言語とは事情が異なる。 どのみち、専用プロセスが必要なら、プロキシ型のほうが汎用的に使えてよいということもあるかもしれない。 各ワーカープロセスが個別にコネクションプールを保持するというのももちろんありえる。リクエスト処理中にAnyEventなどのイベント駆動で並列にデータベースに対して接続するというケースでは、コネクション数の爆発を抑えるために有効かもしれない。

プロキシ型のメリットは他にもある。接続数を管理できるとはいっても、ドライバ型の場合、アプリケーションサーバの台数を増やすと、接続数もアプリケーションサーバ数に比例して増える。したがって、プロキシ型は全てのアプリケーションサーバからの接続数を一定に保ちやすい。 もちろん、アプリケーションサーバ同様に、プロキシをスケールアウトことももちろんある。 しかし、接続を中継するだけのプロキシのスループットより、継続的に機能開発されるアプリケーションサーバのスループットのほうが低下しやすいため、アプリケーションサーバの台数を増やす機会のほうが多いはずだ。

一方で、プロキシ型のデメリットは、リバースプロキシ・アプリケーションサーバ・データベースという代表的な3層構成に加えて、1つ層が増えることにより、管理の手間が増えることだ。一般に、層が増えるとシステム全体の可用性も低下する。 ただし、プロキシを独立したサーバに配置するのではなく、アプリケーションサーバに同居する構成をとれば、サーバの数を増やさずに済む。 しかし、先に述べた接続数の管理のしやすさというメリットは失うことになる。

補足だが、負荷のピークタイムが存在するシステムの場合、ピークタイムに合わせてプール数を設定していると、ピークタイム以外には余分な接続が開いたままなため、データベース側の接続維持のためのメモリが無駄になるという話もある。 BoneCPなどの一部のコネクションプールの実装は、最小接続数と最大接続数を設定しておき、最小と最大の定義域の範囲内で、接続数を負荷に合わせて動的に変更して、メモリ効率を良くしている。

コネクションプーリング全体について

ドライバ型にせよプロキシ型にせよ、最大/最小プール数、接続のタイムアウト時間、再接続までの時間、接続を維持し続ける時間、などの多くのパラメータをチューニングしなければならないことがある。 もちろん、コネクションプーリングの実装次第で管理するパラメータは異なる。

例えば、最大/最小プール数を等しくした結果、接続維持時間を超えた瞬間、一斉に再接続が発生し、一部のアプリケーションスレッドがプールからコネクションを取り出せずにエラーを吐くということがあった。

コネクションプーリングの実装に習熟する暇がないなら、データベースのスケールアップやスケールアウトで解決するなら、それでよいと思っている。 無理に難しいことをし始めて、トラブルシューティングに時間を浪費してしまうということもある。 MySQLのマスタのスケールアップが限界にきていて、コネクション数の上限にあたりそうな場合や、2~3桁のMySQLのスレーブ数のうち何割か台数を減らせる見込みがあるなら、コネクションプーリングを検討してもよいと思う。

PostgreSQLとMySQL

PostgreSQLとMySQLについて、接続の永続化事情を紹介する。 PostgreSQLとMySQLでは、接続受け付けのアーキテクチャやユーザコミュニティが異なるので、おのずと永続化に対する温度感も異なる。

PostgreSQLは1つの接続に対して、1つのプロセスを生成する。 つまり、マルチプロセスモデルであるため、後述のMySQLと比較して、メモリ消費量は多いといえる。 もちろん、Copy On Write(CoW)が効くはずなので、メモリ消費量は多少は最適化される。 しかし、接続を永続化していれば、徐々にfork元プロセスとの乖離が激しくなり、プロセスあたりのメモリ消費量は増加していく。 PostgreSQLは、接続を受け付ける度にforkするため、接続のオーバヘッドはそれなりに大きい。 大量接続を同時に受け付けると、負荷が跳ね上げることがある。

このようにPostgreSQLは、接続オーバヘッドが大きいことが知られているため、前述したPgpoolやPgbouncerなどのプロキシ型のコネクションプーリングと併用されることが多い。Pgpoolは高機能だが、コネクションプーリングの実装は、プールの数だけプロセスをPreforkする。一方、Pgbouncerはコネクションプーリングの機能しかないが、イベント駆動モデルを使用していて、効率がよいという違いがある。What Powers Instagram: Hundreds of Instances, Dozens of Technologies - Instagram Engineering によると、InstagramはPgbouncerを使っているようだ。

一方、MySQLは1つの接続に対して、1つのスレッドを生成する。 マルチスレッドモデルであるため、PostgreSQLと比べて、メモリ消費量は抑えられるはずだ。 とはいえ、スレッド生成/破棄にはそれなりのオーバヘッドがある。 MySQLにはThread Cacheの仕組みがあり、接続に使用したスレッドを使い回すことにより、オーバヘッドを抑えている。

MySQLでは、接続オーバヘッドが小さいことが知られているため、Mobage を支える Ruby の技術 ~ 複数DB編 ~ - sonots:blog に書かれているように、都度接続が用いられていることが多いようだ。はてなでも基本は都度接続している。

参考資料

まとめ

Webアプリケーションにおける都度接続、常時接続、コネクションプーリングなどのデータベース接続モデルと、PerlとJava、MySQLとPostgreSQL、DevとOpsといった各視点からみたデータベース接続事情について書きました。

データベース接続管理アーキテクチャは、Webサーバアーキテクチャと異なり、「UNIXネットワークプログラミング」に相当するような、議論のベースとなる文献がないため、体系的な議論を組み立てるのが難しいと感じました。 どうしても、特定の実装に限定した話になりがちです。 よい文献があれば教えてください。

長年、Perlで運用してきた実績があるため、あの人のあのモジュールを使っていれば、大規模環境の運用も考慮されていて安心して使えるというような知見の借り方がわかっていました。 一方、採用言語の変化により、それまでの表面的な常識が通用しなくなるということがあります。 例えば、モダンPerlの場合は、WebサーバはStarletを使って、ORMはDBIx::HandlerやContainer::Scope::DBIを用いて自作するといったことをやっていました。

しかし、Play2のようにWebサーバとフレームワーク、さらにORMまで密結合しているフルスタックのフレームワークを採用すると、勝手が変わってきます。 それまでリクエスト都度接続だったものがデフォルトで常時接続になっていることもあります。

接続を永続化したときのロードバランサによる接続管理の難しさなどのシステム運用事情は、なかなかアプリケーションエンジニアには馴染みがないか気づきにくいことだと思います。一方、Webアプリケーション側のデータベース接続まわりのコードがどのようになっているか、オペレーションエンジニアにとっては把握しづらいところかもしれません。

このようなシステムのアーキテクチャに対する共通理解もいわゆるDevOpsの一環だと思っています。あまりDevOpsの文脈で語られることはないかもしれませんが。

実践ハイパフォーマンスMySQL 第3版

実践ハイパフォーマンスMySQL 第3版