2021年3月17日水曜日

N+1問題を解決する【Rails備忘録】

まずは実装して動作を確認することが優先であってパフォーマンス・チューニングは二の次だといえども,やらないよりは,やるに越したことはない.気付いたときに,対応できることはしておきたい.今回,無造作な実装だと非効率さがいささか目に余るかな?ということでN+1問題の解決に取り組んでみた.

N+1問題とは,簡単にいえば「Aに関連したBというデータがあったとして,Aがn個の要素を持っているときに,Aに関する1回のSELECTと,Bに関するn回のSELECTが発生するので(計n+1回のSELECT文が発行される)非効率だ」というものである.

具体例

例として次のコードを考えてみる.先日から作っているRuby on Railsのアプリケーションである.

ある学校(School)のUserを抽出してきて,それらに関連するPostを全て抽出,ハッシュテーブルに保管し,最後はJSONでレンダリングして出力というものだ(本当はこれだけだと実用性に乏しくもう少し工夫が必要だが,今回はN+1問題に注目するためコードを簡素化して説明する).

class Td::Api::UsersController < ApplicationController

  def index

    users = User.where(school: current_user.school)

    hash = {}

    users.each{|user|

      hash[user.username] = user_info: user, posts: user.posts }

    }

    render json: hash

  end

end

さて,このコードを動かすと,次のような処理が行われる.

  User Load (1.1ms)  SELECT "users".* FROM "users" WHERE "users"."school_id" = $1  [["school_id", 2]]

  ↳ app/controllers/td/api/users_controller.rb:5:in `index'

  Post Load (0.9ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1  [["user_id", 1]]

  ↳ app/controllers/td/api/users_controller.rb:9:in `index'

  Post Load (0.9ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1  [["user_id", 2]]

  ↳ app/controllers/td/api/users_controller.rb:9:in `index'

  Post Load (0.9ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1  [["user_id", 4]]

  ↳ app/controllers/td/api/users_controller.rb:9:in `index'

  Post Load (0.8ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1  [["user_id", 6]]

  ↳ app/controllers/td/api/users_controller.rb:9:in `index'

  Post Load (1.0ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1  [["user_id", 7]]

  ↳ app/controllers/td/api/users_controller.rb:9:in `index'

Completed 200 OK in 65ms (Views: 9.3ms | ActiveRecord: 25.1ms | Allocations: 28826)


user_idが1, 2, 4, 6, 7の5名が該当し,それぞれに対して5回のSQLが発行されていることが分かるだろう.users.each{ ... } の繰り返しにおいて,都度,SQLを発行しているのでこうなってしまう.

問題解決

これを,次のようなコードに修正する.最初にUserのリストを抽出するwhere()の処理に対して,includes(:posts) を付けてやる点がミソ.

class Td::Api::UsersController < ApplicationController

  def index

    users = User.where(school: current_user.school).includes(:posts)

    hash = {}

    users.each{|user|

      hash[user.username] = { user_info: user, posts: user.posts }

    }

    render json: hash

  end

end

動作は次のようになる.SQL文の発行回数が2回になっていることを確認されたい.

  User Load (0.9ms)  SELECT "users".* FROM "users" WHERE "users"."school_id" = $1  [["school_id", 2]]

  ↳ app/controllers/td/api/users_controller.rb:5:in `index'

  Post Load (1.0ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3, $4, $5)  [[nil, 1], [nil, 2], [nil, 4], [nil, 6], [nil, 7]]

  ↳ app/controllers/td/api/users_controller.rb:5:in `index'

Completed 200 OK in 64ms (Views: 3.0ms | ActiveRecord: 20.5ms | Allocations: 24834)

問題は無事解決.めでたし,めでたし.

応用問題

さて,話はこれだけでは終わらない.実はPostにはそれぞれCommentが紐付けられており,ひとつのPostに対して複数のCommentsが関連付けられている.

さてどうしよう.これも,無造作にやるとN+1問題が発生してしまう.ていうかN✕(M+1)+1問題?というか,まあ,なんというか.

で,こうする.「3つ以上ネストするときどうすんの?問題」はとりあえず置いておく.

class Td::Api::UsersController < ApplicationController

  def index

    users = User.where(school: current_user.school)

                .includes(posts: :comments)

    hash = {}

    users.each{|user|

      post_hash = {}

      user.posts.each {|post|

        post_hash[post.id] = { post_info: post, 

                               comments: post.comments }

      }

      hash[user.username] = { user_info: user, posts: post_hash }

    }

    render json: hash

  end

end

やってみた.関連のSQLは3つ発行されるだけである.まずはこれでOKということにしておこう.

  User Load (1.1ms)  SELECT "users".* FROM "users" WHERE "users"."school_id" = $1  [["school_id", 2]]

  ↳ app/controllers/td/api/users_controller.rb:5:in `index'

  Post Load (1.3ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3, $4, $5)  [[nil, 1], [nil, 2], [nil, 4], [nil, 6], [nil, 7]]

  ↳ app/controllers/td/api/users_controller.rb:5:in `index'

  Comment Load (1.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2, $3, $4, $5, $6)  [[nil, 1], [nil, 2], [nil, 3], [nil, 4], [nil, 5], [nil, 6]]

  ↳ app/controllers/td/api/users_controller.rb:5:in `index'

Completed 200 OK in 111ms (Views: 2.4ms | ActiveRecord: 50.8ms | Allocations: 34417)

だいぶ無駄を省くことができるようになった.

発展問題

「3つ以上ネストするときどうすんの?問題」も片付けておこう.CommentにはそれぞれUserが紐付けられているので,処理するときにさらにcomment.user.fullname なんて感じでフルネームを参照したくなる.このコードを書くと,またSQLがひとつ発行されてしまう.

一対多のとき,関連名は複数形で書いた.その逆のときは,単数形で書けばよい.UserにはScoresも紐付けられていて,さらにScoreにはRubricも対応,RubricはさらにLessonに関連付けられているというような状況のとき,includes()を丁寧に書くと,次のようになる.多段でネストするときは,連想配列をネストさせればよい,ということらしい.

users = User.where(school: current_user.school)

  .includes(posts: {comments: :user}, scores{rubric:lesson})

関連名を記述するとき,複数形と単数形をきちんと使い分けている点に注意しよう.また,PostsとScoresの関係のように,複数の関係が存在するときは,並べて書けばよいようだ.これらのテーブルがどのような関係にあるかは,先の記事を参考にされたい.

おまけ

(得られたJSONは json pretty printer で整形して確認するといいよ!)



0 件のコメント:

コメントを投稿