Rails ActiveRecord — preload、eager_load、includes

預載主要是為了避免 N+1 問題,以下會先使用常搞混的 joins 看看,接著嘗試三種預載方式,來看他們實際產生的 Query 情況,比較之間的差異。

測試於 Rails 4.2、Rails 5.2

先產生兩個測試用的資料表

- Category          - Book
+----+-------+      +----+-------------+----------+
| id | name  |      | id | category_id |  title   |
+----+-------+      +----+-------------+----------+
|  1 | child |      |  1 |           1 | fish can |
|  2 | music |      |  2 |           3 | etf      |
|  3 | stock |      +----+-------------+----------+
+----+-------+

joins 不會將 join 進來的 table 資料載入記憶體,主要用途是用來做資料的 filter

have_book_categories = Category.joins(:books)
# SELECT `categories`.*
# FROM `categories`
# INNER JOIN `books` ON `books`.`category_id` = `categories`.`id

have_book_categories.pluck(:id)
[1, 3]

preload 會拆成兩個 query,一個主 Model 的查詢跟一個 Association Model 的查詢,因為分成兩個查詢,在 ORM 中無法直接用 where 過濾 Association Model 的條件。

categories = Category.preload(:books).to_a

# SELECT `categories`.* FROM `categories`

# SELECT `books`.*
# FROM `books`
# WHERE `books`.`category_id` IN (1, 2, 3)

eager_load 會用 left join (left outer join) 的方式預載需要的表

> categories = Cateogry::eager_load(:books).to_a
# SELECT `categories`.`id` AS t0_r0,
#        `categories`.`name` AS t0_r1,
#        `books`.`id` AS t1_r0,
#        `books`.`category_id` AS t1_r1,
#        `books`.`title` AS t1_r2,
# FROM `categories`
# LEFT OUTER JOIN `books` ON `books`.`category_id` = `categories`.`id`

categories.first.books.loaded?
true

所以我們可以接著 where 對 Association Model 下條件

categories = Cateogry::eager_load(:books).where(books: { title: 'etf' }).to_a
# SELECT `categories`.`id` AS t0_r0,
#        `categories`.`name` AS t0_r1,
#        `books`.`id` AS t1_r0,
#        `books`.`category_id` AS t1_r1,
#        `books`.`title` AS t1_r2,
# FROM `categories`
# LEFT OUTER JOIN `books` ON `books`.`category_id` = `categories`.# `id`
# WHERE `books`.`title` = 'etf'

categories.first.books.first.title == 'etf'
true

includes 會依使用的情境採取不同的方式 單獨使用與 preload query 相同

Category::includes(:books).to_a
# SELECT `categories`.* FROM `categories`
# SELECT `books`.*
# FROM `books`
# WHERE `books`.`category_id` IN (1, 2, 3)

若加上 references 一同使用則與 eager_load 相同 update: 後面若使用 rails ORM 規範的查詢方式, includes 會自動採用 eager_load 的方式(參考)

Category::includes(:books).references(:books).to_a
# SELECT `categories`.`id` AS t0_r0,
#        `categories`.`name` AS t0_r1,
#        `books`.`id` AS t1_r0,
#        `books`.`category_id` AS t1_r1,
#        `books`.`title` AS t1_r2,
# FROM `categories`
# LEFT OUTER JOIN `books` ON `books`.`category_id` = `categories`.`id`

# 總結

  • 使用 joins 用來 filter 查詢結果,可以和預載同時使用

  • preload 拆成兩個獨立查詢,無法對預載表做過濾 (etc. where)

  • eager_load 使用 left join 預載,所以同時可以下條件做過濾

  • includes 不加其他條件,預設會使用 preload

  • includes + reference 等同 eager_load

  • 用 eager_load 做預載時,可能會因為 join 的 table 行數過多造成效能問題,這時候可以嘗試用 preload 的方式,看能不能獲得比較好的效能

# 討論

ActiveRecord 預載的資料表,若在其後對預載表使用 where 做子查詢,並不會使用已載入的 Model Collection 做搜尋,而是會再下新的 Query 到資料庫查詢,這使用只能改用 Array 的 method 例如 select 來進行條件過濾。

在傳入方與接收方不同地方的情況,若接受方已經預期傳入方會先對資料做預載,而使用 select 做條件過濾,但今天其他工程師看到接收方使用 select 而不是 where 查詢是不是會感到困惑?大家都怎麼解決這個問題的?

# references

  • https://blog.arkency.com/2013/12/rails4-preloading/
  • https://guides.rubyonrails.org/active_record_querying.html#specifying-conditions-on-eager-loaded-associations