12/08/2018, 13:17

Load association data in rails

Load association data in rails Như các bạn đã biết, Rails cung cấp 4 cách khác nhau để load các dữ liệu có liên kết (dữ liệu liên kết qua các bảng). Preload, Eagerload, Includes và Joins là 4 cơ chế khác nhau để load các dữ liệu từ một bảng có quan hệ với một bảng khác (tôi tạm gọi là bảng ...

Load association data in rails

Như các bạn đã biết, Rails cung cấp 4 cách khác nhau để load các dữ liệu có liên kết (dữ liệu liên kết qua các bảng).

Preload, Eagerload, Includes và Joins là 4 cơ chế khác nhau để load các dữ liệu từ một bảng có quan hệ với một bảng khác (tôi tạm gọi là bảng nguồn và bảng mục tiêu). Trong bài viết này tôi sẽ xem xét từng cơ chế một.

Preload

preload sẽ load dữ liệu quan hệ thông qua các truy vấn tách biệt nhau.


Chẳng hạn bạn muốn load ra toàn bộ bài viết của một user thông qua cơ chế preload thì thứ tự truy vấn vào database sẽ như sau:


User.preload(:posts).to_a

# =>
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts"  WHERE "posts"."user_id" IN (1)


Chính vì lý do trên nên trong rails người ta ít khi dùng `preload`, `includes` là cơ chế load dữ liệu quan hệ mặc định của Rails.

Cũng chính vì preload luôn luôn thực thi các truy vấn táck biệt nên bạn cũng không thể sử dụng thêm bất kỳ điều kiện where nào để truy vấn vào bảng mục tiêu sau đó.

Ví dụ: Tôi muốn tìm ra các bài viết có chứa nội dung "Ruby on rails" trong tiêu đề của user trên.

User.preload(:posts).where("posts.title='Ruby on rails'")

Một error sẽ được bắn ra:

# =>
SQLite3::SQLException: no such column: posts.title:
SELECT "users".* FROM "users"  WHERE (posts.title='Ruby on rails')

Bạn chỉ có thể truy vấn vào các thuộc tính của bảng nguồn:

User.preload(:posts).where("users.name='Neeraj'")

# =>
SELECT "users".* FROM "users"  WHERE (users.name='Neeraj')
SELECT "posts".* FROM "posts"  WHERE "posts"."user_id" IN (3)

Includes

Includes load dữ liệu thông qua các truy vấn riêng biệt giống như preload

Tuy nhiên, Includes thông mình hơn preload. Theo ví dụ ở trên ta công thể thực hiện thêm bất cứ truy vấn where nào cho thuộc tính ở bảng mục tiêu nữa User.preload(:posts).where("posts.title='Ruby on rails'"), Hãy thử với includes nhé:

User.includes(:posts).where('posts.desc = "ruby is awesome"').to_a

# =>
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "posts"."id" AS t1_r0,
       "posts"."title" AS t1_r1,
       "posts"."user_id" AS t1_r2, "posts"."desc" AS t1_r3
FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"
WHERE (posts.desc = "ruby is awesome")


Như bạn đã thấy, Includes đã chuyển từ việc sử dụng hai query thành việc sử dụng một câu truy vấn đơn sử dụng LEFL OUTER JOIN để lấy dữ liệu. Và nó có thể cung cấp các điều kiện đi kèm.

Như vậy, Includes thay đổi từ hai truy vấn thành một truy vấn duy nhất trong một số trường hợp. Mặc định các trường hợp thông thường thì sẽ tạo hai truy vấn, Trong trường hợp bạn mong muốn thực hiện chỉ một truy vấn, Hãy nói cho Rails biết thông qua references :

User.includes(:posts).references(:posts).to_a

# =>
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "posts"."id" AS t1_r0,
       "posts"."title" AS t1_r1,
       "posts"."user_id" AS t1_r2, "posts"."desc" AS t1_r3
FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"

Ở ví dụ trên, Một truy vấn đã được thực hiện.

Eager Load

Eager load sẽ tải toàn bộ dữ liệu quan hệ trong một truy vấn duy nhất sử dụng LEFT OUTER JOIN


User.eager_load(:posts).to_a

# =>
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "posts"."id" AS t1_r0,
       "posts"."title" AS t1_r1, "posts"."user_id" AS t1_r2, "posts"."desc" AS t1_r3
FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"


Đây chính xác là những gì includes làm khi nó thực hiện một truy vấn đơn duy nhất kèm theo mệnh đề where hoặc order trên bảng mục tiêu (bảng posts).


Joins

Joins sẽ tải toàn bộ dữ liệu của bảng quan hệ sử dụng INNER JOIN.

User.joins(:posts)

# =>
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id"

Câu truy vấn trên có thể trả về dữ liệu với một số records bị trùng lặp. Để thấy được điều đó tôi sẽ tạo một số dữ liệu mẫu cho các bảng:


def self.setup
  User.delete_all
  Post.delete_all

  u = User.create name: Quan
  u.posts.create! title: 'ruby', desc: 'ruby is awesome'
  u.posts.create! title: 'rails', desc: 'rails is awesome'
  u.posts.create! title: 'JavaScript', desc: 'JavaScript is awesome'

  u = User.create name: Hoang
  u.posts.create! title: 'JavaScript', desc: 'Javascript is awesome'

  u = User.create name: HoangQuan
end

Với dữ liệu trên kết quả sẽ là:

#<User id: 9, name: "Quan">
#<User id: 9, name: "Quan">
#<User id: 9, name: "Quan">
#<User id: 10, name: "Hoang">

Để tránh việc duplication dữ liệu hãy sử dụng distinct

User.joins(:posts).select('distinct users.*').to_a

Nếu bạn muốn sử dụng một số thuộc tính của bảng posts hãy select chúng"

records = User.joins(:posts).select('distinct users.*, posts.title as posts_title').to_a
records.each do |user|
  puts user.name
  puts user.posts_title
end


Chú ý rằng thay vì sử dụng select trong joins bạn sử dụng user.posts đồng nghĩa với việc bạn đã thực hiện một truy vấn khác vào databases. Vấn đề này khá quan trọng đối với hiệu xuất của dự án - Vấn đề N + 1 queries.

Tôi sẽ lấy ví dụ cụ thể để giải thích vấn đề này:

Tôi sẽ tạo ra một số bảng và biểu diễn mối quan hệ giữa chúng như sau:

# 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

Thông thường chúng ta sẽ làm như sau để lấy ra bài viết của một danh sách các tác giả:

# users_controller.rb
@users = User.limit(10)

# Views

@users.each  do |user|
	user.name
	user.posts.count ...
end

Điều này gây ra vấn đề N +1 queries (1 truy vấn cho find 10 users + 10 truy vấn để load số lượng bài posts của user).

Điều này sẽ làm chậm hệ thống của bạn như thế nào?

Hãy cùng tôi kiểm tra nó:

Tạo một số dữ liệu mẫu:

# db/seeds.rb

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

trên Views thông thường chúng ta sẽ làm như sau:

<h1>All Posts</h1>

<% @posts.each do |post| %>
	<ul>
		<li><%= post.title %></li>
		<li><%= post.user.name %></li>
		<ul>
			<% post.comments.each do |comment| %>
				<li><%= comment.message %></li>
			<%end%>
		</ul>
	</ul>
<%end%>

Kết quả là tải trang này với hàng trăm queries và mất khoảng 2000ms.

Như tôi đã đề cập ở trên, để tránh N + 1 queries chúng ta hãy dùng thử Includes:

@posts = Post.includes(:user, comments: :user)

Bây giờ số queries đã giảm xuống còn 5 queries và thời gian tải trang là 500ms. nhanh hơn gấp 4 lần rồi đúng không?

Chỉ với một thao tác khá đơn giản nhưng hiệu quả tương đối lớn.

Cảm ơn đã đọc bài viết.

Source code: https://github.com/HoangQuan/load-association-data-in-rails.git

0