💎

Railsアプリが遅い理由はデータベースじゃない:実はCPUが詰まっていた話

に公開

Railsのスレッド数を増やせばCPUをより効率的に使い切れスループットは上がる──そう思っていたことはありませんか?
私も以前はそう思っていました。

Railsコアチームの一員でありRubyのコミッターでもあるJean Boussierさんの記事「The Mythical IO-Bound Rails App」を読み、「Railsを動かしているマシンのCPUがサチっていると、データベースのI/O待ちをしているように見えてしまう」というところが非常に興味深く、また私自身がその罠に嵌まっていたこともあるので、ポイントを整理して要約しました。

3行まとめ:

  • Railsアプリのレイテンシーはしばしば「データベースのI/O待ちが支配的」と言われるが、適切に設計されたデータベースならクエリは十数ミリ秒程度の短時間で返る。むしろ遅さの正体は、Rubyによるテンプレートレンダリングやビジネスロジックの実行など、アプリケーション側コードをCPUで実行する部分にある。YJITの導入でレイテンシーが改善するのもこれを裏付けている。
  • Railsアプリをプロファイラーにかけたとき、プロファイラー上でI/O待ちに見えている時間の中には、実際にはI/Oは既に完了していて、Ruby VMが後続の処理をするためにGiant VM lock (GVL)を取得できるのを待っている時間(≒CPUの空きを待っている時間)が含まれていることがある。そのようなときに「I/O待ちしてる(ように見える)からRailsのスレッドを増やそう」と誤った判断をすると、CPUがさらに逼迫し、レイテンシーが悪化する。このため、実行しているワークロードの特性に合った適切なプロセス数・スレッド数を選ぶことがレイテンシ改善に直結する。PumaのコミッターであるNateさんはRails 7.2でPumaのデフォルトスレッド数を5から3に減らすPull Requestに、「RailsアプリのI/O待ちが25~50%くらいなら、MRIではスレッド数は3がちょうどいい落としどころだと思う」というコメントをしている。
  • なお、この記事の議論はRailsのWebサーバー部分に限定した話である。多くのアプリケーションはSidekiqなどのバックグラウンドジョブがメール送信や外部API呼び出しといった本当にI/Oが遅い処理を担っているため、Webサーバー部分のレイテンシがI/O待ちによって支配されるケースはさらに少ない。

不適切なインデックス・N+1・無駄なクエリは"DBのパフォーマンス問題"ではなく"アプリのバグ"

データベースに適切なインデックスが貼られていない、N+1クエリが発生している、といった問題は、データベースの性能問題ではなくアプリ側のバグに過ぎない。
これは本質的にはインフラを増強して解決する類の問題ではなく、コードを修正して解決すべき問題である。

YJITでレイテンシー改善 = I/OではなくRubyでのコード実行が支配的であるという証左

多くのRailsアプリでYJITを有効化することにより15~30%レイテンシが改善したと報告されている(15.8-19.6% speedup with JIT 3.2Lobsters seeing a 26% speedupBasecamp and Hey seeing a 26% speedup , Shopify’s Storefront Renderer app seeing a 17% speedup)。
YJITはRubyコードの実行速度を改善するが、I/O待ち時間そのものは短縮しないため、レイテンシーの支配要因がRubyのコード実行であること裏付けている。

I/O時間計測の落とし穴──CPUが詰まるとI/O待ちをしているように"見えてしまう"

例えば、次のようなコードでデータベースのI/O待ちの時間を計測したとする。

start = Time.now
database_connection.execute("SELECT ...")
query_duration = (Time.now - start) * 1000.0
puts "Query took: #{query_duration.round(2)}ms"

たとえデータベースがすぐに結果を返しても、CPUに余裕がなければRubyはその後のコードquery_duration = .....の実行に着手できない[1]ので、見かけ上のI/O待ち時間が延びる[2]

このような理屈で『CPUの空きを待つ時間』がプロファイラー上ではI/O待ちとして見えてしまっていることがある。
そのような状況下で「I/O待ちの時間が長いからCPUが遊んでいるに違いない、だからスレッド数を増やそう」としてしまうと、CPUの競合がさらに増えて余計にレイテンシーが悪化するということが起きる。
このような背景もあり、Pumaのデフォルトスレッド数を5から3に減らすという変更がRails本体に取り込まれている[2:1]
また、Why Did Rails' Puma Config Change?!は『Puma のスレッド数を増やし過ぎると待ち時間が増え、レスポンスが遅くなる理由』を図解していてわかりやすい。

その他、元記事で触れられていたこと

  • Railsはステートレスなので、サーバーを並べるだけで素直にスケールする
    10倍のトラフィックにはおおよそ10倍のサーバーを用意すれば良く、スケール自体は難しくない
  • 『データベースがボトルネック』という言葉の本当の意味は、トラフィックが増えたとき真っ先に限界に近づくのがデータベースであるということ
    これは"N+1が起きていて遅い"といった意味ではなく、あくまでキャパシティープランニング上で最も注意すべきポイントがデータベースであるということを指している。
脚注
  1. (補足) Rubyには GVL(Global VM Lock)が存在し、任意の時点で実行されているRubyコードは1スレッドに限られる。そのため、他のスレッドが重たい処理をしていると、query_duration = .....の実行を担うスレッドに処理がなかなか回ってこないことが起きうる。 ↩︎

  2. あるスレッドではDBクエリを、他の5スレッドで重い処理をさせたとき、DBクエリを処理するスレッドでGVLの取得待ちが起きてる例がInstrumenting Thread Stalling in Ruby Applicationsで紹介されていました。 ↩︎ ↩︎

Discussion