Một số tweak giúp tăng tốc độ của ActiveRecord

Khi bạn xây dựng một ứng dụng mới bằng Rails. Bạn sẽ có những thiết lập ActiveRecord mặc định. Truy vấn với .where. Chèn với .save. Tất cả thật dễ dàng, và nó thực sự nhanh.

Nhưng đến một lúc nào đó, khi một trang phải mất 1s hoặc nhiều hơn để load dữ liệu từ máy chủ. Hoặc khi bạn thấy một lỗi 504 Gateway timeout vì mất quá lâu để server xử lý một file CSV mà bạn đã upload. Đó là lúc bạn thấy được tầm quan trọng của việc tinh chỉnh hiệu năng cho dự án của mình.

Bạn có thể giải quyết nhiều vấn đề trong đó bằng caching. Nhưng bạn sẽ phải thêm nhiều lớp phức tạp khác để xử lý expiration, nesting partials, bug.

Thay vào đó, bạn có thể dành chút thời gian để tinh chỉnh hầu hết những vấn đề hiệu năng thông thường, đó là làm thế nào để giảm thiểu việc tương tác quá nhiều với database trong ứng dụng Rails.

Thậm chí, nếu bạn đang chạy database trên cùng một máy, có rất nhiều kết nối overhead sẽ làm chậm hệ thống của bạn. Hoặc nếu database ở trên một máy khác thì việc fetch dữ liệu về cũng làm giảm đi tốc độ cho hệ thống.

Nhưng đó không còn là vấn đề, bằng sự đơn giản của Rails sẽ cải tiến mạnh mẽ thời gian phản hồi của ứng dụng.

1, Grab tất cả dữ liệu một lần trong ActiveRecord:

Nếu bạn nhìn vào log của một ứng dụng chưa được tối ưu, nó sẽ thường như này:

      Processing by RestaurantsController#index as HTML
      Restaurant Load (1.6ms)  SELECT `restaurants`.* FROM `restaurants`
      Review Load (1.2ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 1
      Review Load (1.2ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 2
      Review Load (1.1ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 3
      Review Load (1.2ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 4
      Review Load (1.2ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 5
      Review Load (1.2ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 6
      Review Load (1.2ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 7
      Review Load (1.0ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 8
      Review Load (1.0ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 9
      Review Load (1.0ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 10

Bạn sẽ phải gắng tìm 10 nhà hàng cùng với review của chúng. Và bạn sẽ phải thực hiện 11 lời gọi SQL. Vấn đề này được gọi là N+1 query. Với mỗi nhà hàng, bạn sẽ phải thực hiện một truy vấn đến dữ liệu của nhà hàng. Cộng thêm một kết nối cho mỗi associated reviews giữa chúng. Thử tưởng tượng chương trình của bạn sẽ tồi tệ thế nào nếu như bạn muốn grab các địa chỉ của các nhà hàng, và số điện thoại của mỗi địa chỉ .Bạn sẽ chạy problem này khi bạn loop trên một danh sách các đối tượng và cố gắng truy vấn các liên kết của chúng:

app/views/restaurants/index.html.erb

    <% @restaurants.each do |restaurant| %>
      <tr>
        <td><%= restaurant.name %></td>
        <td><%= restaurant.review_average %></td>
        ...

Bạn không cần phải tương tác với database N+1 lần mà chỉ cần 2 lần. 1 lần cho những nhà hàng mà bạn đang tìm kiếm, 1 lần cho những reviews liên kết với tất cả nhà hàng đó.

Nó được gọi là “eager loading,”. Và bạn có thể thực hiện nó dễ dàng với .includes:

    def index
      @restaurants = Restaurant.all.includes(:reviews)
    end

Hoặc, nếu bạn muốn làm một cái gì đó phức tạp hơn, như tải trước tất cả các địa chỉ và các tác giả của các reviews:

    app/controllers/restaurants_controller.rb
    def index
      @restaurants = Restaurant.all.includes([{:reviews => author}, :address])
    end

Bạn phải xác định rõ những liên kết mà bạn muốn database load trước. Sử dụng cú pháp với Array và Hash phù hợp. Rails sẽ cho bạn những kết quả tốt nhất:

    Restaurant Load (1.2ms)  SELECT `restaurants`.* FROM `restaurants`
    Review Load (3.0ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

Nếu bạn không fetch bản ghi cha. Sẽ phức tạp hơn môt chút. Bạn phải preloading cho chính mình bằng cách sử dụng: ActiveRecord::Associations::Preloader:

    ActiveRecord::Associations::Preloader.new.preload(@restaurants, [:reviews])

Vấn đề N+1 thật dễ để fix, và tránh nếu bạn nắm rõ nó.

2, Một chút về preloading

Một vài app có những cách khác nhau để show những dữ liệu giống nhau trên những page giống nhau. Có thể bạn muốn show những địa chỉ bằng cách sắp xếp theo khoảng cách trong một section. Và bạn cũng muốn show đia chỉ, cái được kết nối với mỗi số điện thoại ở một chỗ khác. Để làm nó, ta tạo một association unsorted_unfiltered_addresses và preloading nó:

app/models/restaurant.rb

    has_many :unsorted_unfiltered_addresses, :class_name => "Address"
    Restaurant.includes(:unsorted_unfiltered_addresses)

Sau đó, sắp xếp và filer nó trong code của Ruby:

    def addresses_sorted_by_distance(point)
      unsorted_unfiltered_addresses.sort_by do |address|
        address.distance_from(point)
      end
    end

    def addresses_with_phone_numbers
      unsorted_unfiltered_addresses.select do |address|
        address.phone_number.present?
      end
    end

3, Crafting your own SQL in ActiveRecord

Nếu bạn muốn get số lượng các reviews của một nhà hàng, hoặc tính review trung bình hoặc thời gian review sớm nhất. Nếu bạn không cần dữ liệu về review, gọi tất cả các đối tượng review ra để tính toán là một việc gây lãng phí. Thay vào đó, dùng method select để query chúng:

    app/controllers/restaurants_controller.rb
    @restaurants = Restaurant.all
      .select("restaurants.*, AVG(reviews.rating) AS review_average")
      .joins(:reviews)
      .group("restaurants.id")

Sử dụng SQL, bạn có thể dễ dàng tính được thông tin trước khi chúng được get về ứng dụng. Khi bạn sử dụng AS on select, Rails sẽ tạo ảo một attribute cho bạn:

    @restaurants.first.review_average # => 2.3

Bây giờ, chẳng hạn bạn muốn tìm chỉ 10 nhà hàng đầu tiên có ít nhất 10 reviews trong 3 háng gần đây:

    @restaurants = Restaurant.all
      .select("restaurants.*, COUNT(reviews.id) AS review_count")
      .joins(:reviews)
      .group("restaurants.id")
      .where("reviews.created_at > ?", 3.months.ago)
      .having("COUNT(reviews.id) > 10")
      .limit(10)

Hoặc bạn có thể sử dụng find_by_sql:

    @restaurants = Restaurant.find_by_sql(["
      SELECT  restaurants.*, COUNT(reviews.id) AS review_count
        FROM `restaurants`
          INNER JOIN `reviews` ON `reviews`.`restaurant_id` = `restaurants`.`id`
        WHERE (reviews.created_at > ?)
        GROUP BY restaurants.id
        HAVING COUNT(reviews.id) > 10
        LIMIT 10", 3.months.ago])

4, Lời kết

Trên đây là tweak nhỏ giúp tăng tốc cho ActiveRevord. Mình sẽ tiếp tục chủ đề này trong bài viết sắp tới. Thanks for read!