まずは実装して動作を確認することが優先であってパフォーマンス・チューニングは二の次だといえども,やらないよりは,やるに越したことはない.気付いたときに,対応できることはしておきたい.今回,無造作な実装だと非効率さがいささか目に余るかな?ということで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 件のコメント:
コメントを投稿