12/08/2018, 14:30

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)

Image

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

Image

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.

Image

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)

Image

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.

0