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.