12/08/2018, 13:01

Remove N+1 queries in your Ruby on Rails app

Xóa bỏ N+1 phép queries trong một ứng dụng Ruby on Rails Giới thiệu Có bao giờ bạn tự hỏi tại sao page của mình lại load một cách chậm chạp như vậy, trong khi chỉ là biểu diễn dữ liệu đơn giản hoặc một phép lặp dữ liệu. Câu trả lời có thể là bạn gặp vấn đề N+1 trong truy vấn làm cho web của ...

Xóa bỏ N+1 phép queries trong một ứng dụng Ruby on Rails

Giới thiệu

Có bao giờ bạn tự hỏi tại sao page của mình lại load một cách chậm chạp như vậy, trong khi chỉ là biểu diễn dữ liệu đơn giản hoặc một phép lặp dữ liệu. Câu trả lời có thể là bạn gặp vấn đề N+1 trong truy vấn làm cho web của bạn chậm đi một cách đáng kể như vậy. Vậy N+1 trong truy vấn mà chúng ta có thể đang gặp phải ở đây là gì vậy, liệu nó có thể khắc phục nhanh chóng và dễ dàng không, chúng ta hãy cùng đi tìm câu trả lời.

Cài đặt ví dụ

Trước hết chúng ta sẽ sử dụng cấu trúc dữ liệu @gist và seed data @gist

Vấn đề N+1

irb(main):001:0> Item.all.each { |item| item.category.title } Item Load (0.9ms) SELECT "items".* FROM "items" Category Load (0.1ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = ? LIMIT 1 [["id", 1]] Category Load (0.0ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = ? LIMIT 1 [["id", 1]] Category Load (0.0ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = ? LIMIT 1 [["id", 1]] Category Load (0.0ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = ? LIMIT 1 [["id", 2]] Category Load (0.0ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = ? LIMIT 1 [["id", 2]] Category Load (0.0ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = ? LIMIT 1 [["id", 2]]

Ví dụ này get tất cả các items, sau đó lặp mỗi records và cố gắng để lấy được các items từ database. Hãy tưởng tượng con số cỡ 50-100 items, khi đó category model sẽ phình to đáng kể.

Giải pháp chung

Một giải pháp chung là sử dụng các phương pháp eager loading :

Preload

là mặc định cho #include method - nó tạo ra hai hành động truy vấn , một cho query chính và một cho các dữ liệu liên quan. Nó có nghĩa là chúng ta không thể add.

#where({ categories: { title: "Fruits" } })

hệ thống sẽ sinh ra một lỗi.

irb(main):001:0> Item.preload(:category).all.each { |item| item.category.title }
  Item Load (0.9ms)  SELECT "items".* FROM "items"
  Category Load (0.1ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (1, 2)

Ví dụ sẽ load tất cả items với preloaded category:

includes

Thay vì dùng #preload, #includes lựa chọn thực hiện một số truy vấn dựa trên tình huống - nếu add quan hệ với mệnh đề #where. Điều này sẽ làm cho truy vấn phức tạp hơn

irb(main):001:0> Item.includes(:category).where(categories: { title: 'Fruits' }).each { |item| item.category.title }
  SQL (0.2ms)  SELECT "items"."id" AS t0_r0, "items"."title" AS t0_r1, "items"."description" AS t0_r2, "items"."price" AS t0_r3, "items"."category_id" AS t0_r4, "categories"."id" AS t1_r0, "categories"."title" AS t1_r1, "categories"."description" AS t1_r2 FROM "items" LEFT OUTER JOIN "categories" ON "categories"."id" = "items"."category_id" WHERE "categories"."title" = ?  [["title", "Fruits"]]

Ví dụ sẽ chỉ load items với preloaded category có title là Fruits. Bạn cũng có thể dùng #includes để tạo một truy vấn với phương thức #references (sử dụng LEFT OUTER JOIN) :

 irb(main):001:0> Item.includes(:category).references(:categories).each { |item| item.category.title }
  SQL (0.1ms)  SELECT "items"."id" AS t0_r0, "items"."title" AS t0_r1, "items"."description" AS t0_r2, "items"."price" AS t0_r3, "items"."category_id" AS t0_r4, "categories"."id" AS t1_r0, "categories"."title" AS t1_r1, "categories"."description" AS t1_r2 FROM "items" LEFT OUTER JOIN "categories" ON "categories"."id" = "items"."category_id"

eager_load

Phương pháp này cũng giống như sự kết hợp của #includes và #references vì nó tạo ra một truy vấn LEFT OUTER JOIN:

irb(main):001:0> Item.eager_load(:category).each { |item| item.category.title }
  SQL (0.1ms)  SELECT "items"."id" AS t0_r0, "items"."title" AS t0_r1, "items"."description" AS t0_r2, "items"."price" AS t0_r3, "items"."category_id" AS t0_r4, "categories"."id" AS t1_r0, "categories"."title" AS t1_r1, "categories"."description" AS t1_r2 FROM "items" LEFT OUTER JOIN "categories" ON "categories"."id" = "items"."category_id"

Đây là những trường hợp phổ biến nhất N+1 xảy ra và chúng có thể dễ dàng để nhận thấy trong Rails console, tuy nhiên sẽ có một vài thứ không giống thế hoặc là khó khăn hơn để thông báo.

Cách giải quyết khác

Những cách giải quyết tôi đưa ra trên đây có rất nhiều vấn đề N+1 hay có nhiều truy vấn equal.

Count/sum/max/... với dynamic condition

Như đoạn code dưới đây

def specific_date_wrong(date)
  Item.where("DATE(expire_at) = ?", date).count
end

Truy vấn trên sẽ thực hiện phép gọi một trang duy nhất trong cả tháng, vì vậy 30 lượt truy vấn và được đếm mỗi ngày. Quá nhiều truy vấn!

def specific_date_right(date)
  @specific_date ||= Item.group('DATE(expire_at)').count
  @specific_date[date] || 0
end

Giải pháp đưa ra là hàm specific_date_right(date) ở trên. Một biến instance được tạo ra là một hash mà keys là dates còn giá trị sẽ là số lần xuất hiện. Dĩ nhiên là rút gọn chỉ còn 1 queries.

*** Join nhiều queries thành một***

Có thể nhiều queries không thể group với nhau nhưng có thể join với nhau trong SQL. Với những thứ quá phức tạp mà ActiveRecord là thứ bạn không thể đếm được. Thay vào đó, chúng ta sử dụng SQL cho những thứ ta cần được lấy ra từ database

Item.where(validated: true).count
  Item.where(validated: false).count
  Item.sum(:count_1)
  Item.sum(:count_2)
  Item.sum(:count_3)
  Item.sum(:count_4)

Chúng ta có thể viết lại thành dạng 1 truy vấn

  Item.select("count(case when validated = 't' then 1 else null end) as valid, count(case when validated = 'f' then 1 else null end) as invalid")
  .select("sum(count_1) as total_1, sum(count_2) as total_2, sum(count_3) as total_3, sum(count_4) as total_4")
  .take

Rõ ràng là có một chút rắc rối và phức tạp hơn, #attributes method trước đó sẽ cho ta thấy các attributes chúng ta gọi alias.

Bullet

Gem có thể giúp ta phát hiện N+1 và show các thông báo lên browser. Hãy tham khảo thêm về Bullet nếu bạn cảm thấy thấy thích thú.

Tóm tắt

ActiveRecord dễ để tìm hiểu công cụ để làm ứng dụng Rails nhưng nó vẫn đòi hỏi bạn phải biết trước một số kiến thức để viết được những pages nhanh hơn. Hi vọng có ích cho bạn trong việc lập trình hàng ngày với những câu truy vấn không còn làm cho bạn cảm thấy khó chịu vì tốc độ rùa bò của nó, giờ đây bạn đã bỏ túi thêm một kinh nghiệm một cách khắc phục vấn đề về truy vấn dữ liệu.

0