[Rails 效能優化] 資料庫關聯查詢

最近一次面試中討論到了一些 Rails 效能優化的問題,讓我驚覺以前還真的沒有仔細思考過當程式碼放在一個高流量環境下會出現的狀況。剛好最近回顧到 ihower 的這篇文章,發現裡面提到了不少面試討論到的觀念,又剛好看到其他相關主題的文章,所以擇日不直撞日,就打鐵趁熱來整理一下。

原本只想寫一篇的,但現在看來會變成一系列文章了(我怎麼不意外呢)。總之,這篇會聚焦在討論從 N+1 queries 問題衍伸出來的 includes /preload /eager_load /joins 使用情境,還有用資料庫實際測速結果。

(下一篇:[Rails 效能優化] 資料庫索引 Database Indexing

效能問題

效能問題其實可以分成兩種,一種是完全沒有意識到抽象化工具、開發框架的效能盲點,而寫下了執行效能差勁的程式碼。另一種則是對現有程式的效能不滿意,研究如何最佳化,例如利用快取機制隔離執行速度較慢的高階程式,來大幅提昇執行效能。

摘錄自 Ruby on Rails 實戰聖經

這邊提到的兩種效能問題我自己是歸類為「程式碼之內」與「程式碼之外」。程式碼之內的問題就是自己的錯,也就是修改程式碼就可以改善的,程式碼之外的問題就是…不是自己的錯XD 所以要用像是快取的外部工具才能讓效能更上一層樓。以下討論的都是針對第一種問題的優化方式。

ActiveRecord 的小陷阱

ActiveRecord 提供了一個很方便的 ORM (Object Relational Mapping) 介面,把資料庫的操作抽象化,讓我們可以直接用 Ruby 語言下資料庫指令,經過介面轉換變成 SQL 語言去操作資料庫。但存取資料庫是一種相對緩慢的 I/O 操作,除了每一次 query 都會花時間,回傳結果的記憶體佔用問題也要留意。

N + 1 查詢

這大概是 Rails 最經典的問題了。Rails 的 association 機制讓我們可以輕輕鬆鬆的存取兩個有關連的物件,如下:

# model
class Speaker < ActiveRecord
has_many :talks
end
class Talk < ActiveRecord 
belongs_to :speaker
end

假設今天我們創建了兩個 model,Speaker & Talk,並且他們之間有一個多對一的關聯,那麼我們可以用 Speaker.first.talks 來找到 Talk 資料表 speaker_id == 1 的所有資料。單一例子沒有問題,問題出在我們很可能會自然而然地就寫出 Speaker.all.map{ |speaker| speaker.talks } 這種句子。

表面上,這個式子看起來很合理,但是去看實際 SQL query 的呼叫,我們會發現它除了使用了一個 query 來取出Speaker資料表中的資料之外,同時也在迭代每筆資料時,一邊呼叫 query 來從 Talk 資料表取出資料,於是這條式子總共會造成 N+1 (N 在這是 Author 資料數量) 個 query。前面提到,存取資料庫是一個緩慢的操作,所以 query 能免則免,當資料量大的時候,N+1 query 造成的效能問題會難以忽視。

最常見的解決方式是使用 ActiveRecord 提供的 includes 方法。將 Speaker.all.map{ |speaker| speaker.talks} 改成 Speaker.includes(:talks).map{ |speaker| speaker.talks } ,則發出的 query 總數會從 N+1 變成 2,一條存取 Speaker,一條存取 Talk

用了 includes 就一定比較快嗎?

好問題。其實除了includes,Rails 還提供了 preloadeager_loadjoins 三種結合查詢的方法。我們來一個一個看。

joins 使用情境

:joins 使用 SQL 的 INNER JOIN 方法,不會真的把關聯的資料取出來。如果你只是想要篩選結果,或是觀察關聯物件的某些屬性質,那麼使用 :joins 是最有效率的,不會殺雞用到牛刀。不過要注意,如果你想要做的事是存取關聯物件本身,那麼 :joins 還是會造成 N+1 問題。

https://gist.github.com/jenny-codes/26d2e5ffa61798ad3ef92b5916a577e0

preload 使用情境

使用includes時,Rails 其實會在背地裡根據你的呼叫情境產生不同的 SQL 語法。這篇文章解釋得不錯。preloadincludes 默認的 query 生產方式,會產生兩條 query:一條存取主要資料表,一條加載關聯數據。

大部分的時候,可以直接把兩者代換,如下面兩個指令等價。

# where 'includes' and 'preload' are interchangeable:
Speaker.includes(:talks).map { |speaker| speaker.talks }
Speaker.preload(:talks).map { |speaker| speaker.talks }

eager_load 使用情境

eager_load使用 SQL 的 LEFT OUTER JOIN 方法,查詢的時候只會產生一個語句,直接加載所有的關聯數據。如果我們使用 includes用了進階篩選/排序(e.g. where & sort),那麼也可以使用 eager_load 替換:

# where 'includes' can be replaced by 'eager_load' (with adjustment)
Speaker.includes(:talks).where( 'talks.topic = "coding"' ).references(:talks)
Speaker.eager_load(:talks).where( 'talks.topic = "coding"' )

includes, preload 與 eager_load 實際上產生的 queries:

https://gist.github.com/jenny-codes/ffc3f177e8de163e81af9c488e8cfd45

所以說誰比較快?

其實這沒有一定的答案,端看你的資料筆數與查詢語句複雜程度。Rails 判斷 :includes 要分給 :preload 還是 :eager_load 也不是依照實際運行的效率。不管怎麼樣,我們來使用 benchmark-ips 來測看看。我用來測試的資料庫裡面有兩張資料表 SpeakerTalk,剛好就是這篇文章舉的例子(所謂深謀遠慮),是一對多的關係。他們各自的資料筆數是 1873 與 1576。

這是我的程式碼:

https://gist.github.com/jenny-codes/4cc9cb1747f3a2ce2f393282fdaf2ffc

測試三次,這是其中一次結果:

preload vs eager_load vs includes vs joins
  1. 基本上,放 :joins 進來比其實不公平,這邊只是想要強調如果有用 :joins 就可以處理好的情況,那就盡量用 :joins
  2. 三次測試中,速度由到快慢的順序都是 joins >> preload ~= includes > eager_load。中間的 preload ~= includes 非常合理因為在這種情況下,:includes 跟 :preload 的行為一樣。 如果再排除掉 :joins,那麼這次測試的結果是 :preload 的速度會比 :eager_load快一點。
  3. 網路上也可以找到其他人的測試結果,比如這篇這篇

後記

其實本來自己比較想研究的問題是資料庫 transaction 還有 counter cahce, N+1 的問題我只是想要輕鬆帶過、快速交待,但是資料一查下去,就…變成…一整篇…文章…了。不說了。不過反正每次寫文章,收穫最大的總是自己,像是暸解 includes與它的好朋友們的差異其實真的很實用。

雖然已盡能力所及地確保資料的正確性,但我恐怕還是會有不對/不精確的觀念或用字,如果願意指正我的話我會非常感激!

Random Gems 一些不太相關的資源

那天發現了 fast-ruby 這個解了我很多心中疑惑、令人讚嘆的 project。之後來發文介紹!

參考資料

Ruby on Rails 實戰聖經 | 網站效能
ihower.tw
Benchmarking Ruby code
blog.appsignal.com
Preload、 Eagerload、 Includes 和 Joins · Ruby China
ruby-china.org
Making sense of ActiveRecord joins, includes, preload, and eager_load | Scout APM Blog
scoutapp.com
A Visual Guide to Using :includes in Rails
medium.com
evanphx/benchmark-ips
github.com

文章同步發表於 Medium


  • Find me at