ワシはワシが育てる

週刊少年ジャンプと任天堂のゲームが三度のメシより好きです。

Railsで複数のhas_oneを行う際の注意

Railsで以下のような状況を想定します。

class Blog < ActiveRecord::Base
  has_many :articles
  has_one :last_article, -> { order(id: :desc) }, class_name: :Article 
end

class Article < ActiveRecord::Base
  belongs_to :blog
end

Blogが複数のArticleを持つ、というオーソドックスなものですね。
Blogのインスタンスがlast_articleを参照するときはこのようになります。

@blog = Blog.find_by(id: 1)
@blog.last_article

この時のSQL

SELECT `articles`.* FROM `articles` 
WHERE `articles`.`blog_id` = 1 
ORDER BY `item_images`.`id` DESC LIMIT 1

というように一件だけ参照してくれます。

ただしBlogのインスタンスが複数となり、それぞれがlast_articleを参照する場合は注意が必要です。

@blogs = Blog.all.includes(:last_article)
@blogs.each do |blog|
  blog.last_article
end

この時N+1問題を防ぐために「includes」を指定しているのがハマりポイントです。
SQLを確認すると浮き彫りになります。

SELECT `articles`.* FROM `articles` 
WHERE `articles`.`blog_id` IN (1, 2, 3…) 
ORDER BY `item_images`.`id` DESC

一件だけ取得していた際には存在していた「LIMIT」が消えています。 つまり@blogsの長さ分だけ取得すればいいものを、素直に全て取ってきてしまっているのです。

実際にRailsの中の挙動を確認していたわけではありませんが、おそらく取得した全てのArticleのインスタンスを用意してしまっているために、Articleの数が多いほどパフォーマンスが目に見えて落ち込みます。

これを防ぐには、あらかじめlast_articlesで取得するべきArticleのIDを取得しておきます。(今回の場合は「対象のArticleの中で最もidが大きいもの」ですね)

last_article_id = Article.where(blog_id: [1, 2, 3...]).group(:blog_id).select('max(id)').pluck(:id)
last_articles = Article.where(id: last_article_id)

上のクエリでグルーピングしたうちの最もIDが大きいArticleのIDが配列で返ってきます。 あとはIDの配列で範囲指定してあげれば、必要な分だけのArticleを取得することができます。

Articleの総数が少ないうちはほとんど気にならないと思いますが、これが一つのBlogに数十、数百と紐付いている場合は目に見えてパフォーマンスが改善するかと思います。

ActiveRecordは本当に便利ですが、気付かずにパフォーマンスを悪化させていることがままあるので、扱いには注意が必要ですね。

※実はあらかじめIDを取得しなくてもSQLを上手く書かば1度のクエリで取得できます。
具体的にはサブクエリを使う方法と、selectをいじる方法があるのですが、前者はレコード数が多いとかえってパフォーマンスが悪くなること、後者はメンテナンス性が悪く、理解も難しいため今回は2回クエリを発行する方法を取っています。