Ruby on Railsの日常的なパフォーマンスルール【翻訳】

に公開

はじめに

RorVsWildのブログで紹介されていた「Ruby on Rails開発者のための日常的なパフォーマンスルール」という記事が非常に参考になったので、翻訳してまとめました。

この記事では、HTTP、Ruby、データベースの各レイヤーにおけるベストプラクティスを紹介しています。基本的には「常に従っても安全なルール」ですが、もちろん状況に応じて破ることも可能です。ただし、その場合は正当な理由が必要でしょう。

HTTP関連

CDNを使用する

すべてのリソースをCDNから配信しましょう。これにより以下のメリットがあります:

  • 訪問者の待ち時間を短縮
  • サーバーへのリクエスト数を削減
  • サーバーよりも高い帯域幅を提供

CDNは高価ではなく、料金体系も段階的です。設定も簡単です:

config.action_controller.asset_host = "cdn.application.example"

プライベートネットワークでのみ動作するアプリケーション以外では、CDNを使わない理由は見当たりません。

HTTP圧縮を有効にする

圧縮により、わずかなCPUコストで帯域幅を節約できます。

ApacheやNginxなどのほとんどのWebサーバーは、デフォルトで圧縮を有効にしています。レスポンスヘッダーに Content-Encoding: gzip が含まれているか確認しましょう。

HTTPキャッシュを有効にする

キャッシュされたリソースは、クライアントとサーバーの両方にとってリクエストが1つ減ることを意味し、結果として読み込み時間が短縮されます。

CDNを通過するすべてのリソースでキャッシュを有効にすることは言うまでもありません。Cache-Control ヘッダーは、ブラウザとCDNに指示を与えます。

レスポンスに以下のようなヘッダーがあることを確認してください:

Cache-Control: "max-age=86400, public"

max-age は秒単位の期間で、この場合は24時間です。より積極的なキャッシュが必要かどうかは、自分で判断してください。

プライベートなリソースの場合は、Cache-Control: private ヘッダーでキャッシュを無効にします。

Keep-Alive接続を有効にする

Keep-Alive接続は再利用可能です。接続を再確立する必要がなく、SSL交渉も不要になります。複数のリソースで構成されるすべてのページで待ち時間を短縮できます。

Webサーバーは多くの場合、デフォルトでこれを有効にしています。以下のヘッダーの存在を確認できます:

Keep-Alive: timeout=5, max=100

この例では、5秒間の非アクティブ状態の後に接続が閉じられ、100回再利用できます。

Ruby関連

できるだけバックグラウンドで実行する

重い処理や待ち時間が長いタスクは、可能な限りバックグラウンドで実行すべきです。

メール送信がその好例です。HTTPリクエストの所要時間と比較すると、比較的長いタスクです。さらに、ネットワーク接続が必要なため、所要時間が予測できません。

一方で、HTTPリクエスト中にメールを送信する義務はありません。そのため、Railsのコントローラーから deliver_later メソッドを使用するのは良い習慣です。

レスポンス時間を短縮するだけでなく、Webプロセスやスレッドを解放して次のリクエストを処理できるようになります。これにより、アプリケーションはより大きな負荷に対応でき、DoS攻撃に対しても脆弱性が低くなります。

count、size、lengthを使い分けてSQLクエリを節約する

最小限の、または最適化されたSQLクエリをトリガーするために、これら3つのメソッドの違いを知ることが重要です。

  • count メソッドは常に SELECT count(*) FROM table クエリをトリガーします
  • length メソッドは、リレーションがロードされていることを確認してからメモリ内でカウントします
  • size メソッドは、リレーションのロード状況に適応します(ロードされていなければクエリをトリガーし、すでにロードされていればメモリ内でカウント)

以下は要約表です:

メソッド レコードがロード済み レコードが未ロード
count SELECT count(*) FROM table SELECT count(*) FROM table
size メモリ内でカウント SELECT count(*) FROM table
length メモリ内でカウント SELECT * FROM table

カウントと列挙を行う場合、リレーションがロードされた後に size を呼び出すことが重要です。いずれの場合も、目的は単一のリクエストをトリガーすることです。

# 悪い例:1つではなく2つのクエリ
users = User.all
users.size # SELECT COUNT(*) FROM "users"
users.each { } # SELECT "users".* FROM "users"

# 良い例
users = User.all
users.length # SELECT "users".* FROM "users"
users.each { } # クエリなし

# 良い例
users = User.all
users.each { } # SELECT "users".* FROM "users"
users.size # クエリなし

# 良い例
users = User.all.load # SELECT "users".* FROM "users"
users.size # クエリなし
users.each { } # クエリなし

# 悪い例:1つではなく2つのクエリ
users = User.all
users.each { } # SELECT "users".* FROM "users"
users.count # SELECT COUNT(*) FROM "users"

exists、any/empty、present/blankを使い分けてSQLクエリを節約する

countsizelength と同様に、これらのメソッドの微妙な違いを知ることで、最小限かつ最も効率的なクエリをトリガーできます。

  • exists? メソッドは常にクエリをトリガーします。行を1つ見つけるとすぐに停止するため最適化されています
  • present?blank? メソッドは、メモリ内で存在を確認する前にクエリが実行されたことを確認します
  • any?empty? メソッドは、リレーションがすでにロードされている場合に適応します

以下は要約表です:

メソッド ロード済み 未ロード
exists? SELECT 1 FROM table LIMIT 1 SELECT 1 FROM table LIMIT 1
any?/empty? メモリ内 SELECT 1 FROM table LIMIT 1
present?/blank? メモリ内 SELECT * FROM table

これにより、レコードの存在に応じて表示を条件付ける場合の良い使い方と悪い使い方を推測できます。

# 悪い例:1つではなく2つのクエリ
users = Users.all
if users.exists? # SELECT 1 FROM users LIMIT 1
  users.each { } # SELECT * FROM users
end

# 良い例
users = Users.all
if users.present? # SELECT * FROM users
  users.each { } # クエリなし
end

# 悪い例:1つではなく2つのクエリ
users = Users.all
if users.any? # SELECT 1 FROM users LIMIT 1
  users.each { } # SELECT * FROM users
end

# 良い例
users = Users.all.load # SELECT * FROM users
if users.any? # クエリなし
  users.each { } # クエリなし
end

可能な場合はpluckを使用してActiveRecordインスタンスのロードを避ける

pluck を使用すると、SQLクエリの生の結果を取得できるため、ActiveRecordインスタンスの作成を回避できます。実行することが少なくなるため、必然的に高速でメモリ消費も少なくなります。

一方で、ActiveRecordモデルの全機能を利用できなくなります。そのため、モデルのメソッドが不要な場合に使用すると良いでしょう。特に、数千行のCSVやテキストのエクスポートを考えています。

# 遅い
CSV.generate do |csv|
  User.all.each { |user| csv << [user.id, user.name, user.email] }
end

# 速い
CSV.generate do |csv|
  User.pluck(:id, :name, :email).each { |row| csv << row }
end

大量のレコードを取得する必要がある場合は、pluck を使った解決策を見つけるようにしましょう。

シンボルまたは凍結された文字列リテラルを使用する

デフォルトでは、Rubyは文字列リテラルごとに新しいインスタンスを作成しますが、シンボルでは作成しません。

"Ruby".object_id #=> 60
"Ruby".object_id #=> 80
"Ruby".object_id #=> 100

:ruby.object_id # => 710748
:ruby.object_id # => 710748
:ruby.object_id # => 710748

同じ文字列が3回作成されているのは無駄です。シンボルは再利用されるため、より効率的です。

各ファイルの先頭にコメントを追加することで、Rubyにリテラル文字列を凍結して再利用するよう指示できます。

# frozen_string_literal: true

"Ruby".object_id #=> 60
"Ruby".object_id #=> 60
"Ruby".object_id #=> 60

:ruby.object_id # => 710748
:ruby.object_id # => 710748
:ruby.object_id # => 710748

ただし、文字列は凍結されているため変更できなくなります。制御できないメソッドに凍結された文字列を渡すと、FrozenError 例外が発生する可能性があります。明示的に複製する必要があります。

# frozen_string_literal: true

"Ruby".concat(" on Rails") # FrozenError: can't modify frozen String
"Ruby".dup.concat(" on Rails") # => "Ruby on Rails"

複数回必要な関数の結果はローカル変数に保存する

これは当たり前のように聞こえますが、以下のようなコードを見かけることは珍しくありません。

# 悪い例
if object.expensive_compute
  puts object.expensive_compute
end

# 良い例
if result = object.expensive_compute
  puts result
end

比較的高速なメソッドであっても、繰り返すのはもったいないです。

# 悪い例
puts array.first.method1
puts array.first.method2
puts array.first.method3

# 良い例
object = array.first
puts object.method1
puts object.method2
puts object.method3

これによってコードが複雑になることはなく、むしろ簡潔になることもあります。

HTTP接続を再利用する

「Keep-Alive接続を有効にする」セクションで説明した理由により、同じHTTP接続を再利用して複数のリクエストを実行する方が効率的です。毎回、接続の確立とSSL交渉に必要な時間を節約できます。これは大きな節約になります。

# 遅い:5つの接続を作成
Net::HTTP.get(url)
Net::HTTP.get(url)
Net::HTTP.get(url)
Net::HTTP.get(url)
Net::HTTP.get(url)

# 速い:同じ接続を5回再利用
Net::HTTP.start(url.host) do |http|
  http.get(url.path)
  http.get(url.path)
  http.get(url.path)
  http.get(url.path)
  http.get(url.path)
end

# 速い:ブロックなし
http = Net::HTTP.new(url.host, url.port)
http.start
http.get(url.path)
http.get(url.path)
http.get(url.path)
http.get(url.path)
http.get(url.path)
http.finish

最も簡単な方法は、Net::HTTP.start に渡されるブロック内ですべてのリクエストを行うことです。こうすれば接続を閉じ忘れることがありません。

すべてのリクエストを1つのブロックにまとめられない場合は、手動で開始と終了を行うこともできます。

データベース関連

データベース設定を調整する

デフォルトでは、ほとんどのデータベースは使用状況やサーバーの能力に対して最適に設定されていません。

自分でデータベースを管理している場合は、これを行うことが重要です。サードパーティが管理している場合でも、適切に行われているか確認することが同様に重要です。

幸いなことに、これを支援するツールが利用可能です。

PostgreSQLの場合は、PGTuneをお勧めします。MySQLの場合はMySQLTunerがありますが、私たちはまだ経験がありません。

RAMの量とCPUの数を入力するだけで、PGTuneはサーバーに最適な設定を提供します。非常に簡単です。

その後、設定をconfigファイルにコピーするだけです。私たちは /etc/postgresql/16/main/conf.d/pgtune.conf のような専用ファイルにこれらの設定を保存するのが好きです。将来変更する必要がある場合は、ファイル全体を置き換えるだけで済みます。メンテナンスが容易です。

SQLiteの場合、Rails 7.1以降は良いデフォルト設定があります。それ以外の場合は、これらのパラメータを自分で設定できます:

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA journal_size_limit = 67108864; -- 64メガバイト
PRAGMA mmap_size = 134217728; -- 128メガバイト
PRAGMA cache_size = 2000;
PRAGMA busy_timeout = 5000;

SQLは常にあなたのコードより速い

タスクをデータベースまたはコードで実行できる場合は、データベースに任せましょう。データがある場所ですべての作業が行われるため、より高速です。これは帯域幅の消費と待ち時間が少ないことを意味します。さらに、あなたのコードがデータベースより優れている可能性は低いです。

Invoice.pluck(:amount).sum # 遅い
Invoice.sum(:amount) # 速い

すべての外部キーにインデックスを作成する

外部キーがwhere句に現れる可能性は非常に高いです。そうでない場合、おそらくその外部キーは不要です。したがって、外部キーを追加する際にインデックスを作成するのは当然の判断です。

インデックスの欠点は、テーブルの書き込みを遅くすることです。しかし非常に多くの場合、読み取りの数は書き込みの数をはるかに上回ります。

一方、インデックスが使用されない場合は、削除すべきです。この情報はPostgreSQLの内部テーブルで利用できます。少し複雑ですが、PgHeroのようなツールを使えば非常に簡単です。

したがって、デフォルトでは各外部キーにインデックスを追加し、使用されない少数のものを削除します。

インデックスからnullを除外する

データベースインデックスはB-tree構造です。データのカーディナリティが高い場合に非常に効率的です。

ただし、列がnullを許可する場合、多くの場合それが最も冗長な値になります。インデックスの効率が低下し、より多くのスペースを占有します。

nullが頻度の低い繰り返し値でない限り、それらをインデックス化することに利点はありません。where句を使用してインデックスを作成する際に除外します。

add_index :table, :column, where: "(column IS NOT NULL)"

または純粋なSQLで:

CREATE INDEX name ON table (column) WHERE column IS NOT NULL;

ブール値などカーディナリティの低い列にはインデックスを作成しない

理由は前段落と同じです。B-treeインデックスは、カーディナリティが高い場合に最も効果を発揮します。したがって、ブール値はインデックスを作成できる最悪の列です。ブール値にはインデックスを作成しないでください。

他のタイプと同様に、ビジネス的に重要でない非常に繰り返しの多い値がある場合は、インデックスから除外するのがおそらく良いアイデアです。特にデフォルト値を考えています:

add_column :accounts, :balance, default: 0, null: false
add_index :accounts, :balance, where: "(balance != 0)"

または純粋なSQLで:

ALTER TABLE accounts ADD COLUMN balance decimal DEFAULT 0 NOT NULL;
CREATE INDEX index_accounts_balance ON accounts (balance) WHERE balance != 0;

まとめ

これらのルールは決して網羅的なものではありません。ぜひ自分なりのルールを共有してください。

パフォーマンスに関しては、基本的なベストプラクティスを守ることで、多くの問題を未然に防ぐことができます。特に、不要なSQLクエリを減らすこと、データベースの力を活用すること、そしてバックグラウンド処理を適切に使用することが重要です。

参考リンク

元記事(英語):Everyday performance rules for Ruby on Rails developers - RorVsWild

Discussion