Fighting the Hydra of N+1 queries
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ế ...
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.