1 minute read

上一篇文章提到了幾種不小心造成的 N+1 情況,這個章節也會提到關於 N+1 效能的問題。

count 、size 和 length 有什麼差別?

count 方法是對資料庫送出一次 COUNT 的 SQL 查詢。

如果我呼叫了兩次,他就會產生兩筆的查詢

post = Post.first

post.comments.count
# Comment Count (0.6ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1  [["post_id", 1]]
post.comments.count
# Comment Count (0.3ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1  [["post_id", 1]]

在 size 與 length 一樣是用陣列的方法計算物件裡面元素的數量,因為物件已經在記憶體了,因此不需要再發出 COUNT 的 SQL 查詢。

post = Post.first

post.comments.load
# Comment Load (0.5ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1  [["post_id", 1]]
post.comments.size
# (no query)

在沒有加載 preload 的情況下,size 就會和 count 一樣執行一次 COUNT 的 SQL 查詢。

post = Post.first

post.comments.size
# Comment Count (0.4ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1  [["post_id", 1]]

在避免 N+1 的同時也可能導致效能下降

這邊我們舉一個例子,想要看到每一篇文章的按讚次數,這邊使用 count 也沒有 preload,一看就知道產生了N+1

posts.each do |post|
  post.likes.count # n+1 queries
end

接著我們先 preload,然後使用 size 來計算按讚次數,順利解決 n+1 的問題,但可能會引入效能問題。

view
posts.preload(:likes).each do |post|
 post.likes.size
end

#  Post Load (2.4ms)  SELECT "posts".* FROM "posts"
#  Like Load (19.9ms)  SELECT "likes".* FROM "likes" WHERE "likes"."post_id" IN

解決方案

對這些文章的 “likes” 使用 group 搭配 count 查詢。這個查詢的目的是取得每篇文章的 “likes” 數量,而不是實際的 likes 資訊,實際的好處如下面所說的:

  • 較少的資料從資料庫中取得,特別是在有大量 likes 的情境下。
  • 只有所需的資訊被加載,不需要多餘的資料。
  • 由於不需要將所有 “likes” 載入記憶體,所以對於資源使用效率更高。
posts = Post.limit(1000)
likes = Like.where(post_id: posts)
counts = likes.group(:post_id).count

view
posts.each do |post|
  puts "Post: #{post.id}, likes: #{counts[post.id] || 0}"
end

# (9.5ms)  SELECT COUNT(*) AS count_all, "likes"."post_id" AS likes_post_id FROM "likes" WHERE "likes"."post_id" IN (SELECT "posts"."id" FROM "posts") GROUP BY "likes"."post_id"

我們也可以使用 joins 來完成這件事,來撈出所需要的資訊即可,這應該是比較常見的組合技。

posts = Post.limit(1000)
counts = posts.joins(:likes).group("posts.id").count("likes.id")

# view

posts.each do |post|
   puts "Post: #{post.id}, likes: #{counts[post.id] || 0}"
end

# (9.3ms)  SELECT COUNT(likes.id) AS count_likes_id, posts.id AS posts_id FROM "posts" INNER JOIN "likes" ON "likes"."post_id" = "posts"."id" GROUP BY posts.id

這時候又能夠發現一件事情 joins 只會返回有 likes 的文章,那麼我想知道有多少文章沒有被按讚怎麼辦? 我們可以改用 left_joins ,posts 表中的所有文章都會被載入,無論文章有沒有被按讚。

posts = Post.limit(1000)
counts = posts.left_joins(:likes).group("posts.id").count("likes.id")

posts.each do |post|
  puts "Post: #{post.id}, likes: #{counts[post.id]}"
end

# (10.7ms)  SELECT COUNT(likes.id) AS count_likes_id, posts.id AS posts_id FROM "posts" LEFT OUTER JOIN "likes" ON "likes"."post_id" = "posts"."id" GROUP BY posts.id

下一篇我們會提到使用工具幫忙找出隱藏的 N+1 ~