###### 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)