includesしているのに N+1 が解消されなかった話


はじめに

こんにちは、毎川です!

業務で一覧画面の速度改善対応を行っていた際、includesを指定しているにもかかわらずN+1クエリが発生するという現象に遭遇しました。

当初はincludesの書き漏れを疑いましたが、調査を進めるうちにActiveRecordが内部で管理している Associationオブジェクトの存在と、そのキャッシュの挙動が原因であることが分かりました。

今回は、原因調査から解決までの流れをまとめます。


目次

  • 経緯
  • 現象
  • 原因調査
  • 原因特定
  • 解決方法
  • まとめ
  • 参考文献

経緯

一覧画面の表示速度改善を行っていた際、N+1クエリが発生していることが分かりました。

対象画面では事前にincludesを利用して関連データを取得しており、一見するとN+1は発生しないように見えました。

しかし実際にはViewの描画中に追加のSQLが発行されていました。

まずは通常通り、ログを確認しながら原因調査を進めることにしました。


現象

対象コードを簡略化すると以下のような構成です。※以降のコードはダミーになります。

class User < ApplicationRecord
  has_many :memberships

  has_many :organizations,
           through: :memberships
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :organization
end

class Organization < ApplicationRecord
end

一覧取得時には関連データをpreloadしていました。

scope :with_associations, -> {
  includes(
    memberships: [
      :organization
    ]
  )
}

Viewではthrough関連を利用していました。

<%= user.organizations.first.name %>

ここでSQLログを確認すると、organizationsを取得するクエリが追加で発行されていました。

includesしているにもかかわらずN+1が発生していたのです。


原因調査

最初は単純にincludesの指定漏れを疑いましたが、

過不足なく揃っていた為、内部処理に問題があるのではと思いました。

まずActiveRecordの内部実装を調べ始めました。

調査を進める中で知ったのが、Associationオブジェクトの存在です。

普段は意識することはありませんが、ActiveRecordは関連データそのものではなく、Associationオブジェクトを通して関連データのキャッシュ状態を管理しています。


原因特定

確認のためAssociationの状態を調べてみました。

user.association(:organizations).loaded?
=> false

user.memberships.first.association(:organization).loaded?
=> true

Membership側の関連はロード済みになっています。

しかしthrough側のorganizationsは未ロード扱いになっていました。

つまりActiveRecordから見ると、

user.organizations

を実行した時点で、

「organizationsはまだ取得していない」

と判断されていたのです。

そのため追加のSQLが発行されていました。

調査を進めると、一部Railsのバージョンでは、through関連が参照するAssociation キャッシュと、中間テーブル側のAssociationキャッシュが別管理になっていることが分かりました。

参照先のAssociationキャッシュが未ロードであればActiveRecordはSQLを発行します。

今回のN+1はこの挙動が原因でした。


解決方法

対応として、through側で利用する関連も明示的にincludesの対象へ追加しました。

修正前

scope :with_associations, -> {
  includes(
    memberships: [
      :organization
    ]
  )
}

修正後

scope :with_associations, -> {
  includes(
    :organizations,
    memberships: [
      :organization
    ]
  )
}

修正後に確認すると、

user.association(:organizations).loaded?
# => true

となり、追加のSQLは発行されなくなりました。

ローカル環境でもN+1が解消されたことを確認できました。


まとめ

今回の経験から得た学びは以下の通りです。

  • includesしていてもN+1が発生する場合がある
  • ActiveRecordはAssociationオブジェクトを利用して関連データを管理している

今回の調査を通して、内部処理を理解することの重要性を改めて実感しました。

「includesしているのにN+1が消えない」という現象に遭遇した場合は、Associationキャッシュの状態を確認してみると解決の糸口になるかもしれません。


参考文献

  • Rails Guides – Active Record Associations
  • Rails Guides – Active Record Query Interface

現在デザイン・システム室では、新しいメンバーを募集しています。
少しでも興味を持たれた方は、ぜひご応募ください。
皆様からのご応募、心よりお待ちしております。