MNTSQ Techブログ

リーガルテック・カンパニー「MNTSQ(モンテスキュー)」のTechブログです。

Ruby on Railsをかんたんスピードアップ

MNTSQ Tech Blog TOP > 記事一覧 > Ruby on Railsをかんたんスピードアップ

Ruby on Railsをかんたんスピードアップ

こんにちは、MNTSQでサーバーサイドエンジニアのようなものをやっている西村です。今回は比較的簡単にRuby on Railsのアプリを高速化する方法を書いてみようと思います。

内容的にはタイトルのとおり、平易なものが多いのですが、頻度高く見かけるものをまとめてみました。

preload/include/eager_loadを利用してN+1を回避する

Railsでは紐づくレコードが芋づる式になりがちで、N+1問題がよく発生します。弊社ではgrapeというgemを利用してAPIを作成していますが、レスポンスを組み立てるタイミングでN+1問題がよく発生します。例えば、以下のようなActiveRecordがあったとします。

class Document < ActiveRecord::Base
  has_many :sections, dependent: :destroy
end

class DocumentEntity < Grape::Entity
  expose :id, documentation: { type: 'Integer', required: true }
  expose :sections, documentation: { is_array: true, required: true }, using: SectionEntity
end

class SectionEntity < Grape::Entity
  expose :section_type, documentation: { type: String, required: true }
end

それに対して以下のようなコードを書くと

documents = Document.where(type: :pdf)
present documents, with: DocumentEntity

以下のようなクエリが発行されてしまいます。

Document Load (0.8ms)  SELECT  `documents`.* FROM `documents` ORDER BY `documents`.`updated_at` DESC LIMIT 1
Section Load (11.6ms)  SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 3
Section Load (2.6ms)  SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 5
Section Load (1.9ms)  SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 8
Section Load (2.3ms)  SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 20
Section Load (0.6ms)  SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 21

その場合はincludes/preload/eager_loadを利用することで、N+1問題を抑制することができます。

documents = Document.preload(:sections).where(type: :pdf)
present documents, with: DocumentEntity

すると以下のようなクエリが発行されるようになります。IN句でまとめてクエリで取得されているのがご確認いただけますでしょうか。

Document Load (0.8ms)  SELECT  `documents`.* FROM `documents` ORDER BY `documents`.`updated_at` DESC LIMIT 1
Section Load (11.6ms)  SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` IN (3,5,8,20,21)

余談ですが、筆者は「N+1」という表現が分かりにくいのであまり好きではありません。 参考:

qiita.com

変数にキャッシュする

結果を変数にキャッシュして高速化する手法です。弊社ではフォルダ構造を表現するためにancestryというgemを利用していますが、このgemから提供されているメソッドをそのまま利用すると上記のN+1問題が発生します。また、この問題は上述のincludespreload等では解決できません。この場合には一度呼ばれたフォルダのデータを二度呼ばないように変数にキャッシュしていきます。

dir_cache = {}
document_scope.in_batches(of: 100).each do |documents_batch|
  doc_ids = documents_batch.pluck(:id)
  directory_doc_id_pair = ::Directory.where(document_id: doc_ids).pluck(:document_id, :ancestry).group_by(&:first).transform_values { |val| val.first.second }
  dir_ids = directory_doc_id_pair.values.map { |ancestry| ancestry&.split('/')&.[](1...)&.map(&:to_i) }.flatten.compact.uniq - dir_cache.keys
  dir_cache.merge!(::Directory.where(id: dir_ids).pluck(:id, :node_name).to_h) if dir_ids.present?

  documents_batch.each do |doc|
    path_names = directory_doc_id_pair[doc.id]&.split('/')&.[](1...)&.map(&:to_i)&.map { |dir_id| dir_cache[dir_id] } || []
    p path_names.join('/')
  end
end

上記はどちらかというと込み入った例で、変数キャッシュのやり方としてよくあるのは、以下のようなやり方です:

def document_count
  @document_count ||= user_document.count
end

これはdocument_countが何度も呼ばれることを想定し、その結果をインスタンス変数に記憶しています。

この使い方で気をつける点としては、インスタンス変数に記憶しているので、ActiveRecordを更新した際などに人力で変数をリセットする必要があることです。また、結果が判定falseのものの場合(falseやnilなど)、キャッシュが効かないので、その点も注意が必要です。

SQLを一本化する

ループ処理で大量のクエリが発行される場合、それらを1つのクエリで済ませるようにすると大幅に高速化できることがあります。例として、以下のとおり1ヶ月分の日々の商品ごとの売上高を計算する処理があったとしましょう。

prev_month_end = Time.zone.now.beginning_of_month - 1.second
prev_month_start = prev_month_end.beginning_of_month

dat = []
(prev_month_start.to_date..prev_month_end.to_date).each do |date|
  dat << Recipient.where(created_at: (date.beginning_of_day..date.end_of_day)).group(:product_code).pluck('sum(price)', 'count(*)', :product_code)
end

これでももちろん動くのですが、売上が伸びるとかなり時間がかかるようになってきます。これを以下のように1クエリで取得するように変更することで、スピードアップが見込めます。

Recipient.where(created_at: (date.beginning_of_month..date.end_of_month)).group(:product_code, 'date(created_at)').pluck('sum(price)', 'count(*)', :product_code, 'date(created_at)')

こういった問題は経年によってデータが溜まることにより顕在化することがあるため、継続的な速度計測をしておくと良いでしょう。

この手法は内容次第でメモリー不足でデータベースが死ぬことがあり、データベースにクエリを処理するために十分なメモリーがあるかを確認すると良いでしょう。

モリーストアへのキャッシュ

これは対策としては最も単純な部類です。Redisやmemcached等にActiveRecordをキャッシュします。実装イメージとしては以下のようになります:

# 最新10件をキャッシュする
Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_EXPIRE) do
  order(updated_at: :desc).limit(10).to_a
end

対応する際の注意点としては、列が増えたり減ったりした時にエラーを吐きがちなので、手元で以前のデータのキャッシュのままデプロイしてエラーにならないか、逆に新形式のデータのキャッシュがデプロイ前のサーバーでエラーにならないか等、綿密にチェックする必要があります。

いわゆるマスターデータは複数のテーブルにまたがったデータをキャッシュしたり、特定の条件のものを絞り込んでキャッシするということをよくやりますが、キャッシュキーをがたくさんできてしまい、どのキャッシュをどのレコードを更新したタイミングで消せば良いのかが煩雑になりがちで、それに起因した問題を踏みがちです。キャッシュに関連するテストは厚めにしておいたほうが良いでしょう。

実態をキャッシュできているか確認することも重要です。よくやりがちなのが、.to_aを忘れてしまって、キャッシュしたつもりが実は都度データベースへの問い合わせが発生しているというものです。これをやらかすと、なかなか気づきにくいです。

クラスキャッシュする

toC向けのサービスの場合、キャッシュをしてもキャッシュサーバーとのネットワークがボトルネックになってしまうことがあります。その場合、Railsサーバーの中でキャッシュをしてしまうことがあります。

ただし、設計に気をつける必要があります。そのサーバー上でその値で固定されてしまうことになるので、更新をかけるタイミングが難しくなります。

キャッシュ内容がメモリーにそのまま乗るので、メモリーに乗せられる量かつ単純な呼び出しのものが対象になります。メモリーについてはこれらがサービスに対するインパクトが強ければ、逆に大量のメモリーを抱えたサーバーを準備する方向もあるでしょう。

やり方としては以下のようにします:

# 1分間データをクラスにキャッシュする
def fetch_cache
  if @@cached_time && @@cached_time > 1.minute.before
    return @@recently_updated unless @@recently_updated.nil?
  end
  @@cached_time = Time.zone.now
  @@recently_updated = order(updated_at: :desc).limit(20).to_a
end

消費メモリー量はObjectSpace.memsize_of()を利用して計測することができます。文字列であれば文字数によって変化するので、注意が必要です。20件キャッシュするのであれば、1つあたりのだいたい20倍ということになります。

> ObjectSpace.memsize_of(Project.first)
=> 120

データベースのインデックスをちゃんとする

MySQLへのクエリが重くて、実はindexが効いてないということは稀によくあります。重いクエリを見かけた際は実際のクエリを吐き出してexplainで中身を見てみると良いでしょう。EXPLAINについて説明するとそれだけで立派なブログ記事が一本書けてしまうので、軽い紹介だけにとどめます。

> Project.all.explain
=> 
EXPLAIN for: SELECT `projects`.* FROM `projects`
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
| id | select_type | table    | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
|  1 | SIMPLE      | projects | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    2 |    100.0 | NULL  |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
1 row in set (0.00 sec)

レコードの存在確認で件数を数えない

細かなチューニングとしては、レコードの存在を確認する時に以下のようなコードを書きがちです。

> User.where(suspended: true).count > 0
SELECT COUNT(*) FROM `users` WHERE `users`.`suspended` = TRUE

それを以下のようなかたちに書き換えます:

> User.exists?(suspended: true)
SELECT  1 AS one FROM `users` WHERE `users`.`suspended` = TRUE

これも結構ありがちで、件数を取得するためのSQL負荷は意外と高いので、見つけ次第修正してデータベースのリソースを有効に使いましょう。

お役立ちgemのご紹介

以下にスピードアップのために役に立つgemをいくつかご紹介します。

github.com

ローカル環境でテストする時に、その画面で呼ばれているAPISQL、それらにかかっている時間などを把握することができます。

github.com

N+1を発見してくれるgemです。どう直したら良いかもアドバイスしてくれます。

github.com

大量のデータを一度にインサートできるgemです。1万件のデータを1件ずつsaveしていたらかなりの時間がかかりますが、バルクインサートすることで大量のデータのインサート時間を短縮することができます。なおRails 6以降であればinsert_allを利用すれば足りるでしょう。

github.com

gemというよりサービスの紹介になりますが、DatadogのAPMを利用することで遅いクエリをあぶり出すことができます。特に本番環境のデータ量でないと再現しない問題も多いので、継続的に監視することで問題を早期に発見し、未然に対処することができます。

重要なのは「不要不急のスピードアップをしないこと」

実はこれが最も重要です。すべてを最適化していたら相当な開発工数を消費するので、結果としてアプリのスピードの上昇率と比べて開発スピードが大幅に減速します。

一方で適切なタイミングでスピードアップする必要があります。そこのバランスはわりと職人芸になります。なぜ職人芸になってしまうかというと、プロダクトによって負荷のかかり方、組織の状況が異なるため、一概にこれが正解ということが言いづらいからです。

例えば、ITエンジニアリソースに余裕がある組織でtoC向けのサービスで多くの通信が発生するサービスであれば、N+1を撲滅し、すべてのデータをキャッシュし、キャッシュの削除部分の丁寧なテストを書いても良いでしょう。また創業したてでITエンジニアの人数も2-3名の組織では、スピードアップに割くリソースも無いため、問題が起きた部分だけチューニングをするという選択肢をとることもあるでしょう。

この記事を書いた人

Yuki Nishimura

雑食系エンジニア