RailsアプリケーションのConnection Poolとパフォーマンスへの影響を理解する

Webアプリケーションのボトルネックはたくさんある

最近、Webアプリケーションのパフォーマンスについて考えたり、教えてもらったりする機会が増えた、

その中で、今まであまり意識していなかったWebアプリケーションのアプリケーション - DB間のボトルネックについて知ったので、記す。

Connection Poolとは?

1 Requestの処理ごとにDBへの接続をイチから要求するのではなく、既に接続が確立されているConnectionを使い回すことを指す。

e-words.jp

例えば、あらかじめ5つのConnectionをPoolに保持しておく。するとアプリケーションはDBへの接続の初期化・終了処理をせずに同時に5プロセスまでDBにアクセスすることができる。 この仕組みを利用することで、アプリケーション - DB間のやりとりが簡略化され、結果としてパフォーマンスの向上に繋がることがある。 ただし、Connection PoolはDB側で用意されたConnection limitを超えて用意することはできず、それを超えようとするとタイムアウトが発生してしまう。

Connection Limitを増やすには

以下、PostgreSQLでの話。

PostgreSQLの設定にはmax_connectionsというものがあり、この値を超えない限りはConnection Poolを増やすことができる。

www.postgresql.jp

このmax_connectionは無尽蔵に大きくできるものではなく、同時接続数が増えれば増えるほどDB側のメモリを消費していく。DBが動いているサーバのメモリサイズに応じて適切に設定してあげる必要がある。

ちなみに、Heroku-Add onのPostgreSQLでは一番高いプランでもlimitが500と決められている。

blog.heroku.com

Connection Poolを設定してみる

さて、このConnection PoolはRailsの設定で変更ができる。

railsguides.jp

具体的には、config/database.ymlpoolという部分の数字を変更してやれば良い。Railsのデフォルトは5

# config/database.yml
default: &default
  adapter: sqlite3
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

しかし、この数字はどう扱えばいいのか?もちろん、デカくすればいいというものではない。

前述の通り、DB側のmax_connectionsを超えてConnectionを維持することはできない。さらに、Railspoolの設定に書かれている数字までConnectionを増やして良いものとして認識するので、この数字を適当に設定してしまうと、アプリケーションにはアクセスできるのに、アプリケーションからDBに接続する部分がタイムアウトになってしまうという残念な結果になる。

RailsはデフォルトでPumaをWebサーバーとして利用するので、以下、Puma限定の話。

devcenter.heroku.com

ここに書かれている通り、

If you are using the Puma web server we recommend setting the pool value to equal ENV['RAILS_MAX_THREADS']. When using multiple processes each process will contain its own pool so as long as no worker process has more than ENV['RAILS_MAX_THREADS'] then this setting should be adequate.

ENV['RAILS_MAX_THREADS']と同じ数値を設定してやれば良いらしい。 この環境変数config/puma.rbのThread設定に使われている。

# config/puma.rb

# Puma can serve each request in a thread from an internal thread pool.
# The `threads` method setting takes two numbers: a minimum and maximum.
# Any libraries that use thread pools should be configured to match
# the maximum value specified for Puma. Default is set to 5 threads for minimum
# and maximum; this matches the default thread size of Active Record.
#
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }

さらに、上記のHeroku記事にあるように、Railsはプロセス毎にConnection Poolを持つらしい。 Pumaは1 Worker毎にプロセスをForkする。

最終的に、thread = pool, worker * thread = connection limitとなるように設定してやる必要がある。

なお、Pumaが使おうとするThread数・Worker数は、Railsを起動した時のログに表示される。 Worker数を設定しなかった、あるいはWorker数が1の場合、PumaはSingle Modeとして起動する。

ちなみに、Workerが複数存在するモードをClustered Modeと呼ぶ。

github.com

web_1       | => Booting Puma
web_1       | => Rails 6.0.2.1 application starting in development
web_1       | => Run `rails server --help` for more startup options
web_1       | [8] Puma starting in cluster mode...
web_1       | [8] * Version 4.3.1 (ruby 2.6.3-p62), codename: Mysterious Traveller
# Pumaが使おうとするThread数が表示される ここではmin, max共に101
web_1       | [8] * Min threads: 101, max threads: 101
web_1       | [8] * Environment: development
# Worker数が表示される。ここでは10
# 101 Thread * 10 Workerなので、最大1010のConnction Limitが必要になる
# もちろんそんなクソデカConnection Limitを設定してはいけない
web_1       | [8] * Process workers: 10
web_1       | [8] * Phased restart available
web_1       | [8] * Listening on tcp://0.0.0.0:3000
web_1       | [8] Use Ctrl-C to stop
web_1       | [8] - Worker 0 (pid: 19) booted, phase: 0
web_1       | [8] - Worker 1 (pid: 22) booted, phase: 0
web_1       | [8] - Worker 2 (pid: 46) booted, phase: 0
web_1       | [8] - Worker 3 (pid: 51) booted, phase: 0
web_1       | [8] - Worker 9 (pid: 85) booted, phase: 0
web_1       | [8] - Worker 4 (pid: 66) booted, phase: 0
web_1       | [8] - Worker 5 (pid: 71) booted, phase: 0
web_1       | [8] - Worker 8 (pid: 84) booted, phase: 0
web_1       | [8] - Worker 7 (pid: 83) booted, phase: 0
web_1       | [8] - Worker 6 (pid: 82) booted, phase: 0

Puma自体のThread, Worker数の最適な数値については、利用しているサーバーのスペックによって変化が大きいので割愛。

おわりに

単一のアプリケーションサーバーであればこれで終わりだが、世の中ではアプリケーションサーバーのインスタンスをスケールさせることがよくある。 なので、そのようなケースではmax_scale_size * worker * thread = connection limitという計算で良い、のかな...?

実際にそれでいい感じに動くのか試していないので、今後要検証。