パフォーマンスを下げる「N+1問題」 Ruby on RailsとTypeORMでの対処法

最近までN+1問題はもう当たり前に意識して対処するものだと思っていたのですが、現在携わっているプロジェクトの既存コードにN+1問題が散見され、チーム内でも「N+1問題」と聞いて「?」になっているメンバーが何人かいました。

普段データベースを使っていないと知らない方も多いようですので、N+1問題とは何か、どのように解決すれば良いのかを解説したいと思います。

サンプルコードはRuby on Railsの場合とTypeORM(typescript用のORMライブラリ)の場合をご紹介します。各言語環境やライブラリのバージョンは以下で検証しています。

  • ruby 3.0.0
  • rails 6.1.3.2
  • nodev15.4.0
  • typeorm 0.2.34

N+1問題とは

N+1問題とは、親子関係にあるテーブルのデータを列挙する際などに、データの取得の仕方に問題があって無駄にたくさんのSQLを発行してパフォーマンスを下げてしまうことです。
例えば以下のようなテーブル構成で部署と社員の情報があったとします。

これらのテーブルから以下のように部署ごとに社員をリスト表示したいというような場合を考えます。

Ruby on Railsで何も考えずにコードを組むとControllerは以下のようになって

class DepartmentsController < ApplicationController
  def index
    @departments = Department.all
  end
end

Viewは以下のようになったりします。

<ul>
  <% @departments.each do |department| %>
    <li><%= department.name %></li>
    <ul>
      <% department.employees.each do |employee| %>
        <li><%= employee.name %></li>
      <% end %>
    </ul>
  <% end %>
</ul>

これで先程のリストは表示できるのですが、実行されるSQLを見てみると…

   Department Load (13.5ms)  SELECT "departments".* FROM "departments"
  ↳ app/views/departments/index.erb:2
  Employee Load (9.6ms)  SELECT "employees".* FROM "employees" WHERE "employees"."department_id" = $1  [["department_id", 1]]
  ↳ app/views/departments/index.erb:5
  Employee Load (1.2ms)  SELECT "employees".* FROM "employees" WHERE "employees"."department_id" = $1  [["department_id", 2]]
  ↳ app/views/departments/index.erb:5
  Employee Load (1.5ms)  SELECT "employees".* FROM "employees" WHERE "employees"."department_id" = $1  [["department_id", 3]]
  ↳ app/views/departments/index.erb:5
...

このようにまず部署のリストを取得するのに1回、その後に部署の数だけSELECT文が発行されてしまいます。
部署の数をNとすると、N+1回のSELECTが発行されるのでN+1問題と呼ばれています。

対処方法

Ruby on Rails(ActiveRecord)の場合

対処方法は簡単で、DBから取得する際に preload, eager_load, includes のいずれかを付けるだけです。

preload

class DepartmentsController < ApplicationController
  def index
    @departments = Department.all.preload(:employees)
  end
end

preloadで発行されるSQLは以下のように、部署を取得するのにまず1回発行され、取得した部署のIDを検索して社員をまとめて取得するので計2回になります。

  Department Load (1.1ms)  SELECT "departments".* FROM "departments"
  ↳ app/views/departments/index.erb:2
  Employee Load (8.1ms)  SELECT "employees".* FROM "employees" WHERE "employees"."department_id" IN ($1, $2, $3, $4, $5, $6, $7)  [[nil, 1], [nil, 2], [nil, 3], [nil, 4], [nil, 5], [nil, 6], [nil, 7]]

eager_load

class DepartmentsController < ApplicationController
  def index
    @departments = Department.all.eager_load(:employees)
  end
end

eager_loadの場合は以下のように LEFT OUTER JOIN で結合され、発行されるSQLは1回で済みます。

SELECT "departments"."id" AS t0_r0, "departments"."name" AS t0_r1, "departments"."created_at" AS t0_r2, "departments"."updated_at" AS t0_r3, "employees"."id" AS t1_r0, "employees"."department_id" AS t1_r1, "employees"."name" AS t1_r2, "employees"."created_at" AS t1_r3, "employees"."updated_at" AS t1_r4 FROM "departments" LEFT OUTER JOIN "employees" ON "employees"."department_id" = "departments"."id"

includes

class DepartmentsController < ApplicationController
  def index
    @departments = Department.all.includes(:employees) 
  end
end

includes は eager_loading? の値によって preload か eager_load のどちらかが呼ばれます。どちらになるかわかりにくいのであまりお勧めはしません。

preload と eager_load のどちらを使ったらよいかについてはケースバイケースですが、私は JOIN したいかどうかで決めてます。JOIN後にWHERE句で絞り込み条件を追加したい場合は eager_load で、それ以外は preload にすることが多いです。

なお、ActiveRecordではbulletというgemでN+1問題が起きているか検査することもできます。

TypeORMの場合

現在携わっているプロジェクトではTypeORMを使用していますので、TypeORMの例も記載します。
TypeORMの場合、実はN+1問題は発生しにくいです。
Ruby on Railsの最初の例のようなことを書こうとするとデータの取得部分は以下のようになります。

const departments = await connection.getRepository(Departments).find();

リストの出力部分は以下のようなコンポーネントになるでしょう。

export default function Departments({ departments }) {
  return (
    <ul>
      {departments.map((d) => (
        <React.Fragment>
          <li>{d.name}</li>
          <ul>
            {d.employees?.map((e) => (
              <li>{e.name}</li>
            ))}
          </ul>
        </React.Fragment>
      ))}
    </ul>
  );
}

このとき、d.employeesは何も読み込まれておらずundefinedになるため、部署の名前しか表示されません。

employeesのデータを取得しようと頑張って次のような処理を書いてしまうとN+1問題となります。

  const employees: Employees[] = [];
  for (const department of departments) {
    const employee = await connection
      .getRepository(Employees)
      .findOne({ departmentId: department.id });
    if (employee) {
      employees.push(employee);
    }
  }

(ここまで頑張って別々にデータを取得することはあまりないとは思いますが・・・)

TypeORMでN+1問題を起こさずにデータを取得する方法もいくつかありますが、ここでは基本的なものだけ紹介します。

findのオプションで relations を指定する

const departments = await connection
  .getRepository(Departments)
  .find({ relations: ["employees"] })

これが一番手っ取り早いと思います。このように書くと、Ruby on Railsの例で紹介したeager_loadと同じようなSQLになります。

SELECT "Departments"."id" AS "Departments_id", "Departments"."name" AS "Departments_name", "Departments"."created_at" AS "Departments_created_at", "Departments"."updated_at" AS "Departments_updated_at", "Departments__employees"."id" AS "Departments__employees_id", "Departments__employees"."department_id" AS "Departments__employees_department_id", "Departments__employees"."name" AS "Departments__employees_name", "Departments__employees"."created_at" AS "Departments__employees_created_at", "Departments__employees"."updated_at" AS "Departments__employees_updated_at" FROM "public"."departments" "Departments" LEFT JOIN "public"."employees" "Departments__employees" ON "Departments__employees"."department_id"="Departments"."id"

QueryBuilderでjoinする

const departments = await connection
  .getRepository(Departments)
  .createQueryBuilder("d")
  .leftJoinAndMapMany("d.employees", Employees, "e", "e.department_id = d.id")
  .getMany()

この場合も同じようなLEFT JOINで結合したSQLが1回だけ発行されます。
私はTypeORMではなるべくfindのオプションでrelationsを指定する方法で書いて、条件が複雑になる場合にQueryBuilderを使った方法で書くことが多いです。

まとめ

  • N+1問題が起きるとDBからのデータ読み込みに時間がかかってしまう
  • Ruby on Rails(ActiveRecord)ではpreloadやeager_loadなどで対応する
  • TypeORMではfindのオプションでrelationsを指定するか、QueryBuilderでjoinする

この記事の例のような少ないデータではあまり影響がないかもしれませんが、頻繁にアクセスされる場合や、データ量が多くなってきた場合などに大きな差が出てきますので、普段から意識してN+1問題を回避するようにしましょう。

この記事を書いた人

青木 美将

青木 美将

メンバーズエッジカンパニー所属。2021年中途入社。日本酒と肉とソフトクリームが好物。趣味はコーヒーの自家焙煎とオートバイでのツーリング。

おすすめ記事

タグ