Eager loading and memory issue, why not solve both?
Bài đăng này đã không được cập nhật trong 2 năm
Giả sử có một app với những model như sau
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user
has_many :comments
end
class Comment < ApplicationRecord
belongs_to :post
enum status: {
approved: 1,
pending: 2
}
end
Với schema như sau:
ActiveRecord::Schema.define(version: 20170403031124) do
create_table "comments", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t|
t.integer "post_id"
t.integer "status"
t.string "body"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id"], name: "index_comments_on_post_id", using: :btree
end
create_table "posts", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t|
t.integer "user_id"
t.string "title"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_posts_on_user_id", using: :btree
end
create_table "users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_foreign_key "comments", "posts"
add_foreign_key "posts", "users"
end
Ở màn hình danh sách Post, mình muốn hiển thị tên User sở hữu bài Post đó, đồng thời số lượng comments của mỗi Post:
# class PostsController < ApplicationController
def index
@posts = Post.page(params[:page]).per(5)
end
<% @posts.each do |post| %>
<tr>
<td><%= post.user.name %></td>
<td><%= post.title %></td>
<td><%= post.comments.length %></td>
</tr>
<% end %>
Điều này gây ra một hiện tượng được gọi là N+1 query. Để rõ hơn ta để ý server log:
Processing by PostsController#index as HTML
Rendering posts/index.html.erb within layouts/application
Post Load (0.4ms) SELECT `posts`.* FROM `posts` LIMIT 5
User Load (0.4ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
Comment Load (0.4ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 1
CACHE (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 [["id", 1], ["LIMIT", 1]]
Comment Load (0.5ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 2
CACHE (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 [["id", 1], ["LIMIT", 1]]
Comment Load (0.4ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 3
CACHE (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 [["id", 1], ["LIMIT", 1]]
Comment Load (0.4ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 4
CACHE (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 [["id", 1], ["LIMIT", 1]]
Comment Load (0.4ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 5
Rendered posts/index.html.erb within layouts/application (49.9ms)
Completed 200 OK in 70ms (Views: 65.0ms | ActiveRecord: 3.7ms)
Để lấy được thông tin của User, với mỗi Post, ta đã cần phải tạo ra một query riêng. Mặc dù có thể thấy SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
được cached nên không tốn thời gian khi execute ở 4 lần sau.
Tuy nhiên sự may mắn này không lặp lại với câu query để lấy thông tin số lượng comment mỗi bài Post.
Basic Eager loading
Chắc mọi người ai cũng biết trường hợp này phải dùng includes
rồi. Tuy nhiên để bài viết được trọn vẹn mình vẫn sẽ giới thiệu lại.
Trước tiên mình sẽ cài đặt 1 gem tên là bullet
trên môi trường development
. Gem này có tác dụng:
Help to kill N+1 queries and unused eager loading
Config cho nó ở development.rb
:
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.rails_logger = true
end
Khi đó bullet
sẽ tự động phát hiện và cảnh báo ta khi có dấu hiệu của N+1 query
Cụ thể là ta sẽ giải quyết vấn đề này như sau, từng bước một nhé, đầu tiên là thằng User, ở PostsController:
# class PostsController < ApplicationController
def index
@posts = Post.page(params[:page]).per(5)
@posts = @posts.includes(:user)
end
Processing by PostsController#index as HTML
Rendering posts/index.html.erb within layouts/application
Post Load (0.2ms) SELECT `posts`.* FROM `posts` LIMIT 5 OFFSET 0
User Load (0.4ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1
Comment Load (0.5ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 1
Comment Load (0.7ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 2
Comment Load (0.4ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 3
Comment Load (0.3ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 4
Comment Load (0.3ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` = 5
Rendered posts/index.html.erb within layouts/application (67.5ms)
Completed 200 OK in 95ms (Views: 85.8ms | ActiveRecord: 5.7ms)
Có thể thấy rõ là chỉ có 1 câu query cho User. Sau đó object user này đã được lưu vào memory để sử dụng ở những lần sau.
Tiếp theo là đến lượt Comment:
# class PostsController < ApplicationController
def index
@posts = Post.page(params[:page]).per(5)
@posts = @posts.includes(:user, :comments)
end
Processing by PostsController#index as HTML
Rendering posts/index.html.erb within layouts/application
Post Load (0.3ms) SELECT `posts`.* FROM `posts` LIMIT 5 OFFSET 0
User Load (0.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1
Comment Load (0.5ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` IN (1, 2, 3, 4, 5)
Rendered posts/index.html.erb within layouts/application (136.6ms)
Completed 200 OK in 470ms (Views: 451.5ms | ActiveRecord: 4.2ms)
Đây rõ là điều chúng ta mong đợi từ đầu. Chỉ 3 câu SQL thôi, no more.
NOTE: Nhớ là ta đã sử dụng hàm length
để đếm số lượng comments, điều gì diễn ra khi ta sử dụng các hàm như size
, count
. Kết quả như dự đoán là length
và size
không khác nhau:
Ta chỉ thấy sự khác nhau với
bullet
message thôi. Còn với count
thì đúng như bản chất, dù có includes
hay không thì cũng tạo ra 1 query để đếm.
Vì vậy trong những trường hợp tương tự mình recommend là nên dùng length
.
Memory Issue
Vậy là với includes
chúng đã rút bớt số lượng câu SQL cầu phải execute, tuy nhiên, hãy để ý đến câu SQL này:
Comment Load (0.5ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` IN (1, 2, 3, 4, 5)
Vấn đề là: giả sử mỗi post có trung bình 200 comments, như vậy với câu SQL này sẽ return 1000 rows từ DB. Tệ hơn nếu với mỗi comment body, bạn không giới hạn kích thước cho trường này và người dùng có thể viết gì tùy thích, dài như truyện Kiều và vẫn ok. Và Rails đã tạo ra 1000 Active Record objects trong memory bởi vì bạn bảo nó làm như vậy. Đó là vấn đề, bạn không cần đến 1000 objects trong memory, thực tế bạn chỉ cần count
mà thôi.
Counter cache
Bản chất của việc này đó là ta sẽ tạo 1 cột cho table posts
chỉ để lưu số lượng comments
. Cách mà cột này được cập nhật ta sẽ để Rails xử lý cho ta. Để rõ hơn có thể đọc thêm về counter cache
ở Guide của Rails
Ta thực hiện điều này như sau. Định nghĩa counter_cache
option bên phía belongs_to
, cột count_of_comments
là cột lưu thông tin về count
, cột này tồn tại bên phía has_many
, tức là posts
:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: :count_of_comments
enum status: {
approved: 1,
pending: 2
}
end
Sau đó ta thêm cột này vào table posts
bằng migration
:
# cột này để giá trị default = 0
rails g migration add_count_of_comments_to_posts count_of_comments:integer
rake db:migrate
Sau đó để cập nhật giá trị cho cột này ta mở rails console
Post.find_each { |post| Post.reset_counters(post.id, :comments) }
Ở controller ta không cần includes(:comments)
nữa, vì giá trị count của Comment giờ đã thành 1 thuộc tính của Post.
def index
@posts = Post.page(params[:page]).per(5)
@posts = @posts.includes(:user)
end
<% @posts.each do |post| %>
<tr>
<td><%= post.user.name %></td>
<td><%= post.title %></td>
<td><%= post.count_of_comments %></td>
<% end %>
Đếm theo điều kiện
Quay trở lại với model Comment
:
enum status: {
approved: 1,
pending: 2
}
Comment có 2 kiểu approved và pending, giờ ở màn hình Post
index ta muốn hiển thị số lượng comment tương ứng với hai trạng thái này thì sao? Tất nhiên là Rails với counter cache
không thể giúp ta làm việc đó (thực tế là có một vài cách, hay thử tìm hiểu điều này với keywords: rails counter cache edge cases
view caching
Russian doll view caching style
).
Để giải quyết vấn đề này theo một cách Rails friendly
nhất, theo mình là nên sử dụng hash
. Ta sẽ build 2 hash
chứa 2 thông tin mà ta cần. Build như thế nào?
Ở controller ta đang có:
@posts = Post.page(params[:page]).per(5)
@posts = @posts.includes(:comments)
Ta có thể bỏ includes
thay vào đó tạo ra 2 hash
. Active Record return hash
khi ta dùng group
@posts = Post.page(params[:page]).per(5)
post_ids = @posts.pluck :id
@pending_count_hash = Comment.pending.where(post_id: post_ids).group(:post_id).count
@approved_count_hash = Comment.approved.where(post_id: post_ids).group(:post_id).count
Sử dụng trong view
<% puts @approved_count_hash %>
<% @posts.each do |post| %>
<tr>
<td><%= post.user.name %></td>
<td><%= post.title %></td>
<td><%= @approved_count_hash[post.id] || 0 %></td>
<td><%= @pending_count_hash[post.id] || 0 %></td>
</tr>
<% end %>
Started GET "/posts" for 127.0.0.1 at 2017-04-04 08:38:25 +0700
Processing by PostsController#index as HTML
Post Load (0.3ms) SELECT `posts`.* FROM `posts` LIMIT 5 OFFSET 0
User Load (0.2ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1
(0.7ms) SELECT COUNT(*) AS count_all, `comments`.`post_id` AS comments_post_id FROM `comments` WHERE `comments`.`status` = 2 AND `comments`.`post_id` IN (1, 2, 3, 4, 5) GROUP BY `comments`.`post_id`
(0.6ms) SELECT COUNT(*) AS count_all, `comments`.`post_id` AS comments_post_id FROM `comments` WHERE `comments`.`status` = 1 AND `comments`.`post_id` IN (1, 2, 3, 4, 5) GROUP BY `comments`.`post_id`
Rendering posts/index.html.erb within layouts/application
{1=>38, 2=>46, 3=>14, 4=>16, 5=>42}
Rendered posts/index.html.erb within layouts/application (2.0ms)
Completed 200 OK in 29ms (Views: 22.4ms | ActiveRecord: 1.9ms)
Ta có 3 query, một để lấy Post, và 2 q còn lại để đếm số lượng. Và lưu 1 hash với size như vậy là đỡ tốn memory hơn nhiều so với việc lưu cả object.
Không chỉ là count
Khi đã hiểu ý tưởng rồi thì các sử dụng group
sẽ phong phú hơn nhiều. Thay vì .count
ta có thể lấy thông tin ta cần với select
( miễn là ta không lấy tất cả thông tin thì cách làm này đơn giản là tiết kiệm memory hơn cách lưu cả object )
@comment_names_hash = Comment.where(post_id: post_ids).select("name, avatar_url").group_by(&:post_ids)
Kết quả (1337 là id)
1337: [
{ name: "nguyen tuan minh", avatar_url: "https://http.cat/404.jpg" },
{ name: "tuan minh nguyen", avatar_url: "https://http.cat/451.jpg" }
]
Eager loading với điều kiện
All rights reserved