Tại AppSignal, chúng tôi trợ giúp các nhà phát triển về hiệu suất ứng dụng. Chúng tôi đang theo dõi một số lượng lớn các ứng dụng gửi hàng tỷ yêu cầu. Chúng tôi nghĩ rằng chúng tôi cũng có thể giúp một chút với một vài bài đăng trên blog về Ruby và hiệu suất. Vấn đề truy vấn N + 1 là vấn đề phản vật chất phổ biến trong các ứng dụng Rails.
Rất nhiều ORM, như ActiveRecord của Rails, được tích hợp sẵn tính năng tải chậm để cho phép bạn trì hoãn các liên kết truy vấn cho đến thời điểm chúng cần thiết. Nó cho phép ngầm hiểu về những liên kết nào cần được tải bằng cách giảm tải quyết định này xuống chế độ xem.
Vấn đề truy vấn N + 1 là một vấn đề phổ biến, nhưng thường dễ phát hiện, phản vật chất hiệu suất dẫn đến việc chạy một truy vấn cho mỗi liên kết, gây ra chi phí khi truy vấn một số lượng lớn các liên kết từ cơ sở dữ liệu.
👋 Nhân tiện, nếu bạn thích bài viết này, chúng tôi đã viết nhiều hơn về hiệu suất của Ruby (trên Rails), hãy xem danh sách kiểm tra giám sát hiệu suất Ruby của chúng tôi.
Tải chậm trong ActiveRecord
ActiveRecord sử dụng tính năng tải lười biếng ngầm để làm việc với các quan hệ dễ dàng hơn. Hãy xem xét ví dụ về webhop, trong đó mỗi Sản phẩm có thể có bất kỳ số lượng Biến thể nào chẳng hạn như chứa màu sắc hoặc kích thước của sản phẩm.
# app/models/product.rb
class Product < ActiveRecord::Base
has_many :variants
end
Trong ProductsController#show
, chế độ xem chi tiết cho một trong các sản phẩm, chúng tôi sẽ sử dụng Product.find(params[:id])
để lấy sản phẩm và gán nó cho @product
biến.
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
end
end
Trong chế độ xem cho hành động này, chúng tôi sẽ lặp lại các biến thể của sản phẩm bằng cách gọi các biến thể variants
trên @product
biến chúng tôi nhận được từ bộ điều khiển.
# app/views/products/show.html.erb
<h1><%= @product.title %></h1>
<ul>
<%= @product.variants.each do |variant| %>
<li><%= variant.name %></li>
<% end %>
</ul>
Bằng cách gọi @product.variants
trong khung nhìn, Rails sẽ truy vấn cơ sở dữ liệu để lấy các biến thể để chúng tôi lặp lại. Ngoài truy vấn rõ ràng mà chúng tôi đã thực hiện trong bộ điều khiển, chúng tôi có thể thấy một truy vấn khác được thực thi để tìm nạp các biến thể nếu chúng tôi kiểm tra nhật ký của Rails cho yêu cầu này.
Started GET "/products/1" for 127.0.0.1 at 2018-04-19 08:49:13 +0200
Processing by ProductsController#show as HTML
Parameters: {"id"=>"1"}
Product Load (1.1ms) SELECT "products".* FROM "products" WHERE "products"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Rendering products/show.html.erb within layouts/application
Variant Load (1.1ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ? [["product_id", 1]]
Rendered products/show.html.erb within layouts/application (4.4ms)
Completed 200 OK in 64ms (Views: 56.4ms | ActiveRecord: 2.3ms)
Yêu cầu này đã thực thi hai truy vấn để hiển thị một sản phẩm với tất cả các biến thể của nó.
-
SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1
-
SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1
Tải lười lặp lại
Cho đến nay, tải lười biếng là rất tốt. Ví dụ:bằng cách sử dụng truy vấn ngầm định, chúng tôi không cần phải xóa nó khỏi bộ điều khiển khi chúng tôi quyết định không muốn hiển thị các biến thể trên chế độ xem này nữa.
Giả sử chúng tôi đang làm việc trên ProductsController#index
, nơi chúng tôi muốn hiển thị danh sách tất cả các sản phẩm với từng biến thể của chúng. Chúng tôi có thể triển khai điều đó với tính năng tải chậm giống như cách chúng tôi đã làm trước đây.
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def index
@products = Product.all
end
end
# app/views/products/index.html.erb
<h1>Products</h1>
<% @products.each do |product| %>
<article>
<h1><%= product.title %></h1>
<ul>
<% product.variants.each do |variant| %>
<li><%= variant.description %></li>
<% end %>
</ul>
</article>
<% end %>
Không giống như ví dụ đầu tiên, bây giờ chúng ta nhận được danh sách các sản phẩm từ bộ điều khiển thay vì một sản phẩm duy nhất. Sau đó, chế độ xem sẽ lặp lại trên từng sản phẩm và tải từng biến thể cho từng sản phẩm.
Trong khi điều này hoạt động, có một điểm khó khăn. Số lượng truy vấn của chúng tôi hiện là N + 1 .
N + 1 truy vấn
Trong ví dụ đầu tiên, chúng tôi hiển thị một chế độ xem cho một sản phẩm và các biến thể của nó. Số lượng truy vấn là 2 vì chúng tôi đã thực hiện hai truy vấn. Yêu cầu này đã trả lại tất cả các sản phẩm (3, trong ví dụ này) từ cơ sở dữ liệu và từng biến thể của chúng và nó thực hiện bốn truy vấn thay vì hai.
Started GET "/products" for 127.0.0.1 at 2018-04-19 09:49:02 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.3ms) SELECT "products".* FROM "products"
Variant Load (0.2ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ? [["product_id", 1]]
Variant Load (0.2ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ? [["product_id", 2]]
Variant Load (0.1ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ? [["product_id", 3]]
Rendered products/index.html.erb within layouts/application (5.6ms)
Completed 200 OK in 36ms (Views: 32.6ms | ActiveRecord: 0.8ms)
-
SELECT "products".* FROM "products"
-
SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1
-
SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 2
-
SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 3
Truy vấn đầu tiên, được thực thi bởi lệnh gọi rõ ràng tới Product.all
trong bộ điều khiển, tìm tất cả các sản phẩm. Những cái tiếp theo được thực thi một cách lười biếng trong khi lặp lại từng sản phẩm trong chế độ xem.
Ví dụ này dẫn đến số lượng truy vấn là N + 1, trong đó N là số lượng sản phẩm và sản phẩm được thêm vào là truy vấn rõ ràng đã tìm nạp tất cả các sản phẩm. Nói cách khác; ví dụ này thực hiện một truy vấn, sau đó thực hiện một truy vấn khác cho mỗi kết quả trong truy vấn đầu tiên. Vì N =3 trong ví dụ này, số truy vấn kết quả là N + 1 = 3 + 1 = 4
.
Mặc dù điều này có thể không thực sự là vấn đề khi chỉ có ba sản phẩm, nhưng số lượng truy vấn sẽ tăng lên cùng với số lượng sản phẩm. Bởi vì chúng tôi biết yêu cầu này có N + 1 truy vấn, chúng tôi có thể dự đoán số lượng truy vấn là 101 khi chúng tôi có 100 sản phẩm (N + 1 = 100 + 1 = 101
), chẳng hạn.
Các liên kết tải háo hức
Thay vì tăng số lượng truy vấn với số lượng sản phẩm như hiện tại, chúng tôi muốn có một số lượng yêu cầu tĩnh trong chế độ xem này. Chúng tôi có thể làm điều đó bằng cách tải trước rõ ràng các biến thể trong bộ điều khiển trước khi hiển thị chế độ xem.
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def index
@products = Product.all.includes(:variants)
end
end
includes
phương pháp truy vấn đảm bảo các biến thể liên quan được tải cùng với sản phẩm của họ. Bởi vì nó biết những biến thể nào cần được tải trước, nó có thể tìm nạp tất cả các biến thể của tất cả các sản phẩm được yêu cầu trong một truy vấn.
Started GET "/products" for 127.0.0.1 at 2018-04-19 10:33:59 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.3ms) SELECT "products".* FROM "products"
Variant Load (0.4ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (?, ?, ?) [["product_id", 1], ["product_id", 2], ["product_id", 3]]
Rendered products/index.html.erb within layouts/application (5.9ms)
Completed 200 OK in 45ms (Views: 40.8ms | ActiveRecord: 0.7ms)
Bằng cách tải trước các biến thể, số lượng truy vấn giảm xuống còn 2, ngay cả khi số lượng sản phẩm tăng lên trong tương lai.
-
SELECT "products".* FROM "products"
-
SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (1, 2, 3)
Lười biếng hay háo hức?
Trong hầu hết các tình huống, việc lấy tất cả các bản ghi được liên kết từ cơ sở dữ liệu trong một truy vấn sẽ nhanh hơn rất nhiều so với việc tải chúng một cách lười biếng.
Trong ứng dụng ví dụ này, chỉ có thể đo lường sự khác biệt về hiệu suất cơ sở dữ liệu với ba sản phẩm, mỗi sản phẩm có mười biến thể. Trung bình, tải danh sách sản phẩm nhanh hơn khoảng 12,5% (0,7 mili giây so với 0,8 mili giây) so với tải chậm. Với mười sản phẩm, sự khác biệt đó tăng lên 59% (1,22 mili giây so với 2,98 mili giây). Với 1000 sản phẩm, sự khác biệt là gần 80%, vì thời gian truy vấn háo hức đạt 58,4 mili giây, trong khi tải chậm chúng mất khoảng 290,12 mili giây.
Mặc dù các liên kết được tải một cách lười biếng mang lại sự linh hoạt hơn trong chế độ xem mà không cần phải cập nhật bộ điều khiển, nhưng một nguyên tắc chung là để bộ điều khiển xử lý việc tải dữ liệu trước khi chuyển nó sang chế độ xem.
Tính năng tải chậm từ chế độ xem hoạt động đối với các chế độ xem hiển thị một đối tượng mô hình và các liên kết của đối tượng đó (như ProductsController#show
trong ví dụ đầu tiên của chúng tôi) và có thể hữu ích khi có nhiều chế độ xem yêu cầu dữ liệu khác nhau từ cùng một bộ điều khiển, chẳng hạn.
Mèo và Búp bê
Mèo có thể không đồng ý, nhưng đôi khi nó được coi là háo hức hơn là lười biếng. Trong bài đăng này, chúng tôi đi sâu vào tải chậm trong ActiveRecord và cho thấy một ví dụ về các tình huống mà điều này có thể tạo ra sự cố hiệu suất. Giống như khi nó dẫn đến vấn đề truy vấn N + 1.
Tóm lại:luôn theo dõi nhật ký phát triển hoặc dòng thời gian sự kiện trong AppSignal, để đảm bảo rằng bạn không thực hiện các truy vấn có thể tải chậm và theo dõi thời gian phản hồi của bạn, đặc biệt là khi lượng dữ liệu được xử lý tăng lên .
Nếu bạn thích điều này, hãy xem thêm một số điều chúng tôi đã viết về hiệu suất và giám sát, chẳng hạn như điều yêu thích này về Russian Doll Caching hoặc điều này về Yêu cầu nhận có điều kiện.