028.Joins, Preload, Eager load và Includes trong Rails.

Thach
Written by Thach on
028.Joins, Preload, Eager load và Includes trong Rails.

N+1 query là vấn đề muôn đời, cơ bản và chủ yếu khiến cho app của bạn chậm, bài viết này để nhắc lại cách sử dụng các câu lệnh join, preload, eager loadincludes để giải quyết N+1 trong Rails application.

1.Setup vấn đề

rails g scaffold Author email name
rails g scaffold Post title:string rating:float author:belongs_to
rails db:migrate
class Author < ActiveRecord::Base
  has_many :posts
end

class Post < ActiveRecord::Base
  belongs_to :author
end
nolan = Author.create!(name: "Nolan", email: "nolan@outlook.com")
rick = Author.create!(name: "Rick", email: "rick@gmail.com")
harry = Author.create!(name: "Harry", email: "harry@hotmail.com")

nolan.posts.create!(title: "Hello world", rating: 3.6)
nolan.posts.create!(title: "Clean code", rating: 4.1)
rick.posts.create!(title: "Hello world", rating: 3.8)
harry.posts.create!(title: "Rich dad, Poor dad", rating: 4.3)

Yes, N+1 xảy ra khi bạn cố lấy danh sách của author, và sau đó lấy list những posts của author đó. 1 query cho author, và N query cho posts.

Author.all.each do |author|
  author.posts.each do |post|
    puts post.title
  end
end
# Author Load (0.7ms)  SELECT "authors".* FROM "authors"
# Post Load (0.5ms)  SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = $1  [["author_id", 1]]
# Post Load (0.5ms)  SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = $1  [["author_id", 2]]
# Post Load (0.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = $1  [["author_id", 3]]

2.Preload

Cùng xem lại cách giải quyết N+1 bằng preload.

Author.preload(:posts).all.each do |author|
  author.posts.each do |post|
    puts post.title
  end
end
# Author Load (1.0ms)  SELECT "authors".* FROM "authors"
# Post Load (1.1ms)  SELECT "posts".* FROM "posts" WHERE "posts"."author_id" IN ($1, $2, $3)  [["author_id", 1], ["author_id", 2], ["author_id", 3]]

3. Eager load

So sánh với Eager load

Author.eager_load(:posts).all.each do |author|
  author.posts.each do |post|
    puts ''
  end
end
# SQL (1.5ms)  SELECT "authors"."id" AS t0_r0, "authors"."email" AS t0_r1, "authors"."name" AS t0_r2, "authors"."created_at" AS t0_r3, "authors"."updated_at" AS t0_r4, "posts"."id" AS t1_r0, "posts"."title" AS t1_r1, "posts"."title" AS t1_r2, "posts"."author_id" AS t1_r3, "posts"."created_at" AS t1_r4, "posts"."updated_at" AS t1_r5 FROM "authors" LEFT OUTER JOIN "posts" ON "posts"."author_id" = "authors"."id"

4. Includes

Như bạn thấy ở trên, eager load sẽ dùng LEFT OUTER JOIN để lấy dữ liệu ở bảng chính và bảng quan hệ. Còn preload thì cho ra 2 câu query.

Vậy còn includes có tác dụng gì?

Khi chúng ta dùng includes, chỉ đơn giản là chúng ta sẽ sử dụng preload hoặc eager load, nhưng Rails sẽ quyết định một cách linh động thay chúng ta. Với case ở trên, includes sẽ sử dụng preload.

Author.includes(:posts).all.each do |author|
  author.posts.each do |post|
    puts post.title
  end
end
# Author Load (121.1ms)  SELECT "authors".* FROM "authors"
# Post Load (1.5ms)  SELECT "posts".* FROM "posts" WHERE "posts"."author_id" IN ($1, $2, $3)  [["author_id", 1], ["author_id", 2], ["author_id", 3]]

Vậy khi nào thì includes sẽ dùng eager load? Là khi bảng quan hệ có thêm điều kiện, như là where hoặc order

Author.includes(:posts).where("posts.title = ?", "Hello world").references(:posts).each do |author|
  author.posts.each do |post|
    puts post.title
  end
end
# SQL (2.9ms)  SELECT "authors"."id" AS t0_r0, "authors"."email" AS t0_r1, "authors"."name" AS t0_r2, "authors"."created_at" AS t0_r3, "authors"."updated_at" AS t0_r4, "posts"."id" AS t1_r0, "posts"."title" AS t1_r1, "posts"."title" AS t1_r2, "posts"."author_id" AS t1_r3, "posts"."created_at" AS t1_r4, "posts"."updated_at" AS t1_r5 FROM "authors" LEFT OUTER JOIN "posts" ON "posts"."author_id" = "authors"."id" WHERE (posts.title = $1)  [[nil, "Hello world"]]

4.1. Associate with condition

Với LEFT OUTER JOIN, kết quả trả về sẽ là tất cả các những bài post thỏa mãn điều kiện, và những author của những bài post đó.

Vấn đề là nếu như chúng ta cần tìm những authorpost với title là “Hello world”, kèm theo tất cả những posts của họ thì phải làm sao?

Author.joins(:posts).where("posts.title = ?", "Hello world").preload(:posts).each do |author|
  author.posts.each do |post|
    puts post.title
  end
end
# Author Load (1.7ms)  SELECT "authors".* FROM "authors" INNER JOIN "posts" ON "posts"."author_id" = "authors"."id" WHERE (posts.title = $1)  [[nil, "Hello world"]]
# Post Load (0.4ms)  SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = $1  [["author_id", 1]]

Okay, nhưng mà khoan, còn nếu ngược lại, chúng ta lấy tất cả author, nhưng chỉ kèm theo những bài post có title là “Hello world”, thì sẽ ra sao?

Ở đây sinh ra một số giải pháp tạm thời.

class Author < ActiveRecord::Base
  has_many :post
  has_many :hello_world_posts, conditions: {title: "Hello world"}, class_name: "Post"
end

Author.includes(:hello_world_posts).all

hoặc là

Author.includes(:posts).references(:posts).where('posts.title' => ['Hello world', nil])

Nhưng cách đảm bảo hơn là sử dụng gem, như là N1Loader, Brick, activerecord_where_assoc.

Cái nào hay dở thì các bạn tự thử nhé.

5. Joins

Vâng, vậy thì còn joins thì để làm gì? Ờm, lúc này thì joins của Rails thực hiện đúng như joins của SQL, chỉ dùng để thêm điều kiện ở bảng quan hệ. Và đã không còn phần load dữ liệu ở bảng quan hệ vào bộ nhớ nữa.

Bài toán lúc này sẽ là “Tìm tất cả những tác giả có bài viết có title là ‘Hello world’”. Phần “kèm theo bài viết” đã được bỏ đi.

6. Một số lưu ý.

Từ rails 6, ưu tiên sử dụng hash thay cho string để mô tả các điều kiện trong where, và điều này khiến cho references trở nên khá phế.

# Before rails 6
Author.includes(:posts).where("posts.title = ?", "Hello world")
# SQLite3::SQLException: no such column: posts.title (ActiveRecord::StatementInvalid)
# no such column: posts.title (SQLite3::SQLException)


# After rails 6
# BAD
Author.includes(:posts).where("posts.title = ?", "Hello world").references(:posts)
# GOOD
Author.includes(:posts).where(posts: { title: "Hello world" })

preload cho tốc độ tốt hơn eager load, và mặc định includes sẽ ưu tiên sử dụng preload nên là thôi, cứ sử dụng includes cho khỏe.

Comments

comments powered by Disqus