12/08/2018, 14:47

Eager Loading (nâng cao) trong Rails

Rails hỗ trợ các phương thức khác nhau (includes, preload, joins, etc.) sử dụng để load một lượng dữ liệu lớn và giảm thiểu số lần truy xuất vào database. Những cấu trúc cơ bản được sử dụng để tải trước một phần dữ liệu. Trong ví dụ, giả sử ứng dụng Rails gồm 3 models: posts, users, và comments. ...

Rails hỗ trợ các phương thức khác nhau (includes, preload, joins, etc.) sử dụng để load một lượng dữ liệu lớn và giảm thiểu số lần truy xuất vào database. Những cấu trúc cơ bản được sử dụng để tải trước một phần dữ liệu. Trong ví dụ, giả sử ứng dụng Rails gồm 3 models: posts, users, và comments.

rails g model user name:string
rails g model post user:references title:string body:text
rails g model comment user:references post:references message:string
rake db:migrate
# app/models/user.rb
class User < ActiveRecord::Base
  has_many :posts
  has_many :comments
end
# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :user
  has_many :comments
end
# app/models/comment.rb
class Comment < ActiveRecord::Base
  belongs_to :post
  belongs_to :user
end
require 'faker'

users = Array.new(80) do
  User.create(name: Faker::Name.name)
end

posts = Array.new(80) do
  Post.create(user: users.sample, title: Faker::Lorem.sentence, body: Faker::Lorem.paragraph)
end

128.times do
  users.each do |user|
    posts.each do |post|
      Comment.create(user: user, post: post, message: Faker::Lorem.sentence)
    end
  end
end

Bây giờ giả sử cần tạo một trang hiển thị post cùng một số comment:

rails generate controller posts

# config/routes.rb
Rails.application.routes.draw do
  root to: 'posts#index'
end
# app/controllers/posts_controller.rb
...
def index
  @posts = Post.all
end
...
- # app/views/posts/index.html.haml
- @posts.each do |post|
  = post.title
  = post.body
  = post.user.name
  - post.comments.each do |comment|
    = comment.message
    = comment.user.name

Để load được kết quả cho hàng trăm câu truy vấn này cần khoảng 2000ms. Vì vậy, với một số lượng khổng lồ các câu truy vấn, ta sẽ load trước những dữ liệu đi kèm post bằng cách sử dụng includes():

@posts = Post.includes(:user, comments: :user) Điều này đã làm giảm số lượng các câu truy vấn và thời gian tải giảm xuống còn 500ms. Ứng dụng của chúng ta có thể không cần hiển thị tất cả các comment, nhưng có lẽ cần 1 cặp 2 comment. Để làm được điều này ta cần đặt một limit(n) khi looping các comment, tuy nhiên như vậy sẽ phá vỡ nguyên tắc eager loading. Ngoài ra, để đặt limit, ta có thể sử dụng slice (dùng Ruby thay vì gọi SQL), nhưng điều đó vẫn thực hiện lưu toàn bộ dữ liệu comment vào bộ nhớ. Không sao, chúng ta còn lựa chọn thứ 3:

# app/models/comment.rb
class Comment < ActiveRecord::Base
  ...
  scope :recent, -> (count = 2) {
    subselect <<-SQL
    SELECT COUNT(*)
    FROM comments AS rcomments
    WHERE rcomments.post_id = comments.post_id AND rcomments.id > comments.id
    SQL
    where(":count > (#{subselect})").order(id: "DESC")
  }
end
# app/models/post.rb
class Post < ActiveRecord::Base
  ...
  has_many :recent_comments, -> { recent }, class_name: "Comment"
end

Hoán đổi comments cho recent_comments trong view và controller và thời gian load là 50ms. Đây là bước cải thiện performance 40x từ khi khởi tạo trang.

Cách làm trên hoạt động tốt với lượng dữ liệu nhỏ với các truy vấn SQL vào dữ liệu bậc hai. Khi bắt đầu post, sẽ có hàng nghìn comment chạy đến hơn 2000ms. Theo phân tích cho thấy, chỉ một số bổ sung đơn giản sẽ không cải thiện đáng kể hiệu suất. May thay, PostgreSQL (và nhiều cơ sở dữ liệu khác) hỗ trợ một phương pháp nhanh hơn nhiều bằng cách sử dụng "cửa sổ chức năng" (window functions):

scope :recent, -> (count = 2) { 
  rankings = "SELECT id, RANK() OVER(PARTITION BY post_id ORDER BY id DESC) rank FROM comments"

  joins("INNER JOIN (#{rankings}) rankings ON rankings.id = comments.id")
    .where("rankings.rank < :count", count: count.next)
    .order(id: "DESC")
}

Nguồn: https://ksylvest.com/posts/2014-12-20/advanced-eager-loading-in-rails

0