ActiveRecord N+ 1 問題和解法
ActiveRecord,一直是Rails工程師的好夥伴。
透過他的ORM性質,我們可以直接用Ruby的程式語言去DB拿資料,省去了很多直接寫SQL的麻煩。
@articles = Article.where(created_at: (Time.now - 1.day)..Time.now)
好用歸好用,但當我們沒有小心使用他的話會造成很多不必要的效能問題,也就是所謂的「N+1問題」。
這個問題因為很常發生,所以你隨便找都找得到相關文章,但也許你可以從我這邊得到一些不一樣的收穫。
TL;DR
N+1問題一句話來形容就是:
「做一兩次就可以完成的事情,卻多做了很多次」
「訂了外送飲料跟吸管,外送員本來跑一趟就可以送來所有東西,但是他只送了飲料來,才跑回去拿吸管來,跑了兩趟。」
只要解決這個問題,不僅API效能大增,更是大大減輕了DB的負擔。
真實情況呢?ActiveRecord中何時會發生?
假設我們的app有文章系列的功能,模型的關聯如下:
但今天我們想要在文章列表( articles#index
)中存取他的comments
,比如說我們要看每篇文章的最新留言:
看log你會發現,View裡每一次的迴圈都會去 Select comments:
FROM articles
SELECT * FROM comments WHERE comments.article_id = 1 order by id desc limit 1
SELECT * FROM comments WHERE comments.article_id = 2 order by id desc limit 1
SELECT * FROM comments WHERE comments.article_id = 3 order by id desc limit 1
SELECT * FROM comments WHERE comments.article_id = 4 order by id desc limit 1
SELECT * FROM comments WHERE comments.article_id = 5 order by id desc limit 1
SELECT * FROM comments WHERE comments.article_id = 6 order by id desc limit 1
SELECT * FROM comments WHERE comments.article_id = 7 order by id desc limit 1
SELECT * FROM comments WHERE comments.article_id = 50 order by id desc limit 1
...
我們可不希望人家看一次文章列表,資料庫就被玩壞了。這個故事的教訓就是:「一開始就要把東西準備好!不要分那麼多次拿東西」
那如何一開始就要把東西準備好?
ActiveRecord有想到說會有這種情況發生,所以他準備了includes
這個方法給你:
includes
是告訴ActiveRecord,在我們在要文章的時候,也要把他的評論載入進來。
這時回去瀏覽我們的文章列表,會發現ActiveRecord是透過額外多做一次搜尋,另外把評論載入進來。
SELECT * FROM articles
SELECT * FROM comments WHERE comments.article_id = in (1,2,3,4,5,6,7,8...,50)
那Joins呢?
joins
雖然會把關聯資料表合起來,但他並不會幫你載入關聯資源進來。
pry(main)> a = Article.joins(:user).all
Article Load (1.5ms) SELECT "articles".* FROM "articles" INNER
JOIN "users" ON "users"."id" = "articles"."user_id"pry(main)> a[0].user
User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
# 還是會去query一次
講白了他只是原生SQL的join語法,預設是 inner join
。一般使用 joins
都是為了搜尋關聯的條件。比如說:
pry(main)> Article.joins(:user).where(users: {name: "Harry Yuan"}) Article Load (17.9ms) SELECT "articles".* FROM "articles" INNER JOIN "users" ON "users"."id" = "articles"."user_id" WHERE "users"."name" = $1 [["name", "Harry Yuan"]]
Bullet: 我找不到哪有N+1
有個Gem叫做 Bullet
,可以抓到一些基本的N+1。有需要可以裝在development環境裡面來使用。
他提供的Option很多,最基本只要這兩個選項就可以開始抓了。
接著我們故意製造一個可怕的N+1,然後存取這個route:
我們回去看Log,就會發現他回報這隻有N+1問題:
N+1 可能藏在各處
Bullet甚至會告訴你要怎麼改,真的很外掛。不過千萬不要太依賴它,因為並不是每個N+1他都抓得到。
舉個例子,今天若我們的Service需要使用 ActiveModel::Serializer
你會發現可怕的Query,而且他沒有report。所以這裡的教訓就是:
- N+1可能藏在各處,不一定只發生在controller裡。
- 培養你的細心度,學會自己去抓比較重要。
搭配scope來讓includes更乾淨有效率
等等,先別走,難道我們真的一定得一次載入全部的東西嗎?
讓我們回到最一開始的例子:最新留言。我們一開始的最基本作法,是直接載入所有文章的留言:
@articles = Article.includes(:comments)
並且在view中呼叫 article.comments.last
來存取最新留言
<!-- article/index -->
<% @articles.each do |article| %>
<div class="title"><%=article.title%></div>
<div class="last-comment"><%=article.comments.last.content%></div>
<% end %>
你想一下就會發現說,這樣根本治標不治本。因為我們只需要最新的留言,若你的服務規模變大,要是評論很多的話這樣也是很沒效率。
這時候我們可以透過scope來幫助我們:
這邊我宣告了一個關聯,叫做last_comment。他其實也只是comment,但我透過額外的scope,讓他在includes的時候能讓結果更乾淨。如果我們改為includes這個關聯,結果如下:
@articles = Article.includes(:last_comment).limit(20)
Article Load (2.5ms) SELECT "articles".* FROM "articles" LIMIT 50 Comment Load (1.4ms) SELECT distinct on (article_id) * FROM "comments" WHERE "comments"."article_id" IN (6017, 6027, 6028, 6095, 6106, 6110, 6131, 6135, 6153, 6167, 6204, 6216, 6219, 6222, 6225, 6235, 6237, 6248, 6273, 6332) ORDER BY article_id desc, created_at desc
接著回到View,我們就可以改為存取每篇文章的last_comment
<!-- article/index -->
<% @articles.each do |article| %>
<div class="title"><%=article.title%></div>
<div class="last-comment"><%= article.last_comment.content %></div>
<% end %>
這樣是不是比較乾淨了呢?而且載入的留言數變少了。
加速API、減輕DB負擔,從解決N+1開始!有誤歡迎指正,若各位大大有其他想法也可多多指教。
最後
話說,你有用過GutenTag嗎?我發現他沒用好也可能會有N+1問題,可以參考我這篇(好啦我沒事不會再用英文寫了)