3 cách tối ưu hóa hiệu suất Rails views

https://smartbear.com/blog/test-and-monitor/detect-application-performance-issues-at-code-leve/

Chào các bạn, chào các bạn!

Bạn đã từng nghe "Làm hoạt động trước đã, tối ưu sau" =)) và hiện tại thì ở đâu đó chúng ta vẫn thường làm vậy, nhưng đến lúc có vấn đề, quay lại xử lý sẽ rất mất thời gian. Và việc làm tốt ngay từ đầu có thể sẽ rất khó, tuy nhiên nó sẽ giúp chúng ta có nhiều thời gian hơn cho những vấn đề khác.

Trong bài viết này, chúng ta sẽ cùng nhau tìm hiểu một số kỹ thuật tối ưu hóa hiệu suất đơn giản và hiệu quả mà bạn có thể sử dụng ngay từ khi bắt đầu viết code.

Vậy bắt đầu thôi (go) 💪

Trước tiên chúng ta tạo một Rails app sử dụng mysql:

rails new performance-optimzation-demo --database=mysql

Sau đó tạo model, trong bài này tôi sử dụng 2 bảng:

  • Person (has many addresses)

    • name:string
    • votes_count:integer
  • Profile (belongs to Person)

    • person_id:integer
    • address:string

Để tạo 2 model này, chúng ta chạy lệnh:

rails g model Person name:string votes_count:integer
rails g model Profile person:references address:string

Sau đó chạy lệnh sau để tạo database và table:

rails db:create
rails db:migrate

File schema trông như sau:

ActiveRecord::Schema.define(version: 2020_12_19_105249) do

  create_table "people", charset: "utf8mb4", force: :cascade do |t|
    t.string "name"
    t.integer "votes_count"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  create_table "profiles", charset: "utf8mb4", force: :cascade do |t|
    t.bigint "person_id", null: false
    t.string "address"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["person_id"], name: "index_profiles_on_person_id"
  end

  add_foreign_key "profiles", "people"
end

File model:

class Person < ApplicationRecord
  has_many :profiles
end
class Profile < ApplicationRecord
  belongs_to :person
end

Thêm validatons cho model:

class Person < ApplicationRecord
  has_many :profiles
  validates :name, presence: true, uniqueness: true
end
class Profile < ApplicationRecord
  belongs_to :person
  validates :address, presence: true
end

Tiếp theo chúng ta tạo dữ liệu để hiển thị, bạn có thể sử dụng gem Faker để fake name, address... một cách dễ dàng hơn.

Sau khi tạo xong file seeds.rb sẽ trông như thế này:

1000.times do |time|
  Person.create(name: Faker::Name.name)
end

persons = Person.pluck(:id)

persons.each do |person|
  Profile.create(address: Faker::Address.street_address, person_id: person)
end

Tiếp theo bạn chạy lệnh: rails db:seed

Và bây giờ chúng ta sẽ tạo một controller là PersonController với action index bằng lệnh:

rails g controller people index

Và thêm logic để lấy ra tất cả các persons:

class PeopleController < ApplicationController
  def index
    @people = Person.all
  end
end

File people.html.erb:

<h1>Persons#index</h1>
<p>Find me in app/views/persons/index.html.erb</p>

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

File _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>

Thử xem hiệu suất của trang web.

Oh no, một con số khổng lồ để tải. Đó chỉ với 1 request, thử tưởng tượng chúng ta có 10, 100, 1000 request cùng một lúc, sẽ thế nào đây? Ngồi hơn 1 phút cũng không load được trang web, và điều đó bạn đang làm cho khách hàng của bạn vĩnh biệt trang web của bạn và không hẹn ngày gặp lại đó. Vậy để giữ chân khách hàng của bạn thì bạn phải làm gì? Tất nhiên là phải cải thiện rồi. Vậy cải thiện thế nào? Hãy cùng tôi đi từng phần nha.

1. Database queries

Bước đầu tiên để xây dựng một ứng dụng hiệu quả là cần tối đa việc sử dụng tài nguyên. Hầu hết các ứng dụng Rails đều lấy dữ liệu trong database để render ra views. Vì vậy chúng ta cần tối ưu hóa các lệnh gọi đến database.

  Rendering people/index.html.erb within layouts/application
  Person Load (2.0ms)  SELECT `people`.* FROM `people`
  ...
  Profile Load (1.2ms)  SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`person_id` = 998
  ↳ app/views/people/_person.html.erb:8
  Rendered people/_person.html.erb (Duration: 3.8ms | Allocations: 906)
  Profile Load (1.2ms)  SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`person_id` = 999
  ↳ app/views/people/_person.html.erb:8
  Rendered people/_person.html.erb (Duration: 3.5ms | Allocations: 906)
  Profile Load (1.2ms)  SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`person_id` = 1000
  ↳ app/views/people/_person.html.erb:8
  Rendered people/_person.html.erb (Duration: 3.4ms | Allocations: 906)
  Rendered people/index.html.erb within layouts/application (Duration: 3806.5ms | Allocations: 998071)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered layout layouts/application.html.erb (Duration: 3816.8ms | Allocations: 1002987)
Completed 200 OK in 3834ms (Views: 2552.5ms | ActiveRecord: 1267.9ms | Allocations: 1007551)

Nhìn ở trên bạn có thể thấy chúng ta đang có n+1 query.

1.1. N+1 QUERIES

Lỗi này khá phổ biến khi chúng ta có truy vấn lấy dữ liệu của bảng con thông qua bảng cha. Nhìn đoạn code render view ở file _person.html.erb chúng ta vừa sử dụng:

<% person.profiles.each do |profile| %>
  <li><%= profile.address %></li>
<% end %>

Về cơ bản, nó truy vấn đến database để lấy profile của person và render theo mỗi person. Đó là N Queries rồi (N chính là số lượng person) cộng thêm 1 query chính là SELECTpeople.* FROMpeople`` => Gọi là N+1 queris.

Để giải quyết vấn đền này, rails hỗ trợ một số cách như: Preload, Eagerload, IncludesJoins

Thử thay đối code controller 1 chút:

class PeopleController < ApplicationController
  def index
    @people = Person.all.includes(:profiles)
  end
end

Theo dõi log:

Person Load (2.5ms)  SELECT `people`.* FROM `people`
  ↳ app/views/people/index.html.erb:5
  Profile Load (3.5ms)  SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`person_id` IN (1, 2, 3, ..., 1000)
  ↳ app/views/people/index.html.erb:5
  Rendered people/_person.html.erb (Duration: 1.3ms | Allocations: 67)
  ...
  Rendered people/_person.html.erb (Duration: 1.7ms | Allocations: 67)
  Rendered people/index.html.erb within layouts/application (Duration: 945.2ms | Allocations: 177055)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered layout layouts/application.html.erb (Duration: 950.5ms | Allocations: 179923)
Completed 200 OK in 954ms (Views: 951.4ms | ActiveRecord: 7.1ms | Allocations: 181220)

Như bạn thấy đó thời gian load đã giảm từ 3834ms xuống còn 954ms, khá nhiều đúng không?. Tuy nhiên con số này vẫn còn khá lớn, nên hãy cùng tôi xem thêm các cách tiếp theo nha.

1.2. Chỉ load những phần sử dụng.

Như code bên trên, trang chủ của chúng ta sẽ trông như sau:

Bạn có thể thấy chúng ta chỉ cần địa chỉ, không cần thêm gì khác. Nhưng trong file _person.html.erb chúng ta load cả profile object. Thay đồi một chút nhé.

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

Theo dõi kết quả:

Completed 200 OK in 933ms (Views: 929.2ms | ActiveRecord: 10.2ms | Allocations: 183645)

Không đáng kể lắm nhỉ :v

1.3. Sử dụng phân trang

Một cách để cải thiện performane là sử dụng phân trang, là chỉ hiển thị với một số lượng dữ liệu vừa đủ, sau đó có thể xem thêm bằng cách click vào xem thêm. Bạn có thể tìm hiểu các gem phân trang như: pagy, will_paginate, kaminari...

Tùy vào yêu cầu của trang web mà bạn có thể sử dụng phân trang như click "next page/ trang tiếp theo" hay sử dụng "Infinite Scrolling" để cung cấp cho user những trải nghiệm tốt hơn.

2. Tránh tải lại HTML

Trong một ứng dụng Rails, HTML view mất khá nhiều thời gian để render. May mắn thay, Có một số phương pháp giúp chúng ta khắc phục điều này.

2.1 Sử dụng Turbolinks

Turbolink được thêm trong rails app, khi chúng ta chạy lệnh rails new. Turbolink là một thư viện Javascript hoạt động ở mọi nơi (ngay cả khi không có Rails, như trên các trang tĩnh) và nó có thể bi giảm chất lượng trên các trình duyệt không hỗ trợ. Nên bạn có thể cân nhắc khi sủ dụng thư viện này.

chuyển đổi mọi link thành một AJAX request và thay thế toàn bộ phần nội dung của trang thông qua JS. Điều này cải thiện đáng kể hiệu suất vì nó không tải lại CSS, JS và hình ảnh.

Tuy nhiên, khi custom lại JS bạn sẽ phản thận trọng hơn khi viết "Turbolinks safe JS". Đọc thêm ở đây

2.2. Sử dụng AJAX

Tương tự như Turbolinks, chúng ta cũng có thể chuyển đổi một số link và button thành các AJAX request. Sự khác biệt ở đây là chúng ta có thể kiểm soát những gì HTML được thay thế thay vì thay thế toàn bộ nội dung như Turbolinks làm.

3. Caching

Phần lớn thời gian load được sử dụng để render ra view. Điều này bao gồm việc load tất cả CSS, JS và hình ảnh, hiển thị HTML từ các file erb và nhiều hơn thế nữa.

Một cách nữa để giảm một phần thời gian load là xác định các phần hiển thị của ứng dụng sẽ ở trạng thái tĩnh trong một khoảng thời gian hoặc cho đến khi một sự kiện xảy ra.

3.1 Caching views

Như ví dụ trên để load 1000 records, chúng ta mất ~ 2000ms

Có một gem hỗ trợ việc caching view này là Dalli

Completed 200 OK in 1008ms (Views: 981.2ms | ActiveRecord: 11.8ms | Allocations: 289155)

3.2 Caching database queries

Một cách khác mà bạn có thể làm để cải thiện tốc độ là sử dụng cache, lưu dữ liệu vào cache.

Bạn có thể tìm hiểu thêm về redis và các gem dùng trong rails.

3.3. Database indexes

Bạn cũng có thể sử dụng index trong sql để tôi ưu hóa tốc độ. Tuy nhiên bạn cũng nên cân nhắc khi đánh index cho các trường trong database, chứ không phải cái nào cũng đánh index nhé =))

Kết luận

Trong bài viết này chúng ta đã khám phá cách cải thiện hiệu suất views của Rails app bằng nhiều cách khác nhau. Bạn có thể xem cái nào phù hợp với ứng dụng của bạn để áp dụng nhé. Và nếu có cách nào khác nữa, hy vọng bạn sẽ chia sẻ trong phần comment của bài viết. Cảm ơn rất nhiều. 😍

References:


All Rights Reserved