技術開発部で主にバックエンド開発をしている浜田(@hamchance0215)です。
現在開発中のプロダクトのバックエンドはRails + graphql-rubyを使っています。
GraphQLを使った際のデメリットとしてN+1が発生しやすいということがよく言われます。
弊社でも例外ではなく、随所でN+1が発生していました。
今まではフロントエンドからの使われ方を考慮して、サクッと実装できる先読み(preload等)でN+1を回避していたのですが、クエリー数が増加したことでクエリーごとに使われ方を把握するのは難しくなってきたのと、同じクエリーであったとしても使われ方が多様化して先読みでは非効率になることが懸念されました(先読みが非効率になる件は後述します)。
そこで重い腰をあげて、遅延ロードを導入するために調査を行うことにしました。
※この記事は執筆時点(2021/06)の情報で記載されています。また、検証は下記のバージョンで実施しています。
- Ruby: 3.0.1
- Rails: 6.1.3.2
- graphql-ruby: 1.12.12
N+1になる例
具体例があったほうが良いので、この記事では下記のモデルとクエリーを使います。
モデル
色々なパターンを網羅できたほうが良いので、user起点で下記の関係を作れるように構成しました。
- 1対1対多
- users - profiles - skills
- 1対多
- 多対多
- users - books (user_booksが中間テーブル)
#
# users
# id, name
#
class User < ApplicationRecord
has_one :profile
has_many :portfolios
has_many :user_books
has_many :books, through: :user_books
end
#
# profiles
# id, user_id, address
#
class Profile < ApplicationRecord
belongs_to :user
has_many :skills
end
#
# skills
# id, profile_id, name
#
class Skill < ApplicationRecord
belongs_to :profile
end
#
# portfolios
# id, user_id, name, url
#
class Portfolio < ApplicationRecord
belongs_to :user
end
#
# user_books
# id, user_id, book_id
#
class UserBook < ApplicationRecord
belongs_to :user
belongs_to :book
end
#
# books
# id, title
#
class Book < ApplicationRecord
has_many :user_books
end
GraphQL
usersを取得するクエリーを実装します。
検証用なので特に絞り込みなどはなく、usersテーブルの全件を返却することにします。
module Resolvers
class Users < Resolvers::BaseResolver
type Types::UserType.connection_type, null: false
def resolve
User.all
end
end
end
ObjectTypeは下記の通り。
module Types
class UserType < Types::BaseObject
field :id, Integer, null: false
field :name, String, null: false
field :profile, Types::ProfileType, null: true
field :portfolios, Types::PortfolioType.connection_type, null: false
field :books, Types::BookType.connection_type, null: false
end
class ProfileType < Types::BaseObject
field :id, Integer, null: false
field :address, String, null: true
field :skills, Types::SkillType.connection_type, null: false
end
class SkillType < Types::BaseObject
field :id, Integer, null: false
field :name, String, null: false
end
class PortfolioType < Types::BaseObject
field :id, Integer, null: false
field :name, String, null: false
field :url, String, null: false
end
class BookType < Types::BaseObject
field :id, Integer, null: false
field :title, String, null: false
end
end
テーブル構造とほぼ一致していますが、中間テーブル(user_books)に対応するタイプは実装していません。
DBとタイプの紐付けについての方針は別途ブログにまとめているのでご興味がある方はご覧ください。
動作確認
下記のusers
クエリーでユーザーを取得します。
query Users {
users {
nodes {
name
profile {
address
skills {
nodes {
name
}
}
}
portfolios {
nodes {
name
url
}
}
books {
nodes {
title
}
}
}
}
}
実行結果は下記の通り。2名のユーザーが登録されている状態で検証を進めます。
{
"data": {
"users": {
"nodes": [
{
"id": 2,
"name": "hoge",
"profile": {
"address": "住所だよ",
"skills": {
"nodes": [
{
"name": "Ruby"
},
{
"name": "Javascript"
},
{
"name": "Python"
}
]
}
},
"portfolios": {
"nodes": [
{
"name": "ポートフォリオ1",
"url": "url1"
},
{
"name": "ポートフォリオ2",
"url": "url2"
},
{
"name": "ポートフォリオ3",
"url": "url3"
}
]
},
"books": {
"nodes": [
{
"title": "hoge本1"
},
{
"title": "hoge本2"
},
{
"title": "hoge本3"
}
]
}
},
{
"id": 3,
"name": "fuga",
"profile": {
"address": "住所だよ",
"skills": {
"nodes": [
{
"name": "Ruby"
},
{
"name": "Javascript"
},
{
"name": "Python"
}
]
}
},
"portfolios": {
"nodes": [
{
"name": "ポートフォリオ1",
"url": "url1"
},
{
"name": "ポートフォリオ2",
"url": "url2"
},
{
"name": "ポートフォリオ3",
"url": "url3"
}
]
},
"books": {
"nodes": [
{
"title": "fuga本1"
},
{
"title": "fuga本2"
},
{
"title": "fuga本3"
}
]
}
}
]
}
}
}
下記は実行時のログの中から発行されたSQLを抽出したものです。
user_idが2と3のユーザーに対してprofiles、skills、portfolios、booksを別々でリードしておりN+1になっていることがわかります。
SELECT `users`.* FROM `users`
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 2 LIMIT 1
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` = 1
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 2
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 2
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 3 LIMIT 1
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` = 2
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 3
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 3
先読み
遅延ロードの検証の前に先読みで解決する場合の実装方法と非効率になってしまう例を紹介します。
先読みの実装は簡単です。下記のように先読みしておきたいアソシエーションをpreloadで指定すればよいです。
def resolve
User.all.preload(:portfolios, :books, { profile: :skills })
end
先ほどと同じクエリーを実行してみましょう。
先程はユーザーごとにリードしていましたが、今回はまとめてリードするようになりました。(user_booksのリードが増えてしまいましたが、全体的には減りました)
SELECT `users`.* FROM `users`
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3)
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (2, 3)
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)
大抵のN+1はこれで解決なのですが、GraphQLはそうはいきません。
GraphQLは使用側から取得したい項目を選択できるという特徴があります。そのため下記のようなクエリーでリクエストされることがあります。
query Users {
users {
nodes {
id
name
}
}
}
上記のクエリーの場合、usersテーブルだけリードすればよいのですが、今回preloadを付けてしまったことで不要なテーブルまでリードされてしまいます。
これがGraphQLの場合、先読みが非効率になってしまうパターンです。
これを回避するために遅延ロードという方法があります。次の章から遅延ロードについて記載します。
遅延ロード
遅延ロードとは、先読みとは逆に必要になってからリードする方式です。
先読みのように予めリードしておくわけではないので、使われないのにリードするということが発生しなくなります。
遅延ロードはRailsには組み込まれていないので、独自に実装するかライブラリを導入する必要があります。
独自に実装するのは非効率すぎるのでライブラリを探してみました。
対象ライブラリ
執筆時点(2021/06)で私が調査したところ、以下の4つのライブラリが見つかりました。
ライブラリを探す時、大抵の場合はメジャーなライブラリが1つ見つかってそれを導入すれば良さそうとなるのですが、今回の場合は甲乙つけがたいライブラリが複数出てきたのでそれぞれ実際に使ってみて使い心地を試すことにしました。
GraphQL::Batch
探したものの中で一番スターが付いていました。
GraphQL初期からあるライブラリのようで、ShopifyやGitHubでも使われているようで実績十分。
調査対象としました。
BatchLoader
- ☆837
- GitLabやNETFLIXで使われているライブラリ
graphql-batchより後発ではあるものの、リリースからは数年経過しており、有名企業でも使われているため実績十分。
後発のメリット(既存ライブラリのペインを解決している可能性が高い)があるかもしれないと思い調査対象としました。
Dataloader
こちらはスターも少なく、2018年から更新が止まっているので調査対象外としました。
GraphQL Ruby付属のDataloader
graphql-rubyにもバージョン1.12からDataloaderが含まれるようになったようです。
graphql-rubyはGraphQLの実装に使っているので追加ライブラリなしで使えるという大きなメリットがあります。
ただし、現時点でドキュメントに下記のように書かれており不安が残ります。
⚠ Experimental ⚠
This feature may get big changes in future releases. Check the changelog for update notes.
不安はあるものの、graphql-ruby自体に含まれていることのメリットは大きいので調査対象としました。
GraphQL::Batchの調査
導入方法はREADMEに記載されている通りなので省略します。調査時点のバージョンは0.4.3でした。
まずは、examplesにあるAssociationLoaderを実装しました。
READMEに載っているRecordLoaderはhas_oneにしか対応していなかったのでAssociationLoaderを使います。当初はhas_oneとhas_manyでRecordLoaderとAssociationLoaderを使い分けていたのですが、has_oneでもAssociationLoaderで動作したので統一しました。
基本コピペしましたが、initializeにsuperに()がないと動作しなかったので()を追加しています。
# app/loader/association_loader.rb
class AssociationLoader < GraphQL::Batch::Loader
def self.validate(model, association_name)
new(model, association_name)
nil
end
def initialize(model, association_name)
super()
@model = model
@association_name = association_name
validate
end
def load(record)
raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
return Promise.resolve(read_association(record)) if association_loaded?(record)
super
end
# We want to load the associations on all records, even if they have the same id
def cache_key(record)
record.object_id
end
def perform(records)
preload_association(records)
records.each { |record| fulfill(record, read_association(record)) }
end
private
def validate
unless @model.reflect_on_association(@association_name)
raise ArgumentError, "No association #{@association_name} on #{@model}"
end
end
def preload_association(records)
::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
end
def read_association(record)
record.public_send(@association_name)
end
def association_loaded?(record)
record.association(@association_name).loaded?
end
end
続いて、取得時に使用するloaderを定義します。
各モデルのアソシエーションに対応するxxx_loaderメソッドを定義しました。
AssociationLoaderのforにモデルクラスとアソシエーション名、loadにはアソシエーション元のオブジェクトを渡します。
# app/models/user.rb
def profile_loader
AssociationLoader.for(User, :profile).load(self)
end
def portfolios_loader
AssociationLoader.for(User, :portfolios).load(self)
end
def books_loader
AssociationLoader.for(User, :books).load(self)
end
# app/models/profile.rb
def skills_loader
AssociationLoader.for(Profile, :skills).load(self)
end
次にObjectTypeで取得メソッドを先程定義したloaderメソッドに変更します。
# app/graphql/types/user_type.rb
field :profile, Types::ProfileType, null: true, method: :profile_loader
field :portfolios, Types::PortfolioType.connection_type, null: false, method: :portfolios_loader
field :books, Types::BookType.connection_type, null: false, method: :books_loader
# app/graphql/types/profile_type.rb
field :skills, Types::SkillType.connection_type, null: false, method: :skills_loader
以上で完了です。
早速先程と同じクエリーを実行してみました。下記が実行時のSQLです。
見事に効率よく取得できています!
SELECT `users`.* FROM `users`
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3)
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (2, 3)
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)
もちろん、下記のようなクエリーの場合はusersに対するリードしか行われません。
query Users {
users {
nodes {
id
name
}
}
}
考察
一番最初にこのgemを検証したのですが、もうこれでいいじゃんと思って他の検証をやめようかと思いましたw
それほど使い勝手が良く感じました。
基本的には上記に書いた通りアソシエーションに対応するloaderを書いていけばよいだけで、一度書き方を覚えれば誰でも簡単に書くことができると思います。
チーム開発において簡単に書けることは重要な要素なので高評価です。
一方、Loaderクラスはコピペして作ったので導入コストはなかったですが、ライブラリ外の持ち物となっているのでバージョンアップ時にローダー周りの修正は手動で入れる必要があります。
Loaderクラスのようなライブラリ外だけどガッツリライブラリに依存しているクラスはバージョンアップ時に壊れやすいので注意が必要です。自動テストなどでカバーしましょう。
Dataloaderの調査
こちらも導入はREADMEを参照してください。調査時点のバージョンは2.0.1でした。
こちらはLoaderなどのクラスを別途作る必要はありませんでした。READMEのサンプル通りObjectTypeに直接取得メソッドを実装してみました。
# app/graphql/types/user_type.rb
def profile
BatchLoader::GraphQL.for(object.id).batch do |user_ids, loader|
Profile.where(user_id: user_ids).each { |profile| loader.call(profile.user_id, profile) }
end
end
def portfolios
BatchLoader::GraphQL.for(object.id).batch(default_value: []) do |user_ids, loader|
Portfolio.where(user_id: user_ids).each do |portfolio|
loader.call(portfolio.user_id) { _1 << portfolio }
end
end
end
def books
BatchLoader::GraphQL.for(object.id).batch(default_value: []) do |user_ids, loader|
UserBook.where(user_id: user_ids).preload(:book).each do |user_book|
loader.call(user_book.user_id) { _1 << user_book.book }
end
end
end
# app/graphql/types/profile_type.rb
def skills
BatchLoader::GraphQL.for(object.id).batch(default_value: []) do |profile_ids, loader|
Skill.where(profile_id: profile_ids).each do |skill|
loader.call(skill.profile_id) { _1 << skill }
end
end
end
パット見はgraphql-batchに似ていますが、こちらはLoaderクラスがなかった代わりにそれぞれの箇所に取得処理を書く必要があります。
単純なアソシエーション部分はほぼコピペですが、has_many-throughのところはpreloadをつけるなどN+1を解消するためには一手間必要でした。
あと、READMEに記載されているのですが、キャッシュがリクエストを跨いで残ってしまうので、下記のミドルウェアを追加してリクエストごとにクリアするようにしましょう。
# config/application.rb
config.middleware.use BatchLoader::Middleware
こちらでも同様のクエリーを実行してみます。下記の通りきちんとN+1が解消されています。
こちらも同様にusersのデータしか指定していないクエリーの場合はusersテーブルのリードのみとなります。
SELECT `users`.* FROM `users`
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3)
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (2, 3)
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)
考察
graphql-batchと同じことが実現できましたが、各箇所に実装する量はgraphql-batchより増えてしまうようです。(ラッパークラスを作ればもうすこし簡素化できるかもしれませんが)
has_many-throughのところはpreloadを自分で付ける必要があったので実装漏れをしてしまう可能性が高そうだなと感じました。preloadを付け忘れてもN+1が解消されないだけで動作するので気づきづらいです。
逆に、自分でカスタマイズする余地があるので、今後複雑な実装が必要になったときはこちらのほうが対応しやすいのかもと思いました。
GraphQL Ruby付属のDataloaderの調査
こちらはgraphql-rubyに同包されているためインストールは不要です。調査時点のバージョンは1.12.12でした。
ドキュメントにも記載されている通り、graphql-batchの影響を受けているようで、似たような実装ができました。
まずSourceを実装します。Sourceはgraphql-batchでいうLoaderのようなものです。
サンプルは単レコードのものになっていますが、graphql-batchのAssociationLoaderのような実装にしたかったので下記のように実装しました。
# app/graphql/sources/active_record_object.rb
class Sources::ActiveRecordObject < GraphQL::Dataloader::Source
def initialize(model_class, association_name)
@model_class = model_class
@association_name = association_name
end
def fetch(ids)
records = @model_class.where(id: ids).preload(@association_name)
ids.map {|id| records.find { _1.id == id.to_i }&.public_send(@association_name) }
end
end
あとはObjectTypeで使うように修正
# app/graphql/types/user_type.rb
def profile
dataloader.with(Sources::ActiveRecordObject, User, :profile).load(object.id)
end
def portfolios
dataloader.with(Sources::ActiveRecordObject, User, :portfolios).load(object.id)
end
def books
dataloader.with(Sources::ActiveRecordObject, User, :books).load(object.id)
end
# app/graphql/types/profile_type.rb
def skills
dataloader.with(Sources::ActiveRecordObject, Profile, :skills).load(object.id)
end
これで完璧と思ったのですが、実行してみたら全然ダメでした・・・
SELECT `users`.* FROM `users`
SELECT `users`.* FROM `users` WHERE `users`.`id` IN (2, 3)
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `users`.* FROM `users` WHERE `users`.`id` IN (2, 3)
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3)
SELECT `users`.* FROM `users` WHERE `users`.`id` IN (2, 3)
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (2, 3)
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`id` IN (1, 2)
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)
確かにuser_idでまとめてリードされるのですが、アソシエーション元のデータもリードしています。
アソシエーション元のデータはパラメーターで渡しており手元になるのでリードする必要はありません。
Souceクラスは私が独自に実装しているので私の実装の問題ではあるのですが、現状でgraphql-batchのようにアソシエーションを効率よく取得する実装が思いつきませんでした・・・
(loadクラスなどをガッツリオーバーライドすればできるのかもしれませんがそこまでしたら沼にハマるのでやりません)
ということでこちらは今回やりたい実装が見つからなかったことと、前述した通り今後変更の可能性が高いようなので今回は見送ることにします。
まとめ
調査した結果、今回はGraphQL::Batchを採用することにしました。
理由は直近もメンテされている点や、スターの数、使用実績など色々ありますが、一番の理由は最もシンプルに実装できたという点です。
チーム開発においてシンプルに実装できることは、開発時のミスが減り品質Upや生産性Upが見込めます。
調査時はloaderメソッドをmodelに定義していましたが、GraphQL専用となりそうなので、DataloaderのサンプルのようにObjectTypeに定義したほうが良いかも?と思ったりしています。
このあたりも含めて、まだ調査で数日間触っただけなので今後業務で使いながらライブラリに対する知識をアップデートしていきたいと思います。
Appendix
GraphQL::Batchを決めてから更に気になったところを追加調査しました。
- connection_typeで並び順を指定できるか?
- connection_typeでページネーションを指定した場合の挙動
connection_typeで並び順を指定できるか?
今回の例の場合、portfoliosやbooksは並び順を指定していませんでしたが、実際に運用をしていくときは並び順の指定が必須だと思います。
並び順はアソシエーションのlambdaで指定することで実現できました。
例えば、idの降順で取得する場合は下記のように記載します。
# app/models/user.rb
class User < ApplicationRecord
has_one :profile
has_many :portfolios
has_many :user_books
has_many :books, through: :user_books
# 下記のようにorderを指定したアソシエーションを追加する
has_many :sorted_portfolios, -> { order(id: :desc) }, class_name: 'Portfolio'
has_many :sorted_books, -> { order(id: :desc) }, through: :user_books, source: :book
end
あとはloaderでorderを付けたアソシエーションを指定すればOKです。
# app/models/user.rb
def portfolios_loader
AssociationLoader.for(User, :sorted_portfolios).load(self)
end
def books_loader
AssociationLoader.for(User, :sorted_books).load(self)
end
こちらで実行すると下記の通りorderが指定された状態で取得され、結果も降順に表示されました。
SELECT `users`.* FROM `users`
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3) ORDER BY `portfolios`.`id` DESC
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3, 4, 5, 6) ORDER BY `books`.`id` DESC
ELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)
connection_typeでページネーションを指定した場合の挙動
connection_typeではfirstやlastを指定することで取得する件数を指定することができます。
これらを指定した場合の挙動を確認します。
下記のクエリーを実行してみます。
query Users {
users {
nodes {
portfolios(last:2) {
nodes {
name
url
}
}
books(first:2) {
nodes {
title
}
}
}
}
}
下記のようにSQLが発行されました。
SELECT `users`.* FROM `users`
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3) ORDER BY `portfolios`.`id` DESC
SELECT COUNT(*) FROM `portfolios` WHERE `portfolios`.`user_id` = 2
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 2 ORDER BY `portfolios`.`id` DESC LIMIT 2 OFFSET 1
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3, 4, 5, 6) ORDER BY `books`.`id` DESC
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 2 ORDER BY `books`.`id` DESC LIMIT 2
SELECT COUNT(*) FROM `portfolios` WHERE `portfolios`.`user_id` = 3
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 3 ORDER BY `portfolios`.`id` DESC LIMIT 2 OFFSET 1
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 3 ORDER BY `books`.`id` DESC LIMIT 2
細かく見ていくと、portfoliosとbooksの取得はuserごとに取得しているのでN+1の状態です。また、遅延ロード用の一括取得SQLも実行されています。
これは、遅延ロードが動作して一括取得したものの、ページネーションが指定されている場合はレスポンスを作るときに再度limitやoffsetがついたクエリーが再度発行されるためだと思います。
ただ、ページネーションを指定した場合、userごとに取得しないとlimitなどを考慮した取得が困難なのでこの挙動は仕方がないのかなと思いました。
逆に遅延ロードのためのクエリーが冗長になってしまうので、ページネーションを確実に使うようなところには遅延ロードは入れないほうが良いのかもしれません。
遅延ロードを入れなかった場合、下記のクエリーになります。(当然ですが)遅延ロードのクエリーが消えています。
SELECT `users`.* FROM `users`
SELECT COUNT(*) FROM `portfolios` WHERE `portfolios`.`user_id` = 2
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 2 LIMIT 2 OFFSET 1
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 2 LIMIT 2
SELECT COUNT(*) FROM `portfolios` WHERE `portfolios`.`user_id` = 3
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 3 LIMIT 2 OFFSET 1
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 3 LIMIT 2
この結果を踏まえると、遅延ロードはhas_oneアソシエーションや全件取得するhas_manyアソシエーションには効果を発揮しますが、件数が多いのhas_manyなどページネーション前提のfieldの場合は実施しないほうが効率が良さそうです。