Load association data in rails
Bài đăng này đã không được cập nhật trong 8 năm
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
All rights reserved