N+1 queries 问题, 使用includes方法,减少sql语句查询,提高性能
参考文章:
https://ithelp.ithome.com.tw/articles/10161667
https://ithelp.ithome.com.tw/articles/10161667
N+1 queries
N+1 queries是資料庫效能頭號殺手。ActiveRecord的Association功能很方便,所以很容易就寫出以下的程式:
# model
class User < ActieRecord::Base
has_one :car
end
class Car < ActiveRecord::Base
belongs_to :user
end
# your controller
def index
@users = User.page(params[:page])
end
# view
<% @users.each do |user| %>
<%= user.car.name %>
<% end %>
我們在View中讀取user.car.name
的值。但是這樣的程式導致了N+1 queries問題,假設User有10筆,這程式會產生出11筆Queries,一筆是查User,另外10筆是一筆一筆去查Car,嚴重拖慢效能。
SELECT * FROM `users` LIMIT 10 OFFSET 0
SELECT * FROM `cars` WHERE (`cars`.`user_id` = 1)
SELECT * FROM `cars` WHERE (`cars`.`user_id` = 2)
SELECT * FROM `cars` WHERE (`cars`.`user_id` = 3)
...
...
...
SELECT * FROM `cars` WHERE (`cars`.`user_id` = 10)
解決方法,加上includes
:
# your controller
def index
@users = User.includes(:car).page(params[:page])
end
如此SQL query就只有兩個,只用一個就撈出所有Cars資料。
SELECT * FROM `users` LIMIT 10 OFFSET 0
SELECT * FROM `cars` WHERE (`cars`.`user_id` IN('1','2','3','4','5','6','7','8','9','10'))
如果user
還有parts
零件的關聯資料想要一起撈出來,includes
也支援hash寫法:@users = User.includes(:car => :parts ).page(params[:page])
Rails當中要連結model之間的關係非常簡單,不過也因為由於建立關係是這樣的簡便,造成許多指令會在讀取資料庫時有記憶體的浪費。例如我們建立以下關係:
# Post
has_many :comments
# Comment
belongs_To :post
並在helper中寫下:
Comment.each do |comment|
comment.post.title
end
如果我們有很多個comment,就會產生非常多的資料庫查詢記錄:
每一筆查詢對資料庫的效能都是一種消耗,因此身為後端開發者,查詢資料庫的次數是越少越好。以上寫法讓我們在查詢post的title時都透過comment的關連去查詢,所以執行每一個comment時,都會查詢一次post,增加大量的資料庫查詢比數。這就是一般資料庫容易產生的N+1 query問題,意思是我們在迴圈當中大量查詢N筆資料,再加上開頭查詢的那1筆,稱為N+1。
為了避免在幾千筆資料查詢時大量消耗不必要的記憶體,Rails提供joins和include方法可以在第一次查詢時將所有我們需要的資料一次查完。
記住,只要從model把資料抓到controller之後,剩下的我們就可以自行處理。越少的find和where指令越好。
例如剛才的情況,可以用includes的方式解決:
comments = Comment.includes(:post)
Comment.each do |comment|
comment.post.title
end
使用這個include方法,會在載入comments時,就先把各項內容載入,解決剛剛N+1的狀況。include這種查詢方法稱為eager loading,先將需要的資料一次查好,避免未來其他
join和include的區別
雖然join和include的字義相像,在model中的用法也相像,但主要差別在於:
- join主要用於過濾model之間的關係,但對查詢筆數來說並無太大幫助
- include主要用於將大量資料在同一筆查詢內一次查好
以剛剛的post和comment為例:
comments = Comment.joins(:post)
# 回傳所有comment內含有post_id的項目
# 並不會同時查詢關連資料,所以剛剛的comment.post.title會產生新的查詢指令
comments = Comment.include(:post)
# 回傳所有comment
# 會查詢關連資料,因此查詢comment.post.title並不會產生新的查詢指令
以上是join用在belongs_to的用法,如果用在has_many,會有不同的狀況:
posts = Post.joins(:comments)
# 查詢全部含有post_id的comment,並回傳該comment所屬的post
# 如果有很多筆comment屬於同一個post,那會回傳大量相同的post,可用.uniq來刪除重複的項目
posts = Post.includes(:comments)
# 回傳所有post
# post和comment內容皆已在本次查詢,之後不產生額外的查詢筆數
不管是joins還是include,都可以搭配where來查詢符合條件的項目:
comments = Comment.joins(:post).where("title like ?", "my_title")
# 在1筆query內查詢所有post的title是"my_title"的項目
# 回傳符合的post所擁有的comment
有些時候也會遇上有趣的狀況:
posts = Post.includes(:comments).where(:comments => {:content => "hello"})
# 只要有comment的content為"hello",就回傳該post
posts.first.comments
# 只會回傳content是"hello"的comment
# 兩個指令只產生一筆查詢
posts = Post.joins(:comments).where(:comments => {:content => "hello"})
# 同上
posts.first.comments
# 回傳該post的所有comments,不管content為何
# 兩個指令產生兩筆查詢
以上狀況,可看出include比較能達到我們要的效果。記得每次寫code時都要注意是否會有N+1查詢次數的問題,利用join和include能夠節省許多記憶體的資源,達到更快的效率。