12/08/2018, 17:15

Join hay không join? Một hành động #includes

Nếu bạn quen thuộc với một ORM, có thể bạn đã gặp nhiều lần bởi một vấn đề rất phổ biến khi cố gắng truy vấn một đối tượng cùng với các mối quan hệ của nó. Ví dụ, hãy tưởng tượng một mối quan hệ rất đơn giản giữa các thực thể trong một dự án Ruby on Rails: class User has_many :books end ...

Nếu bạn quen thuộc với một ORM, có thể bạn đã gặp nhiều lần bởi một vấn đề rất phổ biến khi cố gắng truy vấn một đối tượng cùng với các mối quan hệ của nó. Ví dụ, hãy tưởng tượng một mối quan hệ rất đơn giản giữa các thực thể trong một dự án Ruby on Rails:

class User
  has_many :books
end
class Book
  belongs_to :user
end
u1 = User.create(name: 'Guava')
u2 = User.create(name: 'Foo')
u3 = User.create(name: 'Bar')

Book.create(title: 'Hamlet', author: 'Shakespeare', user: u1)
Book.create(title: 'King Richard III', author: 'Shakespeare', user: u2)
Book.create(title: 'Macbeth', author: 'Shakespeare', user: u3)

Bây giờ, điều gì sẽ xảy ra khi cố gắng để có được user cho mỗi book?

books = Book.all
user_names = books.map { |book| book.user.name }

Hãy nhìn vào console để xem điều gì xảy ra:

Book Load (0.7ms)  SELECT "books".* FROM "books"
User Load (0.2ms)  SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
User Load (0.1ms)  SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
User Load (0.1ms)  SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]

Dễ dàng thấy ra đây là vấn đề của N + 1 query. Truy vấn ban đầu của chúng ta (1 trong N +1) trả về collection có kích thước N, và đến lượt nó chạy một truy vấn cho mỗi một trong số chúng trong database (N trong N + 1).

Vâng, thật may mắn là chúng ta chỉ có 3 books trong ví dụ này. Nhưng hiệu suất hit có thể rất đáng kể. Hãy tưởng tượng những gì có thể xảy ra nếu chúng ta có hàng triệu user! Tùy thuộc vào collection lớn mà bạn có, máy tính của bạn thậm chí có thể bùng nổ! Không, không thực sự ... Nhưng ứng dụng của bạn chắc chắn có thể dừng lại. Và tôi nghĩ rằng nó sẽ tồi tệ hơn trước đây, vì trong trường hợp của một vụ nổ bạn luôn có thể đổ lỗi cho phần cứng. Thông báo, mặt khác, các truy vấn N rất nhanh (khi chúng được xử lý trong database) vì chúng đang khớp với một cột id cụ thể (một index), do đó cải thiện hiệu suất. Nhưng hãy chú ý: bất cứ khi nào bạn có thể đưa N +1 thành hai hoặc thậm chí một query, chỉ cần kích hoạt nó. Chi phí của I / O (đặc biệt là nếu DB không phải là trong cùng một máy như các ứng dụng) giao tiếp với DB là thủ phạm ở đây.

Trong bài này chúng ta sẽ khảo sát 3 methods /strategies được phát triển trong ActiveRecord (để giải quyết vấn đề của các N + 1 query: preload, eager_load và includes).

Một cách để giải quyết vấn đề này là bằng cách có 2 truy vấn: một trong những đầu tiên để lấy dữ liệu kết hợp và thứ hai sẽ là truy vấn với kết quả cuối cùng:

books = Book.all
user_names = books.preload(:user).map { |book| book.user.name }

Và các truy vấn kết quả đầu ra:

Book Load (0.3ms) SELECT “books”.* FROM “books”
User Load (0.4ms) SELECT “users”.* FROM “users” WHERE “users”.”id” IN (1, 2, 3)

Aah! Nó chậm hơn cách N + 1! Nói chung, không. Trong ví dụ cụ thể đó là, vâng. Nhưng chỉ vì số liệu test của chỉ gồm 3 record. Vì vậy, mặc dù tải trước mất 0,7ms để chạy 2 truy vấn, N = 3 mất (trong máy của tôi) chỉ 0,4ms để chạy. Tuy vậy, hãy bỏ qua vấn đề này và tập trung vào ghi nhớ những N +1 query này đang sử dụng bảng chỉ mục Postgres (id như khóa chính) và do đó nó vẫn sẽ nhanh. Đối với phần lớn các trường hợp, 2 truy vấn sẽ đánh bại N + 1 cả ngày.

Nhưng luôn có mặt trái. Bạn nghĩ gì sẽ xảy ra nếu chúng tôi đã cố gắng để lọc các truy vấn chỉ hơn một chút? Ví dụ như:

books.preload(:user).where('users.name="Guava"')
# => 
# => no such column: user.name: SELECT “books”.* FROM “books” WHERE (user.name = Guava)

Vì vậy, ActiveRecord báo lỗi bởi vì nó không tìm thấy cột "users.name" trong truy vấn. Tất nhiên, không preload (name là một gợi ý) chỉ tải hoặc tìm nạp dữ liệu từ một association trước đây trong một query khác. Để sử dụng "users.name" trong truy vấn, chúng ta cần phải join hai bảng. Rõ ràng bây giờ preload không phải là câu trả lời phù hợp. Ví dụ: nếu tôi cần truy cập một association trong query thì sao? Vâng, đó sẽ đưa chúng ta đến strategy tiếp theo.

Vấn đề của preload là không thể truy cập các cột của bảng khác trong truy vấn. Điều đó xảy ra vì preload luôn sử dụng các truy vấn riêng biệt. Đừng lo. eager_load tồn tại vì một lý do. Nó tải dữ liệu từ một association với chỉ một query bằng cách sử dụng các association trái để nạp các bản ghi kết hợp. Ví dụ, bây giờ chúng ta có thể làm được:

user_names = books.eager_load(:user).map { |book| book.user.name }

#=> SQL (0.4ms) SELECT “books”.”id” AS t0_r0, “books”.”title” AS t0_r1, “books”.”author” AS t0_r2, 
#=> “books”.”books_id” AS t0_r3, “books”.”user_id” AS t0_r4, “books”.”created_at” AS t0_r5, 
#=> “books”.”updated_at” AS t0_r6, “users”.”id” AS t1_r0, “users”.”name” AS t1_r1, 
#=> “users”.”created_at” AS t1_r2, “users”.”updated_at” AS t1_r3 FROM “books” 
#=> LEFT OUTER JOIN “users” ON “users”.”id” = “books”.”user_id”

Bạn có thể thấy rằng LEFT OUTER JOIN được sử dụng ở đây. ActiveRecord là một loại-biết-tất-cả, nó chỉ là cố gắng thể hiện trên bao nhiêu SQL nó biết. Có điều quan trọng cần nhớ: eager_load sẽ luôn luôn sử dụng một query với một left outer join (đừng lo lắng về outer, nó giống như một left join).

Thứ hai, chúng ta có thể truy cập các trường từ bảng liên quan (user), vì ActiveRecord tải cả hai bảng vào bộ nhớ (cẩn thận về điều đó). Đó chính xác là vấn đề chúng tôi đã có trước khi tải. Bây giờ chúng ta có thể chạy những điều sau đây và nó sẽ làm việc:

books.eager_load(:user).where('users.name = "Guava"').map { |book| book.author }

Một điều thú vị khác ở đây là nó khác với joins Thỏa thuận với joins là gì? Một vài:

  1. joins sử dụng một inner join thay vì một left outer join.

  2. Mục tiêu khác nhau: nên được sử dụng để lọc kết quả từ một query, không tải các bản ghi với các kết hợp của nó. Nó không tải các associations, do đó không ngăn cản các truy vấn N + 1.

  3. Sử dụng tốt khi bạn muốn lọc kết quả từ một query mà không cần truy cập các trường từ các bảng kết hợp bởi vì nó không tải hoặc nạp các bảng kết hợp vào bộ nhớ, nó chỉ sử dụng chúng để lọc kết quả.

3 điểm này cũng có nghĩa là chúng ta có thể sử dụng joins cùng với preload, eager_load và (như chúng ta sẽ thấy) includes. Đó chính là vì chúng có những mục đích khác nhau.

Dù sao eager_load chỉ tạo ra một query với left joins hoặc preload nạp dữ liệu liên quan trong một truy vấn trước đó riêng biệt? ActiveRecord đã tạo ra một mớ hỗn độn đáng yêu cho chúng ta, vậy hãy cùng xem cách giải quyết hay ít nhất cũng đơn giản hóa tình trạng tiến thoái lưỡng nan này.

Ở các phiên bàn trước Rails 4, includes được sử dụng để phân cấp trách nhiệm của quyết định mà strategy tải tốt hơn để sử dụng trong từng trường hợp. Về cơ bản, nó sẽ kiểm tra trong câu điều kiện where hoặc order để xem nếu bạn referenced một bảng liên kết ở đó và, nếu bạn đã làm, nó sẽ uỷ thác cho eager_load (như rõ ràng nó sẽ cần phải join bảng). Nếu không nó sẽ chỉ sử dụng preload. Ví dụ:

books.includes(:user).where('users.name="Guava"')
#=>
#=> SELECT "books".”id” AS t0_r0, "books"."title" AS t0_r1, 
#=> "books."author" AS t0_r2, "books"."books_id" AS t0_r3, 
#=> "books"."user_id" AS t0_r4, "books"."created_at" AS t0_r5, 
#=> "books"."updated_at" AS t0_r6, "users"."id" AS t1_r0, 
#=> "users"."name" AS t1_r1, "users"."created_at" AS t1_r2, 
#=> "users"."updated_at" AS t1_r3 FROM "books" 
#=> LEFT OUTER JOIN "users" ON "users"."id"= "books"."user_id" 
#=> WHERE (users.name="Guava")

Bây giờ, trường hợp mặc định (nơi điều kiện không đề cập đến bảng kết hợp):

books.includes(:user).where(author: 'Shakespeare')
#=>
#=> SELECT "books".* FROM "books" WHERE "books"."author" = ? [["author", "Shakespeare"]]
#=> SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3)

Nhưng kể từ khi Rails 4, đội Rails đã loại bỏ những điều này. Trong cảnh báo phản đối họ đã đề cập rằng "làm điều này mà không cần viết một trình phân tích cú pháp SQL hoàn toàn thổi là hoàn toàn sai lầm. Vì chúng ta không muốn viết một trình phân tích cú pháp SQL, nên chúng ta đang gỡ bỏ chức năng này ". Đơn giản, includes sẽ hoạt động chính xác như preload từ Rails 5 và ở trên. Ví dụ trước thậm chí sẽ gây ra một lỗi trong Rails 5, như preload sẽ cho bạn biết rằng nó không thể truy cập vào các cột trong một bảng liên kết mà không được join. Nếu bạn muốn kết hợp joins vào (các) bảng liên quan, bạn cần phải nói rõ ràng nó bằng cách sử dụng references method. Về cơ bản nó sẽ trở thành:

books.includes(:user).where('users.name="Guava"').references(:user)

#=> SQL (0.4ms)  SELECT "books"."id" AS t0_r0, "books"."title" AS t0_r1, 
#=> "books"."author" AS t0_r2, "books"."books_id" AS t0_r3, 
#=> "books"."user_id" AS t0_r4, "books"."created_at" AS t0_r5, 
#=> "books"."updated_at" AS t0_r6, "users"."id" AS t1_r0, 
#=> "users"."name" AS t1_r1, "users"."created_at" AS t1_r2, 
#=> "users"."updated_at" AS t1_r3 FROM "books" 
#=> LEFT OUTER JOIN "users" ON "users"."id" = "books"."user_id" 
#=> WHERE (users.name="Guava")

Theo tôi, trước Rails 4 includes khá là tuyệt vời Tôi hiểu rằng nó đã thay đổi vì lí do rất hợp lý, nhưng thực tế là references tồn tại duy nhất để nói sử dụng eager_load không thực sự DRY hoặc thậm chí rõ ràng. references không thể được gọi mà không có includes và includes không có references luôn sử dụng preloading strategy! Tại sao không đơn giản gọi eager_load thay cho toàn bộ query.includes(: user).references(: user) ? Tại sao không đơn giản chỉ cần gọi preload thay vì includes (không có stalker cá nhân của nó), mà là nhiều hơn nữa ý định tiết lộ? Tôi đã nhìn thấy một số phản hồi cho điều này, nhưng tôi không biết ... Ngoài ra, includes có một chi phí trên đầu trang của nó để quyết định chiến lược để ủy thác cho. Chi phí này làm cho nó chậm hơn một chút so với 2 phương pháp khác.

  • preload, eager_load và includes tất cả đều là eager loading strategies
  • Sử dụng inner join để lọc query mà không cần lấy quan hệ
  • preload: load bảng liên quan luôn sử dụng query riêng biệt
  • eager_load: load bảng liên quan sử dụng left join
  • includes: trước đó (< Rails 4) là một cách tuyệt vời để tìm ra strategy nào tốt hơn: eager loading hay preloading. Từ Rails 4 sử dụng preloading nên trừ khi được nói rõ ràng sử dụng các liên kết bên trái với các references
  • references: không thể sử dụng nếu không có includes, nhưng ngược lại có thể xảy ra khi gọi preload

"Có hay không được không?" Hoàng tử Hamlet đang phải đối mặt với tình thế tiến thoái lưỡng nan nghiêm trọng về việc liệu ông có nên giết chú mình, sau đó là cha, vua Claudius hay không. Vâng, tôi không biết về điều đó. Nhưng như chúng ta đã thấy trong bài đăng này, chúng ta không nên miễn cưỡng khi giết N + 1 query. Đó không thực sự là câu hỏi bạn nên tự hỏi mình. Câu hỏi thực sự ở đây là nếu bạn làm hoặc không muốn sử dụng join trong các query của bạn, và đến mức độ nào. Giống như ở Hamlet, đây không phải là một câu hỏi đơn giản. Hy vọng bài đăng này sẽ giúp bạn làm sáng tỏ một số nghi vấn của bạn về việc sử dụng các strategy tải nhanh của ActiveRecord, giúp bạn đưa ra những quyết định đúng đắn khi truy vấn các association giữa các đối tượng.

Bài viết được dịch từ To join or not join? An act of includes.

0