はじめに
Railsで階層構造を持つデータモデルを扱う際に使用するancestry
gem。便利なメソッドが準備されている反面、そのまま使用してしまうとN+1を起こしパフォーマンス的に良くない状態になってしまったので、どうやって解決したかを説明します。
TL;DR
ancestry
gemを使用して親子関係を扱う際に、メソッドを使わず自己参照のリレーションを利用することでN+1問題を解消できます。
環境
Ruby 3.3.0
Rails 7.1.3.2
ancestry 4.3.3
親子関係とN+1問題の例
ancestry
gemを使うと、カテゴリーのような階層構造を持つモデルを簡単に作成できます。例えば、以下のようにCategoryモデル単体で階層化されたモデルを例に考えます。
# category.rb
class Category < ApplicationRecord
has_ancestry
end
N+1を起こす原因箇所
-# index.html.erb
<table>
<tbody>
<% @categories.each do |category| %>
<tr>
<td><%= link_to category.name, category %></td>
<td><%= category.descendants.pluck(:name).join(' / ') %></td>
</tr>
<% end %>
</tbody>
</table>
この構造では、あるカテゴリーの子カテゴリーを表示したい場面です。以下のようなN+1を起こします。
Category Load (0.1ms) SELECT "categories".* FROM "categories" WHERE "categories"."ancestry" IS NULL
Category Pluck (0.1ms) SELECT "categories"."name" FROM "categories" WHERE ("categories"."ancestry" LIKE '1/%' OR "categories"."ancestry" = '1')
Category Pluck (0.0ms) SELECT "categories"."name" FROM "categories" WHERE ("categories"."ancestry" LIKE '7/%' OR "categories"."ancestry" = '7')
Category Pluck (0.0ms) SELECT "categories"."name" FROM "categories" WHERE ("categories"."ancestry" LIKE '13/%' OR "categories"."ancestry" = '13')
Category Pluck (0.0ms) SELECT "categories"."name" FROM "categories" WHERE ("categories"."ancestry" LIKE '19/%' OR "categories"."ancestry" = '19')
Category Pluck (0.0ms) SELECT "categories"."name" FROM "categories" WHERE ("categories"."ancestry" LIKE '25/%' OR "categories"."ancestry" = '25')
Category Pluck (0.1ms) SELECT "categories"."name" FROM "categories" WHERE ("categories"."ancestry" LIKE '31/%' OR "categories"."ancestry" = '31')
・
・
・
Completed 200 OK in 62ms (Views: 49.8ms | ActiveRecord: 1.1ms | Allocations: 74084)
自己参照のリレーションを使った解決法
このN+1問題を解決する方法として、自己参照のリレーションを活用します。具体的には、ancestry
の用意したdescendants
を使用せず、自己参照リレーションを定義してそちらを使ってN+1を回避します。
Category.rb
# category.rb
class Category < ApplicationRecord
has_ancestry
# 自己参照のリレーション
has_many :subcategories, class_name: 'Category', foreign_key: :ancestry
end
N+1を起こしていた原因箇所
-# index.html.erb
<table>
<tbody>
<% @categories.includes(:subcategories).each do |category| %>
<tr>
<td><%= link_to category.name, category %></td>
<td><%= category.subcategories.pluck(:name).join(' / ') %></td>
</tr>
<% end %>
</tbody>
</table>
Category Load (0.1ms) SELECT "categories".* FROM "categories" WHERE "categories"."ancestry" IS NULL
Category Load (0.1ms) SELECT "categories".* FROM "categories" WHERE "categories"."ancestry" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["ancestry", "1"], ["ancestry", "7"], ["ancestry", "13"], ["ancestry", "19"], ["ancestry", "25"], ["ancestry", "31"], ["ancestry", "37"], ["ancestry", "43"], ["ancestry", "49"], ["ancestry", "55"]]
Completed 200 OK in 21ms (Views: 17.0ms | ActiveRecord: 0.9ms | Allocations: 31891)
この方法であれば、N+1を起こさずパフォーマンスが良くなっていることが分かります。
この方法の問題点
- 孫カテゴリーができた時に対応できない
- ancestryではString型でパスを保存する方式(例: /1/2/3/)なので、このレコードが発生した時には破綻する
結論
- 子カテゴリーだけの構成であれば、通常のRailsの機能でN+1を回避することが可能
- ただし、2階層以上の複雑な構成を扱う場合は別の方法を採用する必要がある
- そもそもancestryを採用するべきか問題もあるが今回は触れない
最後までお読みいただきありがとうございます。限定的な手法ですが役に立てれば幸いです。