Rails 用巢狀include和查表方式來避免 n+1 query


先前在用includes和joins避免N+1 query當中有提到,資料庫一直是Rails效能一大殺手,除了瀏覽器下載很肥的檔案會造成效能低落以外,第二個會讓網頁很慢的原因就是資料庫讀取太頻繁(當然如果被牆了那可能又是另一個原因...)

以下介紹兩種稍微複雜一點的情況,使用不同的方法來避免 n + 1 query。

巢狀 include (nested include)

如果我們遇到非常複雜的table結構,關連得非常遙遠,例如:

class Post < ActiveRecord::Base
  has_many :comments
  belongs_to :user
end

class Comment < ActiveRecord::Base
  has_many :replies
  belongs_to :user
end

class Reply < ActiveRecord::Base
  belongs_to :user
end

class User < ActiveRecord::Base
  has_many :posts
  has_many :comments
  has_many :replies
end

這邊有兩種關連

  1. Post > Comment > Reply (就像是Facebook可以針對回覆還可以進行回覆的結構)
  2. User 擁有所有model

假如在某個畫面中,我們需要所有資料欄位,那include的方式可能較為複雜,會寫成巢狀結構。

# 不包含user
Post.includes(:comments => [:replies])

# 包含user
Post.includes(:user, :comments= > [:user, {:replies => [:user]}])

以上的寫法中,要注意三個地方:

1. 分開include

User這樣的model與三個其他model都有關連,就必須分開include。

Post.includes(:user, :comments)
# 錯誤,只有Post有include User,而Comment沒有include User

Post.includes(:user, :comments => [:user])
# 正確,皆include User資料

這樣例如分開來查Comment.first.userPost.first.user才會真正利用到eager_load的機制。

2. Hash和Array寫法

如果巢狀只有一層關連,則使用Array來撰寫,例如

Post.includes(:comments => [:replies])

但如果底下還包含了其他model,則就要用Hash來撰寫,例如

Post.includes(:comments => {:replies => [:user]})

3. 順序

包含其他model的巢狀結構要寫在最後方,例如以下寫法就會顯示錯誤。

Post.includes(:comments => [:replies], :user)
# => SyntaxError

這樣一來,查詢時就會將所有相關資料都include進去,避免實際使用時還到資料庫查詢。

用查表方式減少query

這種方式並非Rails建議查詢table的方法,但總是在退無可退的時候相當好用。例如我們在mysql的某個table中有一千萬筆資料,而且是用複合key的方式進行關連。

Post
 ID | user_id |     date     |   content
  1 |    2    |  2015-01-01  |     ...
  2 |    3    |  2015-02-05  |     ...
  3 |    4    |  2015-03-07  |     ...
  4 |    5    |  2015-08-10  |     ...

假如我們需要知道每一個user在特定時間所寫的內容為何,這樣會產生需要用複合key來查詢的狀況。我必須組合user_iddate為key,用來查詢特定user在特定date的所寫內容。

由於是針對每一個user來進行查詢,所以不管是用includesjoins都會產生不斷查詢資料庫的情況。因此,如果我們將所有的資料先抓出來,塞入一個hash當中,再用key來查詢,這樣的速度會比n+1的情況快上許多。

# 先將Post所有內容塞入hash這個物件當中,當做查詢總表
hash = Post.all.inject({}) do |result, post|
  key = (user_id.to_s + date.to_s).to_sym
  result.merge({ key => content })
end

# 針對特定user和date查詢
current_key = (current_user.id + current_date.to_s).to_sym
current_content = hash[current_key]

用這樣的查詢方式等於我們將資料庫的table整個搬到code裡面來,在第一步產生整包hash,接下來再查詢。剛開始從資料庫抓資料時會花一點時間,但接下來用hash來查詢的速度就會非常快。

注意,這樣的用法會造成與rails convention稍有不同,畢竟都提供這麼健全的table關連方法了,還硬要用查表的,維護上會稍微需要費一點心思。建議過渡期過後,還是將資料結構修改為與rails相符的結構。

這些都是敝人測試經驗,如有更好的寫法歡迎提供,謝謝!