行動すれば次の現実

ほどよくモダンなシステム開発を目指しています。メインテーマは生産性、Ruby、Javascriptです。

Railsアプリが爆速になるパフォーマンスチューニング集

f:id:furu07yu:20220407192847p:plain Railsアプリを速くするためのパフォーマンスチューニング集をまとめました。 これらの手法を用いることで、弊社アプリにおいて、今まで30秒ほど掛かっていた処理が1.5秒まで短縮することができました。

あくまでもアプリケーションレイヤーでできるパフォーマンスチューニングのみに絞っていますので、テーブルインデックスやサーバーのスケールアップなどは対象外としています。

パフォーマンスに悩んでいる方の助けになれば幸いです。

N+1問題の対策

言わずとしれたN+1問題の対策は、手っ取り早くパフォーマンス改善が見込めます。

「N+1問題とは何か?」についてはご存じの方が多いと思いますので、ここでは説明を割愛させていただきます。

N+1問題を解消する手段としては、テーブルからデータ取得する際にincludesなどにより関連するテーブルのデータも含めて取得するのが有効的です。

@blogs = Blog.includes(:user)

また、N+1問題が検出できるbulletというgemもありますので、導入を検討してみると良いでしょう。

includesは計画的に

includesはとても便利な機能ですが、過度にincludesしてしまうとメモリを消費してしまうというデメリットがあります。使用する際は後述のキャッシュ化などを適用した上で計画的にincludesするのがポイントです。

テーブルデータのキャッシュ化

テーブルデータのキャッシュ化もパフォーマンス改善にはとても効果的です。

ループ処理の中で、あるテーブルに対してアクセスしなければならない場合、ループ毎にテーブルをfindするのは非効率です。 事前に必要なテーブルデータを取得しておき、そのキャッシュを使ってアクセスするほうが何倍も速くなります。

例えば以下のような処理です。

blogs = Blog.order(:id)
blogs.each do |blog|
  # blogsの件数分findが実行されてしまう
  category = Category.find(blog.category_id)
  # 省略...
end

このようなコードだとblogsの件数分categoriesテーブルへのfindが実行されてしまいます。 blogsの件数が多い場合は遅くなる要因となります。

下記のようにincludesを使ってcategory一緒に取得する方法も有効的かと思いますが、 仮にblogsが1万件、categoryが10件しかない場合、1万件分のblogオブジェクトにcategoryが乗ることになりますので、無駄なメモリを消費してしまいます。

blogs = Blog.includes(:category).order(:id)
# blogが1万件、categoryが10件しかない場合
# 同じようなcategoryがblogオブジェクトに1万件セットされてしまう

blogs.each do |blog|
  category = blog.category
  # 省略...
end

そのため、categoryのテーブルデータを事前に取得しておくことで メモリ負荷を抑えられる且つ、パフォーマンスの向上も期待できます。

blogs = Blog.order(:id)
categories = Category.all.index_by(&:id)

blogs.each do |blog|
  category = categories[blog.category_id]
  # 省略...
end

index_by(&:id)を使用することで、idをkeyとしたcategoryのHashが構築されますので、Arrayを使うよりも高速にアクセスすることができます。

pluckで必要最低限のカラムのみ取得する

取得したテーブルデータの中で、特定のカラムのみしか使用しない場合はpluckを使用することをおすすめします。

例えばblogのタイトルを出力するプログラムの場合、下記のようにblogオブジェクトを全て取得する必要はないと思います。

blogs = Blog.order(:name)
blogs.each do |blog|
  p blog.name
end

必要なデータはnameだけですので、pluck(:name)をメソッドチェーンしてnameの配列を取得するようにします。

blog_names = Blog.order(:name).pluck(:name)
blog_names.each do |blog_name|
  p blog_name
end

pluckを使用することでメモリの効率化が図れますし、SQLクエリの負荷を抑えられることになりますので、パフォーマンス向上にも繋がります。

# pluckを使用しない場合
#   アスタリスク(*)で全項目のデータを取得してしまう
SELECT "blogs".* FROM "blogs" ORDER BY "blogs"."name" ASC

# pluckを使用した場合
#   nameのみに絞られる
SELECT "blogs"."name" FROM "blogs" ORDER BY "blogs"."name" ASC

バルクインサートで一括登録・更新する

SQLの中でもinsert(updateも同様)は特に遅いです。insertの回数を減らすことでパフォーマンス改善に繋がるケースは多くあります。

activerecord-importというgemを使うことで一括insert(バルクインサート)が可能となります。

Rails6以上であれば標準でバルクインサートがサポートされましたので、そちらも効果としては同様になります。

csv_records.each do |csv_record|
  Product.create(name: csv_record[:name], price: csv_record[:price])
end

このようなコードだとcsv_recordsの件数分productのinsert文が実行されてしまいます。

バルクインサートを用いることでinsert文が一回で済むようになります。

insert_products = []
csv_records.each do |csv_record|
  insert_products << Product.new(name: csv_record[:name], price: csv_record[:price])
end

Product.import insert_products

activerecord-importを使用する場合、バリデーションが効かないためエラーハンドリングが出来ないというのがデメリットかと思います。

その場合はvalid?メソッドを使用して事前にバリデーションを実行することでエラーハンドリングを実装することができます。

insert_products = []
csv_records.each do |csv_record|
  product << Product.new(name: csv_record[:name], price: csv_record[:price])
  unless product.valid?
    # エラーハンドリングをここに実装
  end
  insert_products << product
end

Product.import insert_products

関連オブジェクトを予め詰めておく

Railsのログを確認してみると大量のSQLが発行されていることがしばしばあります。 ほとんどのケースはN+1問題が原因となっています。

例えば以下のようなコードがあった場合、blog.userの実行によりN+1問題が発生してしまいます。

blogs = Blog.order(:id)
blogs.each do |blog|
  # 省略...
  blog.user.company_id
  # 省略...
end

下記のようなSQLが大量に発行されます。

SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2

includesを使用することでN+1問題は回避されますが、 メモリ効率を考えると過度なincludesは避けたいです。

そのような場合はN+1を防止するためにレシーバーとなるモデルオブジェクトに関連しているオブジェクトを予めセットしておくことが有効です。

blogs = Blog.order(:id)
users = User.all.index_by(&:id)

blogs.each do |blog|
  # 省略...
  user = users[blog.user_id]
  # あらかじめblogオブジェクトにuserをセットしておく
  blog.user = user
  blog.user.company_id
  # 省略...
end

このようにすることで、N+1が抑えられる且つ、過度なincludesによるメモリ負荷を抑えることができます。

ただし、この手法を用いると可読性が極端に悪化しますし、セットする関連オブジェクトの最新状態が保証されないというリスクも伴います。どうしてもパフォーマンスを向上させたいケースを除いては使用しないほうが良いでしょう。

モデルのバリデーションを無効にする

モデルのバリデーションといえど侮れません。負荷のかかる処理は存在します。

例えばモデルのバリデーションであるuniquenessは新規登録や更新処理が実行されると必ず以下のようなSQLが発行されてしまいます。

SELECT 1 AS one FROM "products" WHERE "products"."code" = $1 AND "products"."department_id" = $2 LIMIT

もしバリデーションエラーが発生しない前提のロジックなのであれば意図的にバリデーションを無効にすることでパフォーマンスが向上します。 バリデーションを無効にすることで予期せぬデータが混入するリスクが伴いますので、あまり推奨はしません。

あくまでもパフォーマンス向上を重視する場合は検討することも視野に入れてみてはいかがでしょうか。

以下のようにsaveを実行することでバリデーションを一時的に無効にすることが可能です。

product.save(validate: false)

パフォーマンスチューンングの注意点

以上のようなパフォーマンスチューンングを施すことでRailsアプリでも爆速な処理を実装することができます。

パフォーマンスチューニングと可読性はトレードオフの関係にあります。

大々的なパフォーマンスチューニングを実施する場合、個々のロジック単位ではなく、機能全体で最適化を図ります。 そのため、どうしてもプログラムとして不自然なコードが生まれてしまい可読性が悪くなります。

可読性と天秤にかけた上で、場合によっては過度なパフォーマンスチューンングは避けるようにすることも必要です。

また、適宜コメントアウト等で処理の意図を補足するなどして可読性の低下を抑えるなどの心掛けが大事になります。