資料庫的操作是影響網站效能的重要因素,而在存取資料時幾乎一定會遇到 N+1 問題。本篇文章將簡要探討 includes 的使用時機,以及與其相似的 preload 和 eager_load 方法。此外,我們還會介紹在處理多表格操作時常用的 joins。


1. 造成 N+1 問題的原因

我們先想像一個情境,你正在開發商家的訂單系統,其中有兩個 Model,分別是CustomerOrderCustomer記載顧客資料,並且每個顧客擁有多筆訂單Order customerOrders

# customer.rb
class Customer < ApplicationRecord
  has_many :orders
end

# order.rb
class Order < ApplicationRecord
  belongs_to :customer
end

現在我們想要呈現4位顧客的個人資訊以及他們各自的所有訂單,你可能會這樣寫: fourCustomerOrders

Customer.limit(4).each{|customer| puts customer.orders}

Rails ORM 產生的 SQL 指令如下:

SELECT 'customers'.* FROM 'customers' LIMIT 4
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id'=1
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id'=2
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id'=3
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id'=4

從中我們可以觀察到總共產生了5個 SQL queries。首先,第一個查詢擷取了4位顧客的資料,然後又產生了額外的4個查詢來擷取每個顧客的所有訂單。這就是典型的N+1問題,這裡的 N 等於4。當需要擷取的顧客數量增加時,資料庫的效能就會顯著地下降。

2. 解決 N+1問題

在 Rails ActiveRecord 的官方手冊中提到我們可以使用:includes:preload:eager_load來解決 N+1 問題,其本質精神都是"eager load",透過一次性預先加載相關聯的資料來代替頻繁的向資料庫發送查詢指令。 實務上使用:includes即可代替:preload:eager_load,Rails會根據使用者的查詢方式產生與:preload:eager_load相同的 SQL 查詢指令,因此這裡會將三者放在一起討論。

2.1 使用 :includes 和 :preload

絕大多數情況下,:includes會使用與:preload相同的 SQL 查詢,最終產生2條queries:

  1. 存取主資料表的資料
  2. 加載關聯的數據
Customer.includes(:orders).limit(4).each{|customer| puts customer.orders}
SELECT 'customers'.* FROM 'customers' LIMIT 4
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id' IN (1, 2, 3, 4)

在這種情況下,ORM 指令可以從:includes代換成:preload,其效果是相同的:

Customer.preload(:orders).limit(4).each{|customer| puts customer.orders}

2.2 使用 :includes 和 :eager_load

當我們的查詢會附帶條件子句時(ex: whereorder),此時就會需要使用:eager_load:includes,而無法使用:preload了。與:preload不同,此種情境下的:includes:eager_load只會產生一條 SQL query,並且是透過 LEFT OUTER JOIN進行資料庫的操作。

Customer.includes(:orders).where(orders:{status: 'paid'}).limit(4).each{|customer| puts customer.orders}.references(:orders)
or
Customer.eager_load(:orders).where(orders:{status: 'paid'}).limit(4).each{|customer| puts customer.orders}

注意上述的:includes雖然與:eager_load基本相同,但:includes還必須額外加上references來強迫連結兩個資料表。

SELECT 'customers'.'id' AS t0_r0, 'customers'.'name' AS t0_r1, 'orders'.'id' AS t1_r0, 'orders'.'status' AS t1_r1
FROM 'customers'
LEFT OUTER JOIN 'orders'
ON 'orders'.'customer_id' = 'customers'.'id'
WHERE 'orders'.'status' = 'paid' AND 'customers'.'id' IN (1, 2, 3, 4)

可以觀察到:eager_load透過LEFT OUTER JOINCustomerOrder之間建立了一個中間資料表,用以建構模型的輸出。 customerLeftJoin

讀到到這裡,我們已經了解以上三個方法是如何解決典型的 N+1 問題了,其實也沒有想像中複雜,那接著可以討論另一個也很常提及的:joins了

3. How about :joins

Rails中的:joins是透過 SQL 的INNER JOIN來實作,可以將兩個表格依照條件進行連結,進而建立出一個新的中間資料表以提供使用者進一步分析。前面我們在闡述如何解決 N+1 問題時並沒有提到使用 :joins,原因是:joins不會真的將關聯的資料取出。即使使用:joins,在後續存取資料時,仍然可能導致 N+1 問題的發生。例如以下的程式碼:

Customer.joins(:orders).each{|customer| puts customer.orders}
SELECT 'customers'.* FROM 'customers' INNER JOIN 'orders' ON 'customers'.'id' = 'orders'.'customer_id'
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id' = 1
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id' = 2
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id' = 3
SELECT 'orders'.* FROM 'orders' WHERE 'orders'.'customer_id' = 4

可以看到 N+1 queries 還是發生了,並沒有因為使用了:joins而解決,因此還是要使用上述的:includes:preload:eager_load

使用 :joins 的時機

既然:joins不能解決 N+1 問題,那什麼時候才會用到這個方法呢?

這個問題沒有絕對的答案,不過常見的使用時機是:當我們只是想要篩選的結果,或是觀察多個表格間的性質時就可以使用:joins。由於:joins僅進行表格連接,而不會實際存取物件本身,所以它的執行效能通常比:includes等方法更高。以下就是一個使用例子:

# 計算四位顧客所有已付款(paid)訂單的數量
Customer.joins(:orders).where(orders: {status: 'paid'}).limit(4).count
SELECT COUNT(*)
FROM (
  SELECT 1 AS one
  FROM 'customers' INNER JOIN 'orders'
  ON 'orders'.'customer_id' = 'customers'.'id'
  WHERE 'orders'.'status' = 'paid' LIMIT 4
)

References: