Fighting the Hydra of N+1 queries
Bài đăng này đã không được cập nhật trong 6 năm
Chúng ta hãy nói về vấn đề N+1 trong rails. Chúng tôi sẽ giới thiệu sơ qua với những bạn nào chưa biết, nói về cách kiểm soát vụ N+1 queries (cụ thể là bằng cách sử dụng bullet gem), ActiveSupport, và giới thiệu sơ qua về rspec-sqlimit gem.
The Hydra
N + 1 là gì? và nó xảy ra như thế nào? Trong rails, bât kể nhà phát triển nào đều hiểu vấn đề try Vấn N + 1 và cách để đối phó với nó. Hiện tại đã có rất nhiều bài báo, bài viết đề cập đến cả vấn đề và giải pháp cho nó. N + 1 là một trong những vấn đề phổ biến trong hiệu năng. Nó xảy ra khi chúng ta lấy các bản ghi từ một bảng liên quan nhưng k phải là sử dụng trực tiếp một truy vấn mà thay vào đó, chúng ta sẻ dụng nhiều truy vấn cá nhân cho mỗi bản ghi.
chúng ta sẽ thử sql với mô hình liên kêt dưới đây với 2 model là User
và Message
như bên dưới:
class User < ActiveRecord::Base
has_many :messages
end
class Message < ActiveRecord::Base
belongs_to :user
end
Giờ chúng ta sẽ thử chạy lệnh dưới đây trong console:
Message.where(id: 1..3).each do |message|
puts message.user.name
end
Mục đích của chúng ta là thực hiện một truy vấn đề xuất ra 3 message
và sau đó thực hiện 3 truy vấn khác để lấy user_name
từ mỗi message
Và đây là những gì hiển thị trong SQL
:
SELECT * FROM "messages" WHERE "messages"."id" IN (1, 2, 3)
SELECT * FROM "users" INNER JOIN "messages" ON "users"."id" = "messages"."user_id" WHERE "messages"."id" = 1
SELECT * FROM "users" INNER JOIN "messages" ON "users"."id" = "messages"."user_id" WHERE "messages"."id" = 2
SELECT * FROM "users" INNER JOIN "messages" ON "users"."id" = "messages"."user_id" WHERE "messages"."id" = 3
Như vậy đây là 1 giao tiếp kém hiệu quả và làm giảm performance khi sử dụng. Vậy giải pháp xư rlys rát đơn giản, chúng ta có thể sử dụng eagerly loader
để tải trước các hồ sơ liên quan:
Chúng ta sẽ sử dụng eagerly loader
như hình dưới:
Message.where(id: 1..3).includes(:user).each do |message|
puts message.user.name
end
Lần này, ActiveRecord chỉ chạy 2 queries thay vì chạy N lần. Đây là cách sử dụng kết quả của lần request đầu tiên để truy xuất tất cả user
liên quan cùng lúc:
SELECT * FROM "messages" WHERE "messages"."id" IN (1, 2, 3)
SELECT * FROM "users" INNER JOIN "messages" ON "users"."id" = "messages"."user_id" WHERE "messages"."id" IN (1, 2, 3)
Tuy nhiên, với giải pháp đơn giản đó, chúng ta có thể ngăn chặn tình trạng N + 1 đơn giản, nhưng với các dự án lâu dài trong thực thế có thể sẽ xảy ra rất phức tạp. Vấn đề nè được ví như con quái vật Hydra Lernaean với khả năng tái sinh khi bị rờ vào =)), do vây chúng ta cũng nên cần một công cụ đặng biệt để có thể giữ nó trong khả năng kiểm soát của chúng ta.
Chúng ta hãy cùng xem qua ví dụ minh họa dưới đây:
class User < ActiveRecord::Base
has_many :incomings, class_name: "Message", foreign_key: :addressee_id
has_many :outgoings, class_name: "Message", foreign_key: :addresser_id
validates :name, presence: true # just to show that there is a name
end
class Message < ActiveRecord::Base
belongs_to :addresser, class_name: "User"
belongs_to :addressee, class_name: "User"
validates :text, presence: true # just to show that there is a text
end
+---> User <--+
| |
addressor addressee
| |
+-- Message --+
UserPage = Struct.new(:user) do
def to_h
{ name: user.name }
end
end
MessagePage = Struct.new(:message) do
def to_h
{
text: message.text,
addresser: UserPage.new(message.addresser).to_h,
addressee: UserPage.new(message.addressee).to_h
}
end
end
giờ chúng ta hãy kiểm tra xem nó làm sao để hoạt động:
joe = User.create name: "Joe"
ann = User.create name: "Ann"
message = Message.create addresser: joe, addressee: ann, message: "Hi!"
MessagePage.new(message).to_h
# => { text: "Hi!", addresser: { name: "Joe" }, addressee: { name: "Ann" } }
Giờ chúng ta hãy xem làm sao xuất hiện N + 1. Để hiển hiện tất các Messages
chúng ta sẽ làm giống như dưới đây:
class MessagesPage
def to_h
Message.includes(:addresser, :addressee) # here we prevent a N+1 query
.map { |item| MessagePage.new(item).to_h } # and get "preloaded" users
end
end
MessagesPage.new.to_h
# => [{ text: "Hi!", addresser: { name: "Joe" }, addressee: { name: "Ann" } }]
Yup, here is Hydra
Sau một thời gian phát triển, một developer mới tham dự dự án của chúng tôi. Và, anh ta cần thêm một số chi tiết cho người dùng của mình bằng cách tạo một model mới cho Country
và nó được gán cho từng User
:
class Country < ActiveRecord::Base
has_many :users
validates :name, presence: true # just to make it visible here
end
class User < ActiveRecord::Base
# ... all the previous stuff
belongs_to :country
end
Giờ với bản mở rộng nhỏ này, chúng ta có mô hình mới:
Country
^
|
|
+---> User <--+
| |
addressor addressee
| |
+-- Message --+
và chúng ta sẽ làm một bổ sung đơn giản như dưới đây:
UserPage = Struct.new(:user) do
def to_h
{ name: user.name, country: user.country.name }
end
end
OK, giờ bạn có thể đoán điều gì sẽ xảy ra =))))))))))))))
Bởi vì sự phụ thuộc mới đã không được phản ảnh trong query, nên bây giờ chúng ta có 2N+3 query (1 cho danh sách message
, 2 cho dánh ách user
được chỉ định và 2N khác cho addresser/addressee của countries
đối với mỗi message
)
Chúng thậm chí còn có thể tiến xa hơn. Theo luật của Demeter, chúng ta hoàn toàn có thể làm cho một số đoạn như dưới đây:
UserPage = Struct.new(:user) do
delegate :country, to: :user
delegate :name, to: :user, prefix: true
delegate :name, to: :country, prefix: true, allow_nil: true
def to_h
{ name: user_name, country: country_name }
end
end
hoặc là:
UserPage = Struct.new(:user) do
delegate :name, to: :user, prefix: true
delegate :name, :code, to: :country, prefix: true, allow_nil: true
def country
@country ||= user.country
end
def to_h
{ name: user_name, country: { name: country_name, code: country_code } }
end
end
Mặc dù những thay đổi này làm đơn giản hóa mã và làm cho chương trình dễ đọc hơn nhưng chúng cũng đồng thời ẩn chứa các lỗi hổng N + 1. Thông qua các ví dụ trên, chúng ta có thể nhận thấy nguồn góc của vân đề ẩn sâu trong sự bất cẩn của nhà phát triển. Đó là việc chia nhỏ các vấn đề phức tập thành các lớp riêng biệt và mã DRY-ing làm cho chúng ta không thể nhận biết được một cách rõ ràng. Cơ cấu tổ chức của chúng ta ngày càng phát triển thì càng cần nỗ lực để ngăn chặn những sai lầm như thế này. Như đã đề cập ở bên trên, ví dụ cụ thể này đã được đơn giản hóa cho mục đích minh họa. Hyaxnhifn vào cấu trúc thực của một hệ thống dưới đây và cố gắng dự đoán xem những chỗ nào có thể phát sinh mã N + 1.
Shop <------------ ShippingService
^ ^
| |
| |
Account <---- Showcase <------------+ |
^ ^ | |
| | | |
| | | |
Product <----- Listing ----> ShippingProfile
^ ^
| |
| |
Variation <--- ListingVariation
Mặc dù chúng ta biết cách làm sao để giải quyết vấn đề N+1, nhưng để giải quyết toàn bộ những lỗi N+1 phát sinh, chúng ta cần bỏ ra rất nhiều thời gian để theo dõi và xác định được nguồn gốc của vấn đề. Thay vì phải giải quyết lỗi sau khi nó phát sinh thì chúng ta cần tìm một cách tiếp cận hiệu quả hơn để chủ động đối phó với nó, như vậy sẽ hiệu quả hơn nhiều
Kiếm, khiên và gương
Có rất nhiều công cụ giúp bạn có thể giải quyết vấn đề này.
Và tiêu biểu trong số đó, gem bullet
của cộng đồng Rails hiện đang được coi là thịnh hành nhât trong giải pháp chống lại truy vấn chưa tối ưu.
Trong phần tiếp theo chúng ta sẽ cùng tìm hiểu rõ hơn về gem bullet
.
II. Giới thiệu về GEM BULLET
Gem bullet được thiết kế bởi Richard Huang. Nó hoạt động trên brower của chúng ta trong quá trình phát triển sản phẩm. Nó hiển các truy vấn của chúng ta khi phát triển sản phẩm và cảnh báo khi có lỗi N + 1.
Để sử dụng gem bullet khi đang phát triển sản phẩm. Chúng ta chỉ cần khai báo trong Gemfile như sau:
#./Gemfile
gem "bullet", group: "development"
Tiếp theo, ta cần cấu hình:
# ./config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.sentry = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.growl = true
Bullet.rails_logger = true
Bullet.slack = { webhook_url: 'http://some.slack.url', channel: '#default', username: 'notifier' }
end
Trong đó,
- Bullet.enable: cho phép thông báo, giá trị là true hoặc false. Mặc định là false.
- Bullet.alert: hiển thị popup Javascript alert, kiểu dữ liệu boolean. Mặc định là false.
- Bullet.bulletlogger: ghi log ra file, kiểu dữ liệu boolean. Mặc định false.
- Bullet.console: ghi log ra console log của trình duyệt, kiểu dữ liệu boolean. Mặc định false.
- Bullet.growl: pop up Growl nếu hệ thống cài đặt Growl.
- Bullet.railslogger: ghi cảnh báo vào trong Rails log.
- Bullet.slack: thêm thông báo vào slack.
Các bạn có thể đọc thêm cấu hình trong phần (hướng dẫn)[https://github.com/flyerhzm/bullet] của bullet. Và tất nhiê, việc tối ưu truy vấn phụ thuộc vào các bạn, gem này chỉ hỗ trợ các bạn việc hiển thị các truy vấn vào database 1 cách dễ dàng bằng cách cung cấp môi trường thử nghiệm và cảnh báo khi có lỗi N+1.
Một ví dụ như sau:
expect(MessagesPage.new.to_h.size).to eq 2
# 1.2) Failure/Error: Bullet.perform_out_of_channel_notifications if Bullet.notification?
#
# Bullet::Notification::UnoptimizedQueryError:
# user: nepalez
#
# USE eager loading detected
# User => [:country]
# Add to your finder: :includes => [:country]
# Call stack
# ./app/pages/user_page.rb:3:in `to_h'
# ./app/pages/message_page.rb:5:in `to_h'
# ./app/pages/messages_page.rb:4:in `block in to_h'
# ./app/pages/messages_page.rb:4:in `to_h'
Ngoài ra, bạn có thể viết trong spec của rails như sau:
# spec/support.rb
shared_context "bullet", bullet: true do
before(:each) do
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true # raise an error if N+1 query occurs
Bullet.start_request
end
after(:each) do
Bullet.perform_out_of_channel_notifications if Bullet.notification?
Bullet.end_request
Bullet.enable = false
Bullet.bullet_logger = false
Bullet.raise = false
end
end
# spec/rails_helper.rb
require_relative "support"
config.alias_example_to :bulletify, bullet: true
# spec/controllers/my_controller_spec.rb
context 'N+1' do
bulletify { get :index }
end
# spec/rails_helper.rb
require_relative "support"
config.alias_example_to :bulletify, bullet: true
# spec/controllers/my_controller_spec.rb
context 'N+1' do
bulletify { get :index }
end
GIờ chúng ta hãy thử và cảm nhận. Như tôi thấy, nó hỗ trợ khá nhiều giúp chúng ta trong quá trình tối ưu hóa chương trình. Mặc dù, trong 1 số trường hợp, nó lại không cảnh báo được giúp chúng ta, nhưng như các bạn thấy đó, nó vẫn hiển thị các truy vấn cơ sở dữ liệu cho chúng ta, từ đó, ta xác định được lỗi N+1 và tìm ra cách giải quyết.
Cảm ơn các bạn đã theo dõi
All rights reserved