Cải thiện hiệu năng Rails app với cache

Khi mà application còn nhỏ và chỉ có một số lượng khách ghé thăm hạn chế thì mọi chức năng luôn hoạt động nhanh gọn và trôi chảy. Ở tình trạng này ta không cần phải bỏ nhiều công sức để lo lắng cho performance và optimization, mặc dù thời gian xử lý cũng là một yếu tố cạnh tranh. Nhưng theo thời gian, website phát triển lớn mạnh hơn, lượng dữ liệu và số lượt truy cập ngày một tăng, kéo theo thời gian loading cũng nhiều thêm...

Đi cùng với việc performace giảm xuống, các user ngày càng trở nên khó chịu mỗi khi chờ website load hết nội dung.Thời điểm này chính là dấu hiệu website cần được cải thiện hiệu năng. Nhưng trước khi mở lại rà soát tất cả code và query, hãy thực hiện thêm một bước nho nhỏ. Đó là thêm cache vào cho website, việc này sẽ giúp cải thiện thời gian loading đáng kể và làm cho application có thể chịu được thêm nhiều traffic nữa.

Cache là gì và khi nào thì nên sử dụng cache?

Các trang web thường được xây dựng trên các thành phần ít thay đổi. Thường thì một application cần phải xử lý mọi thứ được định nghĩa trên view, ví dụ, một list các object trong một vòng lặp hoặc lấy một số thuộc tính từ mỗi object và mỗi lần lấy object lại phải request từ browser. Đó là một cả sự lãng phí về thời gian và tài nguyên mà có thể dùng cho việc khác. Cache cho phép chúng ta lưu trữ trong bộ nhớ những phần trùng lặp của view và tái sử dụng chúng mỗi lần vào lại trang web.

Ta có thể cache luôn cả trang web và bỏ qua luôn tất cả query đến database bằng cách gửi trang web đã lưu sẵn đến user, phương pháp này tiết kiệm được nhiều tài nguyên nhất nếu khả thi. Nhưng với những trang web có tính tương tác đơn giản như trang tin tức hay comment, user thêm nội dung mới thường xuyên, và cả bộ đếm comment cũng thay đổi liên tục. Nếu ta cache cả page, user sẽ luôn chỉ thấy nội dung cũ, bộ đếm hiện lên luôn sai so với thực tế. Dĩ nhiên ta có thể loại bỏ cache mỗi khi có người đăng thêm comment, nhưng làm như thế rất không hiệu quả, loại bỏ đi ý nghĩa ban đầu của cache. Để giải quyết vấn đề này, ta chỉ cần cache từng bộ phận của trang riêng biệt.

Rails cung cấp một phương pháp cache riêng biệt gọi là fragment cache tập trung giải quyết vấn đề này. Dưới đây là một ví dụ về áp dụng cache:

<% cache article do %>
  <%= render article %>
<% end %>

Như ta thấy sử dụng method cache để bọc đoạn code mà trong đó dữ liệu sẽ được xử lý. Ở lần request đầu tiên sẽ không tìm thấy cache, nên đoạn code bên trong sẽ tiến hành xử lý và thêm phần xử lý vào cache. Từ lần thứ hai trở đi mỗi khi truy vấn đến một khối cache đó, server sẽ tìm được đoạn cache đã được lưu và sử dụng nó chứ không thực thi xử lý trong code nữa. Câu hỏi ở đây là làm thế nào mà app có thể nhận ra và phân biệt các khối cache để đưa ra đúng. Câu trả lời là mỗi khối cache sẽ được đưa cho một key duy nhất, app có nhiệm vụ tạo ra key đó để tìm và tạo khối cache. Với ví dụ trên thì khối cache sẽ có dạng:

views/articles/2-20161018190855530909000/g5ebea385672ogt530mjkirh046102w4

Đây là một key mặc định khi đưa vào tham số là một object. Phần đầu tiên là đường dẫn đến view định nghĩa cache, phần số dài tiếp theo là giá trị updated_at của object và phần cuối là một hash được tạo ra dựa trên view.

Vai trò lớn nhất của key là để ngăn chúng ta sử dụng các khối cache cũ không thể phản ánh đúng tình trạng hiện tại của data trên trang web. Như được nói ở trên key được tạo ra có đính kèm cả giá trị updated_at của object, mà mỗi lần thay đổi object thì giá trị updated_at cũng thay đổi. Từ thời điểm này, key được sinh ra sẽ khác với key được lưu trước đó, và sẽ không được tìm thấy mà yêu cầu phải tạo một khối cache mới dựa trên data hiện tại.

Cache lồng nhau

Trong quá trình sử dụng cache tôi đã có một phát hiện thú vị. Đó là ta có thể gọi cache bên trong một cache khác. Ta có thể sử dụng một vòng lặp như ví dụ dưới đây:

Trong controller:

@articles = Article.all

Trong view:

<% cache @articles do %>
  <% @articles.each do |article| %>
    <% cache article do %>
      <%= render article %>
    <% end %>
  <% end %>
<% end %>

Ta có N khối cache cho mỗi article và một cache lớn cho toàn bộ vòng lặp. Khi không có thay đổi ở key phía trên, khối cache lớn sẽ được tìm thấy và gửi đến user mà không cần xử lý code trong khối. Khi có thay đổi ở một article, thay đổi nay cũng có tác động đến khối cache lớn, server sẽ kiểm tra lại các khối cache con, các khối cache cũ sẽ được thay thế và các khối cache còn lại vẫn được tái sử dụng.

Như có thể thấy ta sử dụng ActiveRecord::Relation để làm tham số cho hàm cache. Trong trường hợp này key sinh ra sẽ sử dụng giá trị updated_at mới nhất trong relation.

Về khía cạnh hiệu năng, nếu không có thành phần nào thay đổi, ta chỉ cần tìm và tạo key duy nhất một lần, ngược lại ta cần phải quét qua tất cả các key một lượt để kiểm tra các phần tử con. Dù vậy cache cả vòng lặp vẫn có lợi thế vì ta có thể tái sử dụng cache của những phần tử vẫn chính xác.

Tạo một cache key tốt

Các ví dụ ở trên khá đơn giản và chỉ bao gồm một object độc lập. Nhưng nếu ta không chỉ cache thông tin từ một object, mà cache từ một partial hoặc một đoạn code lớn chứa data của các object liên quan, như ví dụ dưới đây:

<% cache article do %>
  <%= article.name %>
  <%= article.user.username %>
<% end %>

Đoạn code trên trong có vẻ sẽ hoạt động bình thường. Nếu ta đổiname của article, khối cache sẽ được cập nhập, theo đúng lý thuyết của cache. Nhưng nếu quan sát kỹ hơn, key của cache chỉ chứa giá trị updated_at của object article, nhưng thông tin cache không chỉ là của article, mà còn có thông tin của user, vậy nếu thay đổi username, thay đổi này sẽ không được phản ánh trên view, vì key không chứa updated_at của user, nên cũng không được cập nhập nếu user thay đổi. Để làm cho cache hoạt động đúng, ta cần tạo một array bao gồm tất cả các object được nhắc đến trong khối cache. Các object truyền vào sẽ tạo nên một key có chứa updated_at của cả hai object. Code đúng sẽ có dạng:

<% cache [article, article.user] do %>
  <%= article.name %>
  <%= article.user.username %>
<% end %>

Làm việc với nhiều object có một cách giải quyết khác, đơn giản hơn để update cache. Đó là thiết lập một callback để update trong quan hệ:

class User < ApplicationRecord
  belongs_to :article, touch: true
end

Nhờ thế ta chỉ cần truyền một tham số:

<% cache article do %>
  <%= article.name %>
  <%= article.user.username %>
<% end %>

Sử dụng touch: true ở model user sẽ giúp cho updated_at của article sẽ được update mỗi khi update user.

Ở đây hoàn toàn dựa vào sự cẩn thận để đảm bảo chắc chắn rằng cache sẽ hoạt động đúng và được cập nhập mỗi khi cần thiết. Ta cần chú ý kiểm tra xem mỗi khối cache có chứa những gì và phần nào có thể thay đổi ở đâu. Nó có thể là cả một object, hoặc một số thuộc tính hay dữ liệu đã được xử lý trước. Các tốt nhất là kiểm tra phân tích toàn bộ khối cache và ghi lại các object có thể thay đổi được. Sau đó truyền các object liên quan trong một array.

Ví dụ:

Trong controller:

@category = Category.first
@articles_from_category = category.articles

Và trong view:

<% cache [@articles_from_category, @category] do %>
  <% @articles_from_category.each do |article| %>
    <% cache [article, @category] do
      <%= article.name %>
      <%= article.category.title %>
    <% end %>
  <% end %>
<% end %>

Giống như ví dụ phía trên, ta có thể truyền một ActiveRecord::Relation (ở đây là articles_from_category) bên trong array. Điều này sẽ tạo ra một key bao gồm updated_at của article mới nhất của category và giá trị updated_at của chính category đó.

Bài học rút ra là: nếu ta truyền vào một ActiveRecord::Relation, method cache sẽ chỉ lấy updated_at của phần tử được update mới nhất. Nếu ta truyền vào một array, method cache sẽ lấy updated_at của mỗi phần tử trong array để cho vào key.

Config cache

Nếu muốn bắt đầu sử dụng cache, mở file config/environments/development.rb và thêm một dòng:

config.action_controller.perform_caching = true

Sau đó restart server nếu app vẫn đang chạy. Kể từ lúc này, các khối cache sẽ bắt đầu được lưu lại. Ta có thể tìm thấy ở thư mục tmp/cache.

Với Rails 5 có một cách mới để khởi động cache ở development mode.

Ta chỉ cần gõ dev:cache ở rails console để tạo file caching-dev.txt trong thư mục tmp.

Việc tạo ra file này sẽ kích hoạt một config cache hoàn chỉnh trong file config/environments/development.rb có dạng như sau:

if Rails.root.join(‘tmp/caching-dev.txt’).exist?
  config.action_controller.perform_caching = true
  config.static_cache_control = “public, max-age=172800”
  config.cache_store = :mem_cache_store
else
  config.action_controller.perform_caching = false
  config.cache_store = :null_store
end

Tất nhiên ta có thể chỉnh sửa các thông số của file này dựa trên nhu cầu thực tế.

Kết luận

Cache rất hữu dụng để cải thiện performance, nhưng cần phải sử dụng cẩn thận. Mỗi khi ta tạo một khối cache mới thì ta mất thêm một khoảng thời gian so với bình thường. Vậy nên nếu trang web không phổ biến lắm, rất có thể cache sẽ được tạo mới mỗi khi có user sử dụng và không được tái sử dụng bao giờ. Điều này khiến cho cache trở thành vô dụng và thậm chí gây ảnh hưởng đến cộng đồng user vốn đã khá nhỏ. Ranh giới cho việc này khá nhỏ và việc lạm dụng cache sẽ khiến cho cả trang web chậm hơn và chiếm dụng hết bộ nhớ trống. Nhưng nếu được sử dụng hợp lý cache sẽ giúp cải thiện phần lớn hiệu năng, đem lại sự thoải mái cho cả user và developer.