【Rails】俺、このindex_byが終わったらpluckとgroup_byするんだ

ループ内で特定のテーブルにアクセスする際に、対象のデータがアクセス先の親テーブルを介した子テーブルだった場合、その子テーブルの関連をあらかじめキャッシュしておかないとN+1問題が発生してViewの描画にとんでもない時間を要するということにつながりかねない。

このN+1問題の回避手段としてRailsのActiveRecordにはincludesをはじめとするいくつかのクエリインターフェースが用意されているが、これも何も考えずに使っているとテーブルのデータ量によってはキャッシュに乗せるレコード数が膨らみすぎて今度はメモリパフォーマンスに影響を与える可能性もある。
この記事ではN+1クエリの回避と大量のActiveRecordのキャッシュによるメモリ逼迫を抑えたデータ取得にフォーカスを当てた実装を紹介していきたいと思う。

そもそもN+1問題とは

例えば何らかの店舗の一覧を画面に表示したい場合、controller側で取得した店舗一覧のオブジェクトをeachしてviewに表示していくかと思う。
そしてこの店舗一覧画面でその店舗が所在する都道府県名も合わせて表示したいという要件があったとする。
この場合、controllerでの店舗一覧取得時に何の考慮もされていないと、ループ1回ごとに店舗に関連する都道府県名を都道府県テーブルに対して探しに行ってしまい、店舗N件分のSQLが発行されてしまうことがある。
このようにパフォーマンスに影響するレベルの大量のSELECT文が発行されてしまうようなクエリの実行をN+1クエリとかそれによって起きるパフォーマンス上のエラーをN+1問題と呼んだりする。

1. 店舗一覧に表示するデータを取得するために SELECT を 1 回実行(店舗N件分レコードが返される)
2. 各店舗データの関連データ(都道府県名)を取得するために SELECT を N 回実行
3. データベースアクセスが合計 店舗テーブルに対して 1 回 + 店舗の関連データ読み込み N 回実行される

対策方法

  1. JOIN句でテーブル同士を結合して、結合した一つのテーブルに対して1回のSQLを実行する
  2. eager loadingで先に関連テーブルからデータを取得してキャッシュする
  3. ActiveRecordオブジェクトをHashにする ← 今回はここにフォーカス◎


今回1と2に関するメソッドレベルの詳しい説明は割愛するが1,2についてはRailsだと以下のメソッドで対応することになると思う。

  1. ActiveRecord::QueryMethodsの joins, left_joins
  2. ActiveRecord::QueryMethodsの includes, preload, eager_load

Active Record クエリインターフェイス - Railsガイド
ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い - Qiita

各対応策ごとのメリデメ

JOIN

  • 関連がキャッシュに乗らないためメモリが逼迫しない
  • 上述の通り関連をキャッシュに乗せないため関連テーブルのデータを表示する場合などには不向き
  • 利用シーンとしては関連テーブルのデータを参照しつつ親テーブルの行数をカウントしたり、並べ替えたりする際に利用する
    • 例:関連しているテーブルの値で抽出した行数を出したい
    • 例:関連するテーブルの値で作成日時が最も新しいものの順に親テーブルのレコードを並べ替えたい

eager load

  • 関連をキャッシュに乗せるため、一覧画面等で関連テーブルの情報も合わせて表示する場合などにeach内でループごとにSELECTを発行しなくて済む
  • ActiveRecodeオブジェクトがキャッシュに乗るため関連テーブルの階層が深まるほどに倍数的にメモリに乗せる件数が増えていきメモリが逼迫する
  • 利用シーンとしては一覧画面等で関連を含めて連続的な描画を要する場合など、RailsにおけるN+1の対策としては最も容易かつメジャーな方法と思われる

Hash化

  • eager loadと異なり、取得したい関連テーブルをピンポイントで選択できるためメモリの逼迫を抑えつつ関連をキャッシュできる
  • 関連テーブル内の取得項目についてもpluckなどを用いることでSELECT句レベルで任意に選択できる
  • ActiveRecordオブジェクトがメモリに乗ること自体は変わらないため件数や取得項目数に気を払わないとメモリが逼迫する可能性が100捨て切れるわけではない
  • 親テーブルとは別に関連をキャッシュして、親テーブルのeach内でHashのキーでマッピングするため、コードの可読性やメンテナンス性においてはトレードオフ

具体的なHash化の方法とメソッドの使用例

index_by

  • 任意の属性をキーに、オブジェクト自体をバリューに取ったHashを作成
@stores = Store.all.index_by { |d| d.id }

group_by

  • 任意の属性をキーに、同じ属性を持つ複数のオブジェクト自体をバリューに取ったHashを作成
@stores = Store.all.group_by(&:prefectures_id)

map

  • 任意の属性をキーに、対象のテーブルの必要な属性のデータのみをバリューに取ったHashを作成
  • 戻り値は配列のためto_hでハッシュ化する必要あり
stores = Store.all.map do |store|
  [
		store.id, 
	  {
	    name: store.name,
	    address: store.address,
	    phone_number: store.phone_number,
	  }
	]
end.to_h

pluck

  • 任意の属性をキーに、対象のテーブルの必要な属性のデータのみをバリューに取ったHashを作成
  • mapとの違いとしてはmapはモデルに対してallしたものに対して項目を絞り込んでいるの対して、pluckはモデルへのSELECT句レベルで絞り込みを行うため最初にメモリに乗るデータ量が少なく済む
  • 戻り値は配列のためto_hでハッシュ化する必要あり
stores = Store.pluck(:id, :name, :address, :phone_number).map do |store| 
  [
		store[0], 
	  {
	    name: store[1],
	    address: store[2],
	    phone_number: store[3],
	  }
	]
end.to_h

参考記事

Hashを使って大量のActiveRecordをキャッシュに載せる方法 | Ruby on Rails