0

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ế 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à UserMessage 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

Viblo
Let's register a Viblo Account to get more interesting posts.