12/08/2018, 15:03

Eager loading and memory issue, why not solve both?

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 : { ...

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.

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.

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.

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 %>

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.

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" }
]
0