Một vài lưu ý khi giải quyết vấn đề N+1 query trong rails
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ẽ ...
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.