N + 1 Khi nhiều queries hơn là một điều tốt

Tuần trước, tôi có tìm hiểu về eager loading hoạt động trong Rails application giúp loại bỏ vấn đề N + 1 queries trong câu lệnh SQL bằng cách giamr thiểu số lượng queries. Giả thuyết ban đầu của tôi là giảm thiểu các câu queries, nhưng tôi rất bất ngờ vì những gì mà tôi đã phát hiện ra.

Sử dụng includes để giảm các câu queries

Một ví dụ điển hình nhất để bạn loại bỏ N + 1 là sử dụng method includes để giải quyết vấn đề. includes sử dụng để load nhanh các association quan hệ giữa các model và giảm thiểu các queries nhất có thể. Trong trường hợp này, nó sử dụng preload left outer join, phụ thuộc vào tình huống. Tôi sẽ giải thích thông qua các ví dụ bên dưới.

Khi nào nên và làm thế nào để sử dụng includes

Giả sử ta có các bảng, user có thể có nhiều posts, và có thể comment trong các post, mỗi post lại có thể có nhiều comment. Ta thiết lập được mối quan hệ giữa các bảng như sau:

# models/users.rb
class User < ApplicationRecord
  has_many :posts
  has_many :comments
end

# models/posts.rb
class Post < ApplicationRecord
  has_many :comments
  belongs_to :user
end

# models/comments.rb
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :post
end

Bây giờ, nếu ta muốn thông tin của user, với các posts của user cùng với các comments, đơn giản là ta gọi User.all first load tất cả users. Sau đó là fetch tất cả các posts của các users đó. Sau khi fetch được các posts, tôi sẽ fetch các comments tạo bởi user từ các posts đó. Nếu ta có 10 users, mỗi user 5 posts, và mỗi post có trung bình 2 comments, thì dòng lệnh User.all sẽ có 1 + 5 + 10 queries.

# users_controller.rb
def index
  @users = User.all
  render json: @users
end

Một giải pháp đơn giản được đưa ra là sử dụng includes để fetch users và tất cả các posts có quan hệ,

@users = User.all.includes(:posts)

alt

posts được preload, comments thì không.

Trên đây là dòng code có hiệu suất tốt hơn một chút, trước tiên là fetch users trước, sau đó là fetch các posts có quan hệ với các users đó. So sánh với số queries của dòng code trước đó có số queries là 1 + 5 + 10 thì bây giờ chỉ còn 1 + 1 + 10 . Nhưng sẽ là dòng code tốt hơn nếu comments có quan hệ với posts cũng được load trước, như thế, số lượng câu queries cũng sẽ giảm xuống, chỉ còn là 1 + 1 + 1. Tổng cộng là 3 câu queries để fetch tất cả data. Nhìn vào ví dụ dưới đây và bạn sẽ hiểu hơn:

# users_controller.rb
def index
  @users = User.all.includes(:posts => [:comments])
  render json: @users
end

alt

Có vẻ số lượng câu queries đã là tối thiểu, có vẻ là tốt nhất, nếu dựa trên tiêu chí số lượng câu queries của 1 dòng code. Tuy nhiên, điểm gây khó chịu ở đây là cách viết code khi fetch data. @users = User.all.includes(:posts => [:comments]) . Giả sử chúng ta có nhiều quan hệ được lồng vào hơn, ví dụ là một model belongs_to model comment mà chúng ta cần preload, ví dụ như:

User.all.includes(:posts => [:comments => [:another_relationship]])

Bằng cách này, tất cả các quan hệ nested có thể được preload.

Fetching post với các title đặc biệt

User.all.includes(:posts => [:comments]).where('posts.title = ?', some_title)

Câu lệnh trên sẽ gây lỗi, thế nhưng nếu viết:

User.all.includes(:posts => [:comments]).where(posts: {    title: some_title })

sẽ trả về kết quả mong đợi. Điều này xảy ra bởi vì điều kiện trong hash thỏa mãn, một left outer join của users và posts được thực hiện để fetch các posts có title cụ thể.

alt

Nhưng nếu ta sử dụng một chuỗi hoặc một mảng thuần khiết, thay vì sử dụng hash conditions để định nghĩa conditions bên trong các relations. Hãy xem ví dụ dưới đây:

User.all.includes(:posts => [:comments]).where('posts.title = ?', some_title).references(:posts)

Chú ý rằng phần references(:posts). references cho biết includes bị bắt buộc join với post bằng một left outer join. Để hiểu được điều này, bạn hãy xem qua các queries khi chạy dòng code trên:

alt

chỉ còn 1 query. That's greate!

Tuy nhiên, không phải lúc nào ít hơn cũng tốt

Hãy nhìn các queries trong các ví dụ cuối, các câu queries lên đến 3-4 dòng, thực sự là rất dài. và có các substring như là t0_r1, t0_r2, … , t2_r5. Điều đó thực sự không bình thường. Tuy rằng tôi không biết chính xác ý nghĩa của những substring đó, tuy nhiên, chúng được biết như là các CROSS JOIN or CARTESIAN JOIN.

Vì vậy, sử dụng references or hash condition để đinh nghĩa cho cho các quan hệ included có thể tạo ra các queries rất dài hoặc các phép join không thực sự cần thiết, ảnh hưởng đến hiệu suất và bộ nhớ xấu. Thay vào đó, tách một câu truy vấn đơn lớn thành một vài các câu truy vấn bé đôi khi sẽ có lợi hơn.

Tài liệu trong phần Active Record documentation nói rõ rằng, khi cần truy vấn với các association, bạn nên sử dụng join query với includes thay vì references.

Mặc dù Active Record cho phép bạn chỉ định để eager load các associations, giống như joins, phương pháp đề nghị là dùng joins để thay thế.

Một cách tốt hơn để định nghĩa điều kiện trong eager loaded associations:

User.all.joins(:posts).where('posts.title = ? ', some_title).includes(:posts => [:comments])

Tạo ra 1 + 1 + 1 câu queries, chỉ load users có post maching với điều kiện nhận được, có title như đã định nghĩa.

Kết luận

Eager load rất hữu ích và cải thiện hiệu năng ở mức độ lớn, tuy nhiên cũng có thể gây ra thiệt hại nghiêm trọng khi mà có quá nhiều các association lồng nhau được load. Tôi cho rằng sẽ có một chút ngạc nhiên khi bạn nghe thấy rằng có ít tác động vào database lại làm cho mọi thứ tồi tệ hơn, nhưng đúng là như vậy.

Hi vọng bạn thích bài viết này, và nó sẽ giúp ích cho bạn khi phải giải quyết N + 1.

bài viết tham khảo: https://www.sitepoint.com/n-1-when-more-queries-is-a-good-thing/