###### tags: `Ruby on Rails 觀念`
# N+1 Queries 效能問題
當我們習慣rails框架帶來的方便性後,效能則是進一步要去考量的部分,同時也是面試的經典考題之一,對於新手來說可能很陌生,那是因為在小型的專案上並不會有太多效能上的考量,但是隨著專案規模越來越大,就不能忽視 N+1 queries 產生的問題。
## 一、什麼是 N+1 ?
通常Rails的開發者都會被調侃不懂 SQL ,那是因為ORM (Object-Relational Mapping)框架可以讓開發者以操作物件的方式來存取關聯式資料庫,省下不少開發時間,但偏偏 N+1 queries 就是 ORM 產生的問題。
我已下列例子作範例,一個是動物園名單的資料表 Zoo,另一個則是記錄動物種類的資料表 Animal,資料關係是一個動物園可以有許多動物在裡面
```rb=
# zoo.rb
class Zoo < ApplicationRecord
has_many :animals
end
```
```rb=
# animal.rb
class Animal < ApplicationRecord
belongs_to :zoo
end
```
```rb=
# animals_controller.rb
class Animal < ApplicationController
def index
@animals = Animal.all
end
end
# index.html.erb
<% @animals.each do |animal| %>
<%= animal.zoo.name %>
<% end %>
```
在終端機執行 `rails server` 可以查看到 SQL query :
```sql=
Animal Load (0.4ms) SELECT "animals".* FROM "animals"
Zoo Load (0.2ms) SELECT "zoos".* FROM "zoos" WHERE "zoos"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Zoo Load (0.2ms) SELECT "zoos".* FROM "zoos" WHERE "zoos"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Zoo Load (0.3ms) SELECT "zoos".* FROM "zoos" WHERE "zoos"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
Zoo Load (0.2ms) SELECT "zoos".* FROM "zoos" WHERE "zoos"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
```
第一列的`Animal Load SELECT "animals".* FROM "animals"` 白話語意是,「從 animals 表單中,找出全部 animals 的資料」,**第一列也就是 N+1 的 1 ,而下方接續產生的四列 SQL 指令就是 N**。
換句話說 1 筆是查Animal,另外 4 筆是一筆一筆去查 Zoo; 設想一下如果今天有全世界全部的動物在資料表中,同時也有多家動物園, N+1 queries 所帶來的效能損耗會有多驚人。
## 二、解決 N+1 問題的四個方法
### **1. includes**
**主要用於可以直接將相關連的資料,在同一筆查詢,一起撈出來,是最常見的解決方式**
```rb=
# animals_controller.rb
class Animal < ApplicationController
def index
@animals = Animal.includes(:zoos)
end
end
```
```sql=
Animal Load (103.7ms) SELECT "animals".* FROM "animals"
Zoo Load (32.1ms) SELECT "zoos".* FROM "zoos" WHERE "zoos"."id" IN (?, ?, ?, ?, ?)[["id", 1], ["id", 2], ["id", 3], ["id", 4]]
```
使用 includes 方式後, SQL query 就只有兩列,指令中 IN (?, ?, ?, ?, ?) 就是一次把 id 的資料撈出來,再進入 index.html.erb 檔案中執行就不需要再去資料庫撈一次,因為都已經先撈出來了。
### **2. preload**
**與 includes 方法相同,在大多數情況下使用 includes 將默認先使用 preload 方法**
當開發者在使用 includes 方法時,其實 Rails 會做自己的判斷,在內部使用 method :preload ,它同樣會產生兩列 SQL query ,第一列是主要資料表,第二列是關聯資料表,輸出結果等同上圖
```rb=
# animals_controller.rb
class Animal < ApplicationController
def index
@animals = Animal.preload(:zoos)
end
end
```
### **3. eager_load**
**使用 LEFT OUTER JOIN ,在一列 SQL 指令中載入全部關聯性資料**
當開發者在使用 includes 方法時,加入進階的判斷式後,例如 where 或是 order,SQL 會在內部使用 method :eager_load 作運算,所以我們也可以直接將 includes 直接替換 eager_load ,SQL query 輸出如下
```sql=
Zoo.eager_load(:animals)
SELECT "zoos"."id" AS t0_r0, "zoos"."name" AS t0_r1, ...
FROM "zoos" LEFT OUTER JOIN "animals" ON "animals"."zoo_id" = "zoos"."id"
```
### **4. joins**
**使用 INNER JOIN 方法查出全部關聯資料,但不會將關聯的資料拉出來**
```sql=
Zoo.joins(:animals)
SELECT "zoos".* FROM "zoos" INNER JOIN "animals" ON "animals"."zoo_id" = "zoos"."id"
```
joins 的方法與 includes 非常相似,差異在於 joins 查詢所有包含 zoo_id 的 animals ,並回傳該 animal 所屬的 zoo,但並不會將 animal 資料撈出來,只是去做比對,因此再進入 index.html.erb 檔案中執行時,還是會去資料庫中再撈出資料
## 三、總結
在一般操作上主要以 `includes`以及 `joins`為主,使用時機可以簡單區分為兩種,若是要處理大量資料建議使用 `includes`,因為它可以先將需要的資料一次查好,以避免 N+1 queries 的問題 ; 若只是想要篩選結果,或是查看關聯物件中的屬性時,建議使用 `joins`
## 四、參考資料
[Ruby on Rails - 用 Include 和 Join 避免 N+1 Query](https://mgleon08.github.io/blog/2016/01/10/ruby-on-rails-include-join-avoid-n-1-query/)
[A Visual Guide to Using :includes in Rails](https://engineering.gusto.com/a-visual-guide-to-using-includes-in-rails/)
[Rails ActiveRecord 效能優化(上):關聯查詢](https://medium.com/@jinghua.shih/rails-activerecord-%E6%95%88%E8%83%BD%E5%84%AA%E5%8C%96-%E4%B8%8A-%E9%97%9C%E8%81%AF%E6%9F%A5%E8%A9%A2-75ca79f510b3)