ちゃんとEager Loadingをした結果、逆に遅くなった件


背景

N+1を起こしている箇所があったので必要なデータをEager Loadingしてデプロイをした
その結果、逆にレスポンスが劣化した…

tl;dr

1:N のような関連の場合、eager_load(LEFT OUTER JOIN)した上で1の側にLIMITをかけると非常に遅いクエリが出来るケースがある

関連するモデル

Message/Attachmentの2つ
Messageは複数のAttachmentを持つ

class Message
  has_many :attachments
  scope :latest, -> { order(id: :desc) }
end  

class Attachment
  belongs_to :message
end

遅かったコード

Message.eager_load(:attachments).limit(5).latest

発行されていたSQL

SQLは2つ発行されいてた

対象となるMessage#idの抽出

SELECT
  DISTINCT `messages`.`id`
FROM `messages`
  LEFT OUTER JOIN `attachments`
    ON `attachments`.`message_id` = `messages`.`id`
ORDER BY
  `messages`.`id` DESC
LIMIT 5

実際に使うデータの読み出し

SELECT
  `messages`.*,
  `attachments`.*
FROM
  `messages`
  LEFT OUTER JOIN `attachments`
    ON `attachments`.`message_id` = `messages`.`id`
WHERE
  `message`.`id` IN(1, 2  3, 100, 100000)
ORDER BY `messages`.`id` DESC

何が遅かったのか?

対象となるMessage#idの抽出の中にあるDISTINCT message.id の部分

Message/AttachmentをJOINしているのでその結果は以下の表のような感じになる
このままではMessageに対してにLIMITはかけられない←Message#idの重複がある(1,2,2,5,5,5...)
そこで DISTINCT Message#id をして重複を除いた上でLIMITをかけることになる

Message Attachement
1 1
2 5
2 7
5 10
5 11
5 18
6 22
6 23

手元のデータ量では全く問題ないクエリだったのだが、本番ではJOINをした結果10万件ぐらいのデータに対して
DISTNCTをかけることになり、これが非常に遅かった

解決策

eager_loadではなくpreloadを使う

Message
  .preload(:attachments)
  .limit(5)
  .latest

発行されたクエリ

SELECT
  `messages`.*
FROM
  `messages`
ORDER BY `messages`.`id` DESC
LIMIT 5
SELECT
  `attachments`.*
FROM
  `attachments`
WHERE
 `attachments`.`message_id` IN (1, 2  3, 100, 100000)

そもそも

本番のデータ規模で確認したほうがいいと思うので、以下のような事例を参考にしてもいいと思います