Một vài lưu ý khi giải quyết vấn đề N+1 query trong rails
Bài đăng này đã không được cập nhật trong 7 năm
Lời mở đầu
Khi xây dựng một trang web, đặc biệt là các trang web nhỏ, có số lượng dữ liệu nhỏ thì ta thường không chú ý nhiều đến cải thiện performance. Tuy nhiên khi làm việc với 1 ứng dụng lớn hơn, có lượng dữ liệu lớn thì nếu không tính toán kỹ, thiết kế và truy vẫn dữ liệu không hợp lý thì sẽ dẫn dễ tình trạng trang web của chúng ta chạy ì ạch. Đặc biệt vấn đề N+1 query trong rails là một vấn đề quá phổ biến mà hầu hết các lập trình viên có kinh nghiệm đều cố gắng làm mọi cách để loại bỏ chúng. Nhưng có một thực tế là không phải lúc nào bằng mọi giá giảm số lượng query cũng là tốt nhất. Ta hãy thử làm rõ vấn đề này trong bài viết và ví dụdưới đây.
Sử dụng includes để làm giảm số lượng query
Có nhiều cách để làm giảm số lượng câu query, trong đó có 1 cách phổ biến là sử dụng includes
. Include
sử dụng eager load với các bảng có quan hệ với model khác. Cụ thể ở đây là khi dùng includes, ta đã sử dụng preload hay left outer join tuỳ vào các trường hợp khác nhau.
Vậy khi nào ta nên dùng includes ?
Ta sẽ đi vài 1 ví dụ cụ thể. Giả sử user tạo nhiều post và user cũng có thể comment vào trong các post đó. Khi thiết kế quan hệ model, ta sẽ khai báo 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
Với khai báo quan hệ như trên, bây giờ nếu ta muốn lấy thông tin của user kèm theo cả post và comment thì ta sẽ gọi User.all
. Với câu lệnh trên, trước tiên ta sẽ get được user, sau đó là các bsif post của user đó, cuối cùng là các comment của từng post. Và nhẩm tính đơn giản, nếu có 10 user, mỗi user 5 post và mỗi post 2 comment thì khi gọi User.all
thì rails đã thực hiện tổng cộng 1 + 5 + 10 câu query. Trong controllers, nếu ta ko sử dụng includes thì function get dữ liệu đơn giản là
# users_controller.rb
def index
@users = User.all
render json: @users
end
Nếu sử dụng include để truy vấn thì câu lệnh như sau
@users = User.all.includes(:posts)
Ta có thể thấy số lượng câu query giảm đi đồng nghĩa với perframance tăng. Ta thấy được trước tiên user được load, sau đó trong subquery thì các post liên quan đến user đó được load,. NHư vậy số lượng query từ 1 + 5 + 10 giảm xuống còn 1 + 1 + 10. Tuy nhiên ta vẫn có thể giảm số câu query nếu như tiếp tục load các comment liên quan đến post theo cách trên. Và cuối cùng số câu truy vẫn chỉ còn lại là 1 + 1 + 1.
Ta sẽ xem ví dụ sau để xem cụ thể các câu query đc truy vẫn như thế nào
# users_controller.rb
def index
@users = User.all.includes(:posts => [:comments])
render json: @users
end
Ta có thể thấy là tổng cộng chỉ có 3 câu query, 1 là truy ấn cho user, 1 cho post và còn lại là của comment. Ở đây ta đã truyền comment vào trong 1 mảng để active record hiểu là sẽ load kiểu preload các comment thuộc post. ta cũng có thể thay các argument trong include như sau
User.all.includes(:posts => [:comments => [:another_relationship]])
ta có thể hiểu là another_relationship
sẽ được preload. Trong tất cả các câu truy vấn trên thì includes
đã sử dụng preload.
Lây dữ liệu post kèm điều kiện
Ví dụ ta có câu lệnh sau
User.all.includes(:posts => [:comments]).where('posts.title = ?', some_title)
Chạy câu lệnh trên, ta sẽ nhận được thông báo lỗi. Tuy nhiên nếu thay đổi câu lệnh trên thành
User.all.includes(:posts => [:comments]).where(posts: { title: some_title })
ta sẽ get được dữ liệu cần thiết do khí truỳen điều kiện truy vấn dưới dạng hash, left outer join của user và post sẽ được thực hiện để get về chính xác các user có post mà title trùng với điều kiện truy vấn.
Nếu ta ko muón truyền điều kiện truy vấn dưới dạng hash thì ta bắt buộc phải sử dụng thêm từ khoá reference
User.all.includes(:posts => [:comments]).where('posts.title = ?', some_title).references(:posts)
Như vậy ta đã giảm số câu lệnh truy vấn từ 1 + 5 + 10 xuống còn 1 + 1 + 1
Và ... giảm số query nhưng liệu có tốt không ?
Nhìn 2 ví dụ bên trên, thì có thể thấy mỗi câu query đều dài đến 3,4 dòng, chưa kể còn một cơ số các câu sub query, đôi khi còn xảy ra việc join không cần thiết, làm giảm nghiêm trọng performance và bộ nhớ.. Do đó trong nhiều truowngf hợp, ta ko cần chăm chăm giảm bằng được số lượng câu query, nên để một vài câu query nhỏ thay vì 1 câu query dài dằng dặc. Như ở trên ta đã sử dụng join với references
để có thể truyền điều kiện truy vấn dưới dạng string. Tuy nhiên theo active record thì nên join bằng includes
hơn là references
. Vì thế câu lệnh bên trên ta có thể thay bằng
User.all.joins(:posts).where('posts.title = ? ', some_title).includes(:posts => [:comments])
Kết luận
Có thể nói, sử dụng eager load để hạn chế N+1 query là một phương pháp vô cùng hữu ích tuy nhiên cũng có trường hợp nó lại phản tác dụng, ko những không giảm performance mà còn ngược lại. Điều này thực sự là khá lạ lẫm tuy nhiên coder chúng ta cũng nên lưu ý để cân nhắc để áp dụng vào các dự án sau này.
All rights reserved