12/08/2018, 17:40

Testing preloading/eager-loading của ActiveRecord associations trong Rails

Là một developer quan tâm đến hiệu suất của hệ thống thì một trong những điều bạn cần lưu ý đó là loại bỏ N+1 query bằng cách sử dụng các method #includes, #preload hoặc #eager_load. Nhưng có bao giờ bạn nghĩ là làm thế nào để bạn biết bạn đã thực hiện thành công điều đó hay chưa? Có cách nào để ...

Là một developer quan tâm đến hiệu suất của hệ thống thì một trong những điều bạn cần lưu ý đó là loại bỏ N+1 query bằng cách sử dụng các method #includes, #preload hoặc #eager_load. Nhưng có bao giờ bạn nghĩ là làm thế nào để bạn biết bạn đã thực hiện thành công điều đó hay chưa? Có cách nào để test chúng hay không? Sau đây là một số phương pháp:

Ví dụ bạn có 2 model Oder và OderLine như sau:

class Order < ActiveRecord::Base
  has_many :order_lines

  def self.last_ten
    limit(10).preload(:order_lines)
  end
end
class OrderLine < ActiveRecord::Base
  belongs_to :order
end

Bây giờ ta muốn kiểm tra xem method Oder.last_ten đã thực hiện eager loading thành công hay chưa? Lưu ý: Các ví dụ sau đều được thực hiện với Rspec.

Sử dụng association(:name).loaded?

require "rails_helper"

RSpec.describe Order, type: :model do
  specify "#last_ten eager loading" do
    o = Order.new()
    o.order_lines.build
    o.order_lines.build
    o.save!

    orders = Order.last_ten
    expect(orders[0].association(:order_lines).loaded?).to eq(true)
    # hoặc
    expect(orders[0].association(:order_lines)).to be_loaded
  end
end

Bởi vì chúng ta sử dụng preload(:order_lines) nên chúng ta quan tâm xem order_lines có được load thật không. Để kiểm tra chúng ta cần 1 object Order (ở đây là orders[0]) để xác minh điều đó

Đếm số lượng query với ActiveSupport::Notifications

Đôi khi bạn muốn làm việc với một biểu đồ các object ActiveRecord nhưng kết quả bạn nhận về không phải là nó mà là một giá trị được tính toán dựa trên chúng. Vậy làm thế nào để kiểm tra xem nó có xuất hiên N+1 query ở đây? Không thể kiếm tra trên kết quả trả về, giá trị trả về không được load thêm gì. Vậy chúng ta cần làm gì?

Vẫn với ví dụ trên ta thay đổi một chút như sau:

class Order < ActiveRecord::Base
  has_many :order_lines

  def self.average_line_gross_price_today
    lines = where("created_at > ?", Time.current.beginning_of_day)
	.preload(:order_lines).flat_map do |order|
        order.order_lines.map(&:gross_price)
    end
    lines.sum / lines.size
  end
end

class OrderLine < ActiveRecord::Base
  belongs_to :order

  def gross_price
    # ...
  end
end

Điều mà chúng ta muốn biết ở đây là Order.average_line_gross_price_today có bị N+1 query hay không? Đoạn code order.order_lines.map(&:gross_price) có phát sinh truy vấn vào DB hay ko? Có một cách đó là sử dụng ActiveSupport::Notifications và nhận thông báo về mọi câu SQL được thực thi.

require "rails_helper"

RSpec.describe Order, type: :model do
  specify "#average_line_gross_price_today eager loading" do
    o = Order.new()
    o.order_lines.build
    o.order_lines.build
    o.save!

    count = count_queries{ Order.average_line_gross_price_today }
    expect(count).to eq(2)
  end

  private

  def count_queries &block
    count = 0

    counter_f = ->(name, started, finished, unique_id, payload) {
      unless %w[ CACHE SCHEMA ].include?(payload[:name])
        count += 1
      end
    }

    ActiveSupport::Notifications.subscribed(
      counter_f,
      "sql.active_record",
      &block
    )

    count
  end
end

Một điều mà bạn cần lưu ý khi làm theo cách này là bạn phải tạo đủ số lượng bản ghi để có thể phát hiện được các vấn đề của eager loading. Một order với một order_line không đủ để đảm bảo có hay không có eager loading. Trong trường hợp này bạn cần ít nhất 2 order_line để có thể thấy sự khác biệt của query trước và sau khi sử dụng eager loading.

Bạn cũng có thể sử dụng gem "db-query-matchers" https://github.com/brigade/db-query-matchers để kiếm tra điều đó. Gem "db-query-matchers" cung cấp một số Rspec matcher cho phép bạn kiểm tra sự tương tác với DB. Ví dụ:

  context 'when we expect no queries' do
    it 'does not make database queries' do
      expect { subject.make_no_queries }.to_not make_database_queries
    end
  end

  context 'when we expect queries' do
    it 'makes database queries' do
      expect { subject.make_some_queries }.to make_database_queries
    end
  end

  context 'when we expect exactly 1 query' do
    it 'makes database queries' do
      expect { subject.make_one_query }.to make_database_queries(count: 1)
    end
  end

  context 'when we expect max 3 queries' do
    it 'makes database queries' do
      expect { subject.make_several_queries }.to make_database_queries(count: 0..3)
    end
  end

  context 'when we expect a possible range of queries' do
    it 'makes database queries' do
      expect { subject.make_several_queries }.to make_database_queries(count: 3..5)
    end
  end

0