zephiransasのチラシの裏

とあるJava/Rubyプログラマのメモ代わりブログ

論理削除とeager_loadでN+1問題が発生する件

Railsアプリにて論理削除とeager_loadを合わせて使うとN+1問題が発生することに気づいたのでメモ。

N+1問題を確認する

まずはN+1問題が起きるようなモデルを作成します。よくあるブログアプリのような、ブログのエントリがあり、それにコメントが複数あるパターンです。

1
2
3
4
5
6
7
8
9
10
11
12
class Post < ActiveRecord::Base
  attr_accessible :title,
                  :content
  has_many :comments
end

class Comment < ActiveRecord::Base
  attr_accessible :post_id,
                  :name,
                  :content
  belongs_to :post
end

適当なデータを入れた後、これに対してrails cで以下のようにレコードを取得します。

1
2
3
Post.all.each do |post|
  puts post.comments.first.name
end

すると、以下のようなSQLが発行されます。

1
2
3
4
5
6
7
8
9
Post Load (0.1ms)  SELECT "posts".* FROM "posts"
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 12 LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 13 LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 14 LIMIT 1
ユーザ1
...
(以下続く

この場合は、対象となったPostの件数分、CommentsテーブルへのSQLが発行されることになります。 これがN+1問題です。

N+1問題に対処する

これを解決するには、eager_loadを使うことが一般的です。つまり

1
2
3
Post.eager_load(:comments).each do |post|
  puts post.comments.first.name
end

この場合のSQLは(一部簡略化しています)

1
2
SQL (0.2ms)  SELECT "posts".*, "comments".*
FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"

となります。Postsテーブルと一緒にCommentsテーブルを取得しているので、SQLが1回だけ発行されていることがわかります。

美しい理想の世界です。ハラショー

paranoiaを導入する

さて本題。ここでうっかり論理削除を導入してみましょう。

Railsには論理削除に関するgemは多数ありますが、現在のデファクトスタンダードはparanoiaだと思います。まずはparanoiaをGemfileに記述します。

Gemfile
1
gem 'paranoia', '~> 1.0'  # Rails3系には1.0系を使用

その後、モデルを以下のように変更します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Post < ActiveRecord::Base
  acts_as_paranoid
  attr_accessible :title,
                  :content
  has_many :comments
end

class Comment < ActiveRecord::Base
  acts_as_paranoid
  attr_accessible :post_id,
                  :name,
                  :content
  belongs_to :post
end

その後、eager_loadしてみます。

1
2
3
Post.eager_load(:comments).each do |post|
  puts post.comments.first.name
end

するとSQLは以下の様に発行されます。(一部簡略化しています)

1
2
3
SQL (0.2ms)  SELECT "posts".*, "comments".*
FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"
WHERE ("posts".deleted_at IS NULL)

PostsテーブルのWHERE条件にdeleted_at is nullが付与されているのは期待通りですが、Commentsテーブルには付与されていないので、これでは論理削除されたCommentsテーブルの内容も取得してしまいます・・・

では、以下のようにPostのcommentsにconditionsを付与するのはどうでしょう?

1
2
3
4
5
6
class Post < ActiveRecord::Base
  acts_as_paranoid
  attr_accessible :title,
                  :content
  has_many :comments, conditions: 'comments.deleted_at is null'
end

ここで同様にeager_loadするとSQLは以下の様に発行されます。

1
2
3
SQL (0.2ms)  SELECT "posts".*, "comments".*
FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" AND comments.deleted_at is null
WHERE ("posts".deleted_at IS NULL)

WHERE条件が追加されて、なんだか、いい感じにeager_loadできました。

論理削除したデータも取得したい場合

さて、ここで少々頭がおかしくなって「削除したCommentも取りたい(^q^)」という気分になったとしましょう。

そこでPostクラスにcomments_with_deletedなるアソシエーションを追加します。

1
2
3
4
5
6
7
8
9
class Post < ActiveRecord::Base
  acts_as_paranoid
  attr_accessible :title,
                  :content
  has_many :comments, conditions: 'comments.deleted_at is null'
  has_many :comments_with_deleted,
           class_name: 'Comment',
           foreign_key: :post_id
end

さて、これを使ってeager_loadしてみましょう。

1
2
3
Post.eager_load(:comments_with_deleted).each do |post|
  puts post.comments.first.name
end

すると、以下のようなSQLが発行されます。

1
2
3
4
5
6
7
8
9
10
SQL (0.2ms)  SELECT "posts".*, "comments".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."id" = "posts"."id" WHERE ("posts".deleted_at IS NULL)
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 12 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1
ユーザ2
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 13 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 14 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 15 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 16 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1

最初のSQLでは、条件にcomments.deleted_at is nullが付与されていないので、これは期待通りなのですが、その後、なぜかN+1問題が再発しています。

現在のところ、これを回避できる方法は見つけられていません。

  • 追記

我らのひむひむセンセイから、アドバイスを頂きました。

なるほど!やってみましょう。

1
2
3
Post.eager_load(:comments_with_deleted).each do |post|
  puts post.comments_with_deleted.first.name
end

するとSQLは

1
2
3
4
SELECT "posts".
SQL (0.2ms)  SELECT "posts".*, "comments".* FROM "posts" LEFT OUTER JOIN
ON "comments"."post_id" = "posts"."id"
WHERE ("posts".deleted_at IS NULL)

となって、意図した結果になりましたとさ。

でもこれ、実装時に意識しながら書ける自信ないですわ・・・(´・3・`)

結論

結論を社畜ちゃんにまとめていただきます。

summary

とは言っても論理武装が必要でしょうから、こちらも合わせてどうぞ。