Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Tăng tốc Rails View: Kỹ thuật Đã Chứng minh để Hiệu suất Tối đa

Trong bài này, chúng ta sẽ xem các phương pháp đã thử nghiệm để cải thiện hiệu suất Rails view. Cụ thể, tôi tập trung vào hiệu quả database, thao tác view và caching.

Tôi nghĩ cụm từ "premature optimization is the root of all evil" đã bị hiểu lầm một chút. Tôi thường nghe developer dùng câu này trong code review khi chỉ ra kỹ thuật tối ưu đơn giản. Bạn biết câu nổi tiếng: "Tôi làm cho nó chạy trước, rồi tối ưu sau" - rồi test - rồi debug - rồi test lại, và cứ thế!

May mắn là có những kỹ thuật hiệu suất và tối ưu đơn giản, hiệu quả có thể dùng ngay từ khi bắt đầu viết code.

👋 Nếu thích bài này, hãy xem các bài Ruby (on Rails) performance khác trong checklist giám sát hiệu suất Ruby của chúng tôi.

Trong bài, chúng ta sẽ dùng một Rails app cơ bản, cải thiện và so sánh kết quả.

App cơ bản có các model sau:

  • Person (has many addresses)
    • name:string
    • votes_count:integer
  • Profile (belongs to Person)
    • address:string

Model Person:

# == Schema Information
# Table name: people
# id :integer not null, primary key
# name :string
# votes_count :integer
# created_at :datetime not null
# updated_at :datetime not null
class Person < ApplicationRecord
  has_many :profiles
  validates_presence_of :name
  validates_uniqueness_of :name
  def vote!
    update votes_count: votes_count + 1
  end
end

Model Profile:

# == Schema Information
# Table name: profiles
# id :integer not null, primary key
# address :text
# person_id :integer
# created_at :datetime not null
# updated_at :datetime not null
class Profile < ApplicationRecord
  belongs_to :person
  validates_presence_of :address
end

Có seed file tạo 1000 people, dùng gem Faker.

Tạo action "home" trong ApplicationController:

def home
  @people = Person.all
end

View home.html.erb:

<ul>
  <% @people.each do |person| %>
    <li id="<%= person.id %>"><%= render person %></li>
  <% end %>
</ul>

Dry run và đo hiệu suất: trang tải 1066.7ms. Chúng ta sẽ giảm con số này.

Database Queries

Bước đầu xây dựng app performant là tối đa hóa sử dụng tài nguyên. Hầu hết Rails app render data từ database ra view, nên hãy tối ưu database call trước!

Dùng MySQL database cho demo.

Phân tích load 1066ms ban đầu:

414.7ms execute 'controllers/application_controller#home'
... (0.1ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 996]]
Rendered people/_person.html.erb (1.5ms)
... (lặp lại cho person_id 997, 998, 999, 1000)
Rendered application/home.html.erb within layouts/application (890.5ms)
Completed 200 OK in 1066ms (Views: 890.5ms | ActiveRecord: 175.4ms)

519.2 và 132.8ms render partial "application/home.html.erb" và "people/_person.html.erb".

Bạn nhận ra gì lạ? Chúng ta gọi database một lần trong controller, nhưng mỗi partial lại tự gọi database! Đó là vấn đề N+1 query.

1. N+1 Queries

Kỹ thuật tối ưu phổ biến, đơn giản - nhưng đáng nhắc đầu vì lỗi này rất hay gặp.

Xem partial "people/_person.html.erb":

<ul>
  <li> Name: <%= person.name %> </li>
  <li> Addresses: <ul>
    <% person.profiles.each do |profile| %>
      <li><%= profile.address %></li>
    <% end %>
  </ul> </li>
</ul>
<%= button_to "Vote #{person.votes_count}", vote_person_path(person) %>

Nó query database cho profiles của person và render từng cái. Vậy là N query (N = số người) cộng 1 query controller = N+1.

Để tối ưu, dùng MySQL join và ActiveRecord includes.

Đổi controller:

def home
  @people = Person.all.includes(:profiles)
end

Tất cả people load bằng 1 query MySQL, tất cả profiles load bằng query thứ 2. Giảm N+1 xuống 2 query.

Kết quả: chỉ 936ms load trang. Log cho thấy action "application_controller#home" chỉ 2 MySQL query.

Rendered people/_person.html.erb (0.3ms) ... (lặp lại)
Rendered application/home.html.erb within layouts/application (936.0ms)
Completed 200 OK in 936ms (Views: 927.1ms | ActiveRecord: 9.3ms)

2. Chỉ Load Những Gì Bạn Sẽ Dùng

Trang home chỉ cần address, không cần object profile đầy đủ. Trong partial "_person.html.erb" ta load object profile. Hãy thay đổi:

<li> Addresses: <ul>
  <% person.profiles.pluck(:address).each do |address| %>
    <li><%= address %></li>
  <% end %>
</ul> </li>

Xem sâu hơn về N+1 query: ActiveRecord performance: the N+1 queries antipattern.

ProTip: Tạo scope cho việc này và thêm vào "models/profile.rb". Raw query trong view file không hữu ích.

3. Di chuyển Tất Cả Database Call vào Controller

Giả sử sau này muốn hiển thị tổng số user trên home page.

Dễ! Gọi trong view:

# of People: <%= @people.count %>

Yêu cầu khác: tạo UI element hiển thị tiến độ trang. Chia số người trên trang cho tổng số.

Progress: <%= index / @people.count %>

Đồng nghiệp không biết bạn đã query và làm lại nhiều lần trong view.

Nếu controller như sau:

def home
  @people = Person.all.includes(:profiles)
  @people_count = @people.count
end

Sẽ dễ reuse biến đã tính. Mặc dù không cải thiện trực tiếp tốc độ load, nó ngăn nhiều gọi database từ view và chuẩn bị cho caching sau này.

4. Phân trang (Paginate) Bất cứ Đâu Có Thể!

Giống như chỉ load thứ cần thiết, chỉ hiển thị thứ cần thiết cũng giúp! Với pagination, view render một phần thông tin, phần còn lại load on demand. Cắt giảm nhiều ms! Gem will_paginate và kaminari làm điều này trong vài phút.

Khó khăn: user phải click "Next Page". Có thể dùng "Infinite Scrolling" cho trải nghiệm tốt hơn.

Tránh Reload HTML

Trong Rails app truyền thống, render HTML view tốn nhiều thời gian. May mắn có biện pháp giảm thiểu.

1. Turbolinks

Đi kèm Rails app chuẩn. Turbolinks là thư viện JS hoạt động mọi nơi (cả static page) và degrade gracefully trên browser không hỗ trợ.

Nó chuyển mọi link thành AJAX request và replace toàn bộ body qua JS. Cải thiện hiệu suất lớn vì không reload CSS, JS, images.

Tuy nhiên, viết custom JS cần chú ý viết "Turbolinks safe JS". Đọc thêm tại đây.

2. Dùng AJAX Request

Cùng tư duy Turbolinks, bạn có thể chuyển một số link/button thành AJAX request. Khác biệt: bạn kiểm soát HTML nào được replace thay vì replace toàn body như Turbolinks.

Xem AJAX thực tế! Trong app mẫu, có nút "Vote" cho mỗi user. Đo thời gian action đó.

Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by PeopleController#vote as HTML
Person Load (0.3ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
(0.1ms) begin transaction
Person Exists (4.5ms) SELECT 1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ? [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
SQL (1.0ms) UPDATE "people" SET "votes_count" = ?, "updated_at" = ? WHERE "people"."id" = ? [["votes_count", 1], ["updated_at", "2020-01-21 09:20:49.941928"], ["id", 1]]
Redirected to https://localhost:3000/
Completed 302 Found in 24ms (ActiveRecord: 7.5ms)
Started GET "/" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by ApplicationController#home as HTML
Rendering application/home.html.erb within layouts/application
Rendered people/_person.html.erb (2.4ms)
(0.3ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 30]]
Rendered people/_person.html.erb (2.2ms)
...
Rendered application/home.html.erb within layouts/application (159.8ms)
Completed 200 OK in 190ms (Views: 179.0ms | ActiveRecord: 6.8ms)

Mất cùng thời gian reload trang, cộng thêm chút cho voting.

Làm thành AJAX request. Partial "people/_person.html.erb" giờ:

<%= button_to "Vote #{person.votes_count}", vote_person_path(person), remote: true %>

Controller action trả về JS response:

$("#<%= @person.id %>").html("<%= j render(partial: 'person', locals: {person: @person}) %>");

Chỉ replace content cần thiết. Cung cấp HTML ID hook vào div và replace. Có thể tối ưu thêm bằng cách chỉ replace button content, nhưng để demo, replace toàn partial.

Kết quả?

Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:52:56 +0530
Processing by PeopleController#vote as JS
Person Load (0.2ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
(0.1ms) begin transaction
Person Exists (0.3ms) SELECT 1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ? [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
SQL (0.4ms) UPDATE "people" SET "votes_count" = ?, "updated_at" = ? WHERE "people"."id" = ? [["votes_count", 2], ["updated_at", "2020-01-21 09:22:56.532281"], ["id", 1]]
(1.6ms) commit transaction
Rendering people/vote.js.erb (0.2ms)
SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 1]]
Rendered people/_person.html.erb (3.2ms)
Rendered people/vote.js.erb (6.3ms)
Completed 200 OK in 31ms (Views: 14.6ms | ActiveRecord: 2.9ms)

30ms! Tuyệt vời!

ProTip: Nếu không muốn loay hoay với HTML ID/class để biết khi/nào replace, cân nhắc gem render_async. Nó lo phần nặng out-of-the-box.

3. Dùng WebSockets

Ưu điểm HTML reload: lấy content mới từ server mỗi lần. Với AJAX, chỉ thấy content mới cho snippet nhỏ.

WebSockets cho phép server push update đến client, thay vì client request thông tin mới.

Hữu ích khi build dynamic webpage. Ví dụ hiển thị điểm game trên web. Để fetch content mới:

  • Yêu cầu user reload toàn trang
  • Cung cấp nút reload chỉ refresh điểm
  • Dùng JS polling backend mỗi giây
    • Vẫn ping server ngay khi data không đổi
    • Mỗi client gọi mỗi giây - dễ làm quá tải server
  • Dùng WebSockets!

Với WebSockets, server kiểm soát khi nào push data cho tất cả client (hoặc subset). Vì server biết khi data thay đổi, chỉ push khi có thay đổi!

Rails 5 ra mắt ActionCable quản lý WebSockets. Cung cấp JS framework cho client subscribe server và backend framework cho server publish thay đổi. Với ActionCable, bạn chọn bất kỳ WebSocket service nào: Faye, self-managed, hoặc Pusher (subscription service).

Cá nhân tôi chọn subscription service, giảm thứ phải manage.

Setup xong ActionCable, view lắng nghe JSON input từ server. Khi nhận được, hook action bạn viết sẽ replace HTML tương ứng.

Rails docs và Pusher có tutorial hay về WebSockets. Bắt buộc đọc!

Caching

Phần lớn load time dùng cho render view: load CSS, JS, images, render HTML từ ERB...

Cách giảm load time: xác định phần app tĩnh trong một thời gian hoặc cho đến khi event xảy ra.

Trong ví dụ, rõ ràng cho đến khi ai đó vote, home page trông giống hệt cho mọi người (hiện tại không có option edit address). Hãy cache toàn bộ "home.html.erb" cho đến khi event (vote) xảy ra.

Dùng gem Dalli. Dùng Memcached lưu và truy xuất fragment nhanh. Memcached không có datatype, lưu cái gì cũng được.

1. Caching Views

Load time 2000 record không cache: 3500ms!

Cache tất cả trong "home.html.erb". Đơn giản:

<% cache do %>
  <ul>
    <% @people.each do |person| %>
      <li id="<%= person.id %>"><%= render person %></li>
    <% end %>
  </ul>
<% end %>

Cài Dalli gem và đổi cache store trong "development.rb":

config.cache_store = :dalli_store

Trên Mac/Linux, start Memcached service:

memcached -vv

Reload!!

Chỉ khoảng 537ms! Cải thiện 7x tốc độ!

Log cho thấy ít MySQL query hơn vì toàn bộ HTML lưu trong Memcached và đọc từ đó, không ping database.

Đây mới chỉ bề mặt view caching. Có thể cache partial rendering scope cho từng person object (fragment caching) hoặc cache toàn bộ collection (collection caching). Với nested view rendering sâu hơn, có thể làm Russian Doll caching.

2. Caching Database Queries

Tối ưu khác: cache complex database query. Nếu app hiển thị stats/analytics, chắc chắn đang làm complex query tính metric. Lưu output vào Memcached và set timeout. Sau timeout, tính toán lại và cache tiếp.

Ví dụ: app cần hiển thị size của team user. Có thể tính toán phức tạp: count direct reports, outsourced consultants...

Thay vì lặp lại tính toán, cache nó!

def team_size
  Rails.cache.fetch(:team_size, expires_in: 8.hour) do
    analytics_client = AnalyticsClient.query!(self)
    analytics_client.team_size
  end
end

Cache auto-expire sau 8 giờ. Sau đó tính lại và cache giá trị mới cho 8 giờ tiếp theo.

3. Database Indexes

Cũng tăng tốc query bằng indexes. Query đơn giản lấy tất cả address của person:

person.addresses

Query yêu cầu Address table trả về address nơi cột person_id = person.id. Không index, database phải inspect từng row check match person.id. Có index, database có list address match person.id.

Tài nguyên hay về database indexes!

Tóm tắt

Bài này khám phá cải thiện view performance Rails bằng cải tiến database utilization, dùng tool/service thứ ba và giới hạn thứ user thấy.

Nếu muốn cải thiện performance app, bắt đầu đơn giản và liên tục đo lường! Clean up database query, tạo AJAX request ở đâu có thể, cache view càng nhiều càng tốt. Sau đó mới chuyển sang WebSockets và database caching.

Tuy nhiên, cẩn thận - tối ưu là con đường trơn trượt. Bạn có thể nghiện như tôi!

P.S. Để monitor performance Rails app production, xem AppSignal APM - built by Ruby devs for Ruby devs. 🚀