12/08/2018, 16:07

Một vài thủ thuật cải thiện performance cho Rails

Chúng ta biết rằng Ruby đã có nhiều cải tiến từ version 1.9 và đã được chứng mình là một option có khả năng mở rộng cho app web. Tuy nhiên, thực tế là nó vẫn chưa nhanh và không thể cải thiện sớm được, vì trong thời gian xử lý của Ruby phải đảm nhiệm quá nhiều nhiệm vụ và cơ chế garbage collection ...

Chúng ta biết rằng Ruby đã có nhiều cải tiến từ version 1.9 và đã được chứng mình là một option có khả năng mở rộng cho app web. Tuy nhiên, thực tế là nó vẫn chưa nhanh và không thể cải thiện sớm được, vì trong thời gian xử lý của Ruby phải đảm nhiệm quá nhiều nhiệm vụ và cơ chế garbage collection cũng là một yếu tố chính ảnh hưởng đến tốc độ thực thi.

Trong bài viết này, tôi sẽ đề cập một vài vấn đề performance thường gặp và cách để khắc phục chúng.

Truy cập database

Vấn đề thường gặp nhất, đó là các hoạt động tương tác với database chậm, nguyên nhân do thời gian đọc ghi vào đĩa cứng quá lâu, vì vậy chúng ta phải xử lý một cách thông minh. Tuy nhiên, thật khó khăn để thấy được cách mà database hoạt động bởi vì hầu như ta chỉ làm việc với khái niệm trừu tượng của database.

VD hay gặp là

Truy cập vào các quan hệ, cần lưu ý đến vấn đề query N + 1

class Driver < ApplicationRecord
  has_many :orders
end
class Order < ApplicationRecord
  belongs_to :driver
end

Ta muốn duyệt qua toàn bộ player và các achievement của player

Driver.all.each do |driver|
  driver.orders.each do |order|
    order.amount
  end
end

Theo đó, sẽ có một query cho từng player để lấy ra achievement., đó là vấn đề query N + 1

  Driver Load (1.5ms)  SELECT `drivers`.* FROM `drivers`
  Order Load (129.4ms)  SELECT `orders`.* FROM `orders` WHERE  `orders`.`driver_id` = 1
  Order Load (55.8ms)  SELECT `orders`.* FROM `orders` WHERE `orders`.`driver_id` = 2
  Order Load (1.7ms)  SELECT `orders`.* FROM `orders` WHERE `orders`.`driver_id` = 3
  Order Load (1.6ms)  SELECT `orders`.* FROM `orders` WHERE `orders`.`driver_id` = 4
  ......

ActiveRecord đã cung cấp một hàm hữu ích cho vấn đề này là #includes, đoạn code trên được thay đổi như sau:

Driver.all.includes(:orders).each do |driver|
  driver.orders.each do |order|
    order.amount
  end
end

Thay vì phải chạy 1 query để lấy list player và thêm N query nữa để lấy achievment của từng player, thì chỉ còn 2 query,

  Driver Load (0.9ms)  SELECT `drivers`.* FROM `drivers` 
  Order Load (9.9ms)  SELECT `orders`.* FROM `orders` WHERE `orders`.`driver_id` IN (1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 18, 20, 21, 26)

Sử dụng các hàm tập hợp cho database

Với yêu cầu lấy min, max, hay filter kết quả database, thì việc sử dụng các hàm xử lý Array và Hash ( như #map, #min, #max, #reject, #inject, ... ) sẽ thật là đơn giản, tuy nhiên, thay vì đưa xử lý vào trong Ruby, thì tính toán các yêu cầu đó trong database là một cách nhanh hơn so với phải làm việc với một Enumerable quá lớn.

VD: ta muốn SUM tất cả id của 1 bảng với 1 điều kiện nào đó

# Sử dụng SQL
 Order.select("sum(case when orders.status = 1 then orders.id else 0 end) as sum_id").first.attributes
  Order Load (3.0ms)  SELECT  sum(case when orders.status = 1 then orders.id else 0 end) as sum_id FROM `orders` WHERE `orders`.`deleted_at` IS NULL  ORDER BY `orders`.`id` ASC LIMIT 1
=> {"id"=>nil, "sum_id"=>2006854}

# Sử dụng hàm inject trong Ruby 
Order.all.inject(0){|res, o| res = res + ( o["status"] == 1 ? o.id : 0 ); res }
  Order Load (8.3ms)  SELECT `orders`.* FROM `orders` WHERE `orders`.`deleted_at` IS NULL
=> 2006854

Ở cách bên dưới thì do phải mất thời gian duyệt từng order nên thời gian mất nhiều lần so với chạy function trong SQL, vì không cần phải load object vào trong ruby.

Lưu ý đến transaction

Thêm một điều nữa là bên dưới sự trừu tượng là điều khiển transaction. Khi bạn save trong ActiveRecord, Rails sẽ tự động mở một transaction và COMMIT sau khi được INSERT. Vì vậy, chạy lệnh

(1..5000).each do
  Player.create(name: 'Lorem Ipsum', email: 'lorem@ipsum.br')
end

Sẽ tạo và commit 5k transactions, và kết quả benchmark là gần 14 giây. Phụ thuộc vào yêu cầu bài toán, cần phải xem xét việc tạo một transaction trong mỗi vòng lặp, thông thường, nên đặt tất cả các hoạt động vào chỉ một transaction

ActiveRecord::Base.transaction do
  (1..5000).each do
    Player.create(name: 'Homer', email: 'lorem@ipsum.br')
  end
end

Với cách sửa này, thời gian thực thi chỉ gần 3.5 giây, một sự cải thiện không nhỏ.

Điều khiến Ruby chậm là gì?

Ngoài vấn đề kết nối với database và các phụ thuộc khác, có nhiều cách để giải quyết performance. Trong quyển sách Ruby Performance Optimization, Why Ruby Is Slow and How to Fix It đã giải thích tại sao Ruby chậm , nguyên nhân chính là do vấn đề tiêu thụ bộ nhớ và cơ chế quản lý garbage collector trong Ruby.

VD:

data = Array.new(1024) { Array.new(512) { 'x' * 2048 } }
Benchmark.realtime do
  data.map do |row|
    row.map { |col| col.upcase }
  end
end

Lệnh trên mất 7.25 giây để thay đổi tất cả các từ thành in hoa. Tuy nhiên, nếu chúng ta disable garbage collector ( bằng cách gọi GC.disable), thời gian chạy sẽ giảm 2.68 giây. Ta có thể thấy, 20% thời gian thực thi là của garbage collector làm việc. Càng tệ hơn là bộ nhớ bị sử dụng nhiều hơn bởi vì mọi thứ trong Ruby là object. Dĩ nhiên, chúng ta không thể chặn GC vì sẽ khiến cho bộ nhớ sẽ nhanh chóng bị tràn, do sẽ chứa đầy rác. Cho nên, mục tiêu để giúp chương trình nhanh hơn là sử dụng ít bộ nhớ hơn.

Trong vd bên dưới, thay vì gọi hàm map và upcase, ta sử dụng hàm map! và upcase!, bởi vì map và upcase sẽ tạo một Array và String mới tương ứng. Còn map! và upcase! sẽ xử lý trực tiếp với object thay vì tạo mới object, nên sẽ giảm thời gian chạy chương trình 2.2 giây.

data.map! do |row|
  row.map! { |col| col.upcase! }
end

Lưu trữ bộ nhớ từ ActiveRecord

Chúng ta đã biết rằng ta phải giảm bớt tiêu thụ bộ nhớ, có một cách để tiết kiệm bộ nhớ là chỉ chọn các giá trị cần thiết trong query. VD, nếu bạn lấy một danh sách 15k model có 15 attributes kiểu string, nhưng chỉ sử dụng 2 trong số chúng, cách thường dùng nhất là lấy tất cả attributes, điều đấy sẽ tiêu tốn 224ms

Thing.all.each { |thing| thing }

Nếu chỉ chọn các trường mà chúng ta cần thì sẽ chỉ tốn 138ms

Thing.select(:field_1, :field_2).each { |thing| thing }

Và nếu sử dụng #pluck thay vì select để trả về một Array thay vì ActiveRecord thì sẽ chỉ mất 43ms.

Thing.pluck(:field_1, :field_2).each { |thing| thing }

Với cách dùng #pluck đã tăng 80% tốc độ hơn phương pháp đầu tiên.

Ngoài ra, bạn có thể tạo query mà không cần khởi tạo model bằng cách gọi ActiveRecord::Base.connection.execute.

Tăng tốc render view

Để tăng tốc render view, ta có thể sử dụng một vài cách sau:

index.html.erb
<% @things.each do |thing| %>
  <%= render 'thing', thing: thing %>
<% end %>
_thing.html.erb
<%= thing.field_1 %>

Kết quả của việc render 1k ActiveRecord này mất rất nhiều thời gian

Completed 200 OK in 627ms (Views: 624.3ms | ActiveRecord: 0.8ms)

Nếu ta có thể render bằng cách truyền vào collection như sau:

<%= render partial: 'thing', collection: @things, as: :thing %>

Tổng thời gian giảm xuống chỉ còn 33ms, cải thiện 90% tốc độ.

Completed 200 OK in 33ms (Views: 30.7ms | ActiveRecord: 0.7ms)

Trong cuốn sách Ruby Performance Optimization, Why Ruby Is Slow and How to Fix It , tác giả Alexander Dymo đã giải thích:

Lý do tại sao render một collection nhanh hơn là vì trình biên dịch đã khởi tạo template chỉ một lần. Sau đó, nó sử dụng lại cùng template đấy để render tất cả các đối tượng từ collection. Render 10000 partial trong một vòng lặp sẽ phải lặp lại việc khởi tạo 10000 lần.

Tóm lại

Trên đây là một số cách để cải thiện tốc độ hệ thống. Tuy nhiên, các vấn đề trong thực tế có thể khó xác định hơn và khó sửa hơn. Thỉnh thoảng, bạn cần phải giảm bớt độ phức tạp của thuật toán, sử dụng cache hay phải thay đổi cả cấu trúc giải pháp của bạn (VD: có thể chuyển logic vào background hay micro services, và cả tăng bộ nhớ của server) để tăng tốc nhanh hơn.

0