12/08/2018, 14:16

Tối ưu hóa bộ nhớ khi sử dụng Rails

Vì sao phải tối ưu hóa bộ nhớ ? Chúng ta luôn nghĩ cái gì tối ưu là cần thiết, bộ nhớ thì đương nhiên là lại càng nên tối ưu, nếu dùng ít bộ nhớ thì ứng dụng của chúng ta se chạy nhanh hơn, có thêm nhiều tài nguyên để xử lý các tác vụ khác..., vì thế càng tối ưu nhiều càng tốt. Câu hỏi đặt ra ...

Vì sao phải tối ưu hóa bộ nhớ ?

Chúng ta luôn nghĩ cái gì tối ưu là cần thiết, bộ nhớ thì đương nhiên là lại càng nên tối ưu, nếu dùng ít bộ nhớ thì ứng dụng của chúng ta se chạy nhanh hơn, có thêm nhiều tài nguyên để xử lý các tác vụ khác..., vì thế càng tối ưu nhiều càng tốt.

Câu hỏi đặt ra là liệu việc tối ưu có cần thiết và có tốt không ?

Nguyên tắc đầu tiên đó là Don't do it - tức là Đừng làm. Sao lại đừng làm ? các lập trình viên có kinh nghiệm vẫn luôn thực hiện tối ưu theo thời gian mà. Đúng vậy, nguyên tắc này không có ý nói chúng ta không được tối ưu bộ nhớ mà chỉ là viết tắt bởi cho một vài cảnh báo sau:

  • Việc tối ưu hóa có thể là không cần thiết.
  • Việc tối ưu hóa có thể không khả thi và se làm lang phí thời gian của bạn.
  • Bạn có thể tạo nên nhưng sai sót khi quá tập trung vào việc tối ưu.
  • Việc tối ưu của bạn có thể khiến đoạn code của bạn trở lên khó hiểu hơn.

Do đó để biết chắc là mình có nên tối ưu hóa không, bạn nên đặt ra và trả lời các câu hỏi sau:

  • Bạn có thực sự cần tối ưu hóa chưa ? Đó có thực sự là vấn đề khiến ứng dụng chậm đi không, hay chỉ là suy đoán của bạn ? Nếu nó chỉ là suy đoán, tốt nhất không nên làm gì cả. Hay http://wiki.c2.com/?YouArentGonnaNeedIt và làm các vấn đề khác quan trọng hơn.
  • Bạn có biết được gốc rê của vấn đề ? Nếu không thấy hay tìm ra nó bởi nếu không, bạn se chỉ đoán, nhưng rất có thể là phán đoán của bạn sai.
  • Có cách khác để giải quyết được vấn đề không ? Nó có thể được giải quyết bởi một phương pháp khác, bạn hay suy nghĩ rộng hơn xem. Ví dụ, để tránh việc tối ưu hóa phức tạp, để giải quyết vấn đề bộ nhớ, chúng ta hoàn toàn có thể tạo một worker trên server khởi động lại bất kỳ process Ruby nào bắt đầu sử dụng quá nhiều bộ nhớ. Với rails, chúng ta có thể sử dụng unicorn-worker-killer.

Và khi đã tự đặt câu hỏi và thấy rằng không còn cách nào khác, thì đó là lúc bạn cần thực hiện tối ưu hóa. Nhưng trước khi tối ưu bạn cần thiết lập các số liệu.

Thiết lập các số liệu

Khi tối ưu hóa, chúng ta cần có những con số để đánh giá được công việc tối ưu của mình, và bây giờ là lúc bạn thiết lập các số liệu.

Bạn cần phải đo bộ nhớ sử dụng sau hàng chục và hàng trăm request, một request là không đủ vì việc cấp phát bộ nhớ của Ruby không thực sự tốt. Bạn có thể sử dụng đoạn script để đo tổng số sử dụng bộ nhớ của process Rails của bạn sau 30 request.

Điều chỉnh garbage collection

Sau khi đã có được những số liệu đo đạc việc sử dụng bộ nhớ cho ứng dụng của bạn, đầu tiên ta cần thay đổi các tham số garbage collection trước khi đổi code ứng dụng.

Bạn có thể thay đổi tần suất mà Ruby đòi bộ nhớ trống bằng cách thiết lập lại hàng loạt các biến môi trường. Các biến dưới đây là các giá trị mặc định của chúng và cận dưới là đối với Ruby 2.2.0

RUBY_GC_HEAP_FREE_SLOTS=4096              #           Must be > 0
RUBY_GC_HEAP_INIT_SLOTS=10000             #           Must be > 0
RUBY_GC_HEAP_GROWTH_FACTOR=1.8            #           Must be > 1.0
RUBY_GC_HEAP_GROWTH_MAX_SLOTS=0           # Disabled; Must be > 0
RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR=2.0   #           Must be > 0
RUBY_GC_MALLOC_LIMIT=16777216             # 16 MiB;   Must be > 0
RUBY_GC_MALLOC_LIMIT_MAX=33554432         # 32 MiB;   Must be > 0
RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR=1.4    #           Must be > 1.0
RUBY_GC_OLDMALLOC_LIMIT=16777216          # 16 MiB;   Must be > 0
RUBY_GC_OLDMALLOC_LIMIT_MAX=134217728     # 128 MiB;  Must be > 0
RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR=1.2 #           Must be > 1.0

Khi chúng ta thiết lập các giá trị nhỏ hơn thì Ruby sẽ kích hoạt GC thường xuyên hơn. Nhưng chúng ta nên thay đổi như thế nào đây ?

2 giá trị RUBY_GC_HEAP_GROWTH_FACTOR và RUBY_GC_HEAP_GROWTH_MAX_SLOTS khi được giảm xuống sẽ kích hoạt việc chạy GC thường xuyên hơn.

Bạn cũng có thể hạ thấp việc Ruby được phép cấp phát off-heap bao nhiêu bộ nhớ khi Ruby chạy GC.

#Đối với minor GC
RUBY_GC_MALLOC_LIMIT=4000100
RUBY_GC_MALLOC_LIMIT_MAX=16000100
RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR=1.1
#Đối với full GC
RUBY_GC_OLDMALLOC_LIMIT=16000100
RUBY_GC_OLDMALLOC_LIMIT_MAX=16000100

Tuy nhiên giảm giá trị để kích hoạt GC chạy thường xuyên hơn nhiều khi có thể khiến ứng dụng của bạn chậm hơn hoặc làm tăng việc sử dụng bộ nhớ, do đó trước khi quyết định thay đổi, chúng ta cần kiểm tra lại các thiết lập số liệu bằng cách đo lại các thông số sau khi thay đổi. (nếu bạn muốn biết rõ hơn cần thay đổi như thế nào thì có thể tham khảo thêm tại đây, tôi sẽ tìm hiểu và nói rõ hơn ở một bài viết khác).

Thay đổi code ứng dụng

Nếu việc thay đổi GC không đủ giảm việc sử dụng bộ nhớ của ứng dụng, thì bạn nên thay đổi code trong ứng dụng. Nhưng để tìm ra đoạn code thực hiện cấp phát bộ nhớ là việc khá mất thời gian. Nhưng gem memory_profiler sẽ chỉ ra cho bạn nơi đoạn mã của bạn cấp phát bộ nhớ hoặc bạn có thể đếm nơi phân bổ đối tượng của bạn với gem stackprof

Ở đây tôi sẽ đưa ra một số gợi ý khi đổi code giúp tối ưu việc sử dụng bộ nhớ.

Trong Rails, đối tượng ActiveRecord sử dụng bộ nhớ rất tham lam. Nếu bạn chỉ sử dụng khoảng 30 đối tượng ActiveRecord thì không vấn đề nhiều, nhưng nếu có 300 hay 3000 thì nó thực sự rất ngốn bộ nhớ. Vì thế các hữu ích nhất là sử dụng pluck để tránh sử dụng các đối tượng ActiveRecord.

# This uses WAY more memory (and time)...
User.select(:id).map(&:id)

# ...than this!
User.pluck(:id)

User.pluck(:id, :name, :email)
# [
#   [123, "Alice",   "alice@example.com"],
#   [124, "Brian",   "brian@example.com"],
#   [125, "Cynthia", "cynthia@example.com"]
# ]

Mặc dù sử dụng pluck giúp hạn chế khá nhiều bộ nhớ so với việc dùng ActiveRecord nhưng nếu tập dữ liệu của bạn quá lớn, bạn nên tránh load dữ liệu từ tất cả record cùng một lúc. Chúng ta có thể duyệt qua tất cả mà không cần giữ toàn bộ kết quả trong bộ nhớ bằng cách sử dụng find_each:

User.find_each.lazy.map(&:some_calculation_in_ruby).reduce(:+)

find_each giúp tải các record thành một tập 1000 record, sau đó mỗi 1000 record này sẽ được xử lý để nó có thể được thu gom bởi GC. lazy đảm bảo map không tạo ra một mảng lớn.

Nếu bạn cần tải trước association để tránh n+1 query, bạn có thể sử dụng:

User.includes(:posts).find_each.lazy
# Mỗi user sẽ có user.post được load trước

Một điều nữa, bạn không nên bỏ qua những việc sửa đổi nhỏ nếu thấy nó không khiến giảm nhiều bộ nhớ, vì nếu bạn cải thiện nhiều việc nhỏ đó thì ứng dụng của bạn sẽ giảm đáng kể bộ nhớ đấy. Tôi có một ví dụ cho việc sửa đổi nhỏ ảnh hưởng thế nào             </div>
            
            <div class=

0