ActiveRecord N+ 1 問題和解法

袁浩 Harry Yuan
8 min readNov 13, 2020

--

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問題:

Bullet回報N+1問題

N+1 可能藏在各處

Bullet甚至會告訴你要怎麼改,真的很外掛。不過千萬不要太依賴它,因為並不是每個N+1他都抓得到

舉個例子,今天若我們的Service需要使用 ActiveModel::Serializer

你會發現可怕的Query,而且他沒有report。所以這裡的教訓就是:

  1. N+1可能藏在各處,不一定只發生在controller裡。
  2. 培養你的細心度,學會自己去抓比較重要。

搭配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問題,可以參考我這篇(好啦我沒事不會再用英文寫了)

References

  1. https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
  2. https://scoutapm.com/blog/activerecord-includes-vs-joins-vs-preload-vs-eager_load-when-and-where
  3. 一些工作經驗

--

--

袁浩 Harry Yuan

Software Engineer | Ruby on Rails 喜歡學習前後端技術。希望文章白話到阿嬤都看得懂。