Khi phát triển ứng dụng, chúng ta thường có phương thức chạy chậm. Có thể chúng cần truy vấn database, hoặc gọi service bên ngoài, cả hai đều làm chậm. Chúng ta có thể gọi phương thức mỗi khi cần dữ liệu và chấp nhận overhead, nhưng nếu hiệu năng quan trọng, chúng ta có lựa chọn.
Thứ nhất, gán dữ liệu vào biến và tái sử dụng, sẽ tăng tốc. Dù là giải pháp khả thi, quản lý biến thủ công nhanh trở nên nhàm chán.
Nhưng, nếu phương thức làm "việc chậm" tự quản lý biến đó thì sao? Điều này cho phép gọi phương thức như bình thường, nhưng phương thức lưu và tái sử dụng dữ liệu. Đây chính xác là memoization.
Đơn giản, memoization là lưu giá trị trả về của phương thức để không phải tính toán lại mỗi lần. Giống mọi caching, bạn đổi bộ nhớ lấy thời gian (tức bỏ bộ nhớ lưu giá trị, nhưng tiết kiệm thời gian xử lý phương thức).
Cách memoize giá trị
Ruby cung cấp idiom rất sạch để memoize với toán tử or-equals: ||=. Nó dùng OR logic (||) giữa giá trị trái và phải, sau đó gán kết quả cho biến bên trái. Trong hành động:
value ||= expensive_method(123) #tương đương logic: value = (value || expensive_method(123))
Memoization hoạt động như thế nào
Để hiểu cách hoạt động, cần nắm hai khái niệm: giá trị "falsey" và lazy evaluation. Bắt đầu với truthy-falsey.
Truthy và Falsey
Ruby (như hầu hết ngôn ngữ) có từ khóa built-in cho boolean true và false. Chúng hoạt động như mong đợi:
if true #chúng ta luôn chạy này end if false # này sẽ không bao giờ chạy end
Tuy nhiên, Ruby (và nhiều ngôn ngữ khác) cũng có khái niệm giá trị "truthy" và "falsey". Nghĩa là giá trị có thể được đối xử "như thể" là true hoặc false. Trong Ruby chỉ nil và false là falsey. Tất cả giá trị khác (kể cả zero) được đối xử như true (lưu ý: ngôn ngữ khác chọn khác. Ví dụ C coi zero là false). Tái sử dụng ví dụ trên, cũng có thể viết:
value = "abc123" # một string if value # chúng ta luôn chạy này end value = nil if value # này sẽ không bao giờ chạy end
Lazy Evaluation (Đánh giá lười)
Lazy evaluation là dạng tối ưu hóa phổ biến trong ngôn ngữ lập trình. Nó cho phép chương trình bỏ qua các phép toán không cần thiết.
Toán tử OR logic (||) trả về true nếu bên trái hoặc bên phải true. Nghĩa là nếu đối số trái true, không cần đánh giá bên phải vì đã biết kết quả true. Nếu tự triển khai, có thể kết thúc như:
def logical_or (lhs, rhs) return lhs if lhs rhs end
Nếu lhs và rhs là hàm (ví dụ lambda), bạn thấy rhs chỉ thực thi nếu lhs falsey.
Or-Equals
Kết hợp hai khái niệm truthy-falsey và lazy evaluation cho thấy toán tử ||= làm gì:
value #mặc định nil value ||= "test" value ||= "blah" puts value => test
Bắt đầu với value là nil vì chưa khởi tạo. Tiếp, gặp toán tử ||= đầu. value falsey lúc này nên đánh giá bên phải ("test") và gán kết quả cho value. Giờ gặp ||= thứ hai, nhưng lần này value truthy vì có giá trị "test". Bỏ qua đánh giá bên phải và tiếp tục với value không đổi.
Quyết định khi nào dùng Memoization
Khi dùng memoization, cần tự hỏi: Giá trị được truy cập bao nhiêu lần? Điều gì làm nó thay đổi? Nó thay đổi bao nhiêu lần?
Nếu giá trị chỉ truy cập một lần, caching không hữu ích. Càng thường xuyên truy cập, càng nhiều lợi ích từ caching.
Về điều làm nó thay đổi, cần xem giá trị nào dùng trong phương thức. Nó nhận argument? Nếu có, memoization có thể cần tính đến điều này. Tôi cá nhân thích gem memoist vì nó xử lý argument cho bạn.
Cuối cùng, xem giá trị thay đổi bao nhiêu lần. Có instance variable làm nó thay đổi? Cần clear cached value khi chúng thay đổi? Giá trị nên cache ở cấp object hay class?
Để trả lời, hãy xem ví dụ đơn giản và đi qua quyết định:
class ProfitLossReport def initialize(title, expenses, invoices) @expenses = expenses @invoices = invoices @title = title end def title "#{@title} #{Time.current}" end def cost @expenses.sum(:amount) end def revenue @invoices.sum(:amount) end def profit revenue - cost end def average_profit(months) profit / months.to_f end end
Code gọi không hiển thị, nhưng đoán chừng title chỉ gọi một lần, cũng dùng Time.current nên memoize có thể làm giá trị nhanh bị stale.
Phương thức revenue và cost bị gọi nhiều lần ngay trong class. Vì cả hai cần truy database, chúng là ứng cử viên chính cho memoize nếu hiệu năng thành vấn đề. Giả sử memoize hai này, thì profit không cần memoize, nếu không chỉ thêm caching lên caching cho gain tối thiểu.
Cuối cùng, average_profit. Giá trị này phụ thuộc argument nên memoize phải tính đến. Cho trường hợp đơn giản như revenue, ta làm:
def revenue @revenue ||= @invoices.sum(:amount) end
Cho average_profit, cần giá trị khác cho mỗi argument. Có thể dùng memoist, nhưng để rõ ràng, tự triển khai:
def average_profit(months) @average_profit ||= {} @average_profit[months] ||= profit / months.to_f end
Ở đây dùng hash theo dõi giá trị đã tính. Đầu tiên đảm bảo @average_profit khởi tạo, sau đó dùng argument làm key hash.
Memoize ở cấp Class hay Instance
Thường memoization ở cấp instance, dùng instance variable lưu giá trị tính toán. Nghĩa là mỗi khi tạo instance mới, nó không hưởng giá trị "cached". Minh họa đơn giản:
class MemoizedDemo def value @value ||= computed_value end def computed_value puts "Crunching Numbers" rand(100) end end
Dùng object này thấy kết quả:
demo = MemoizedDemo.new => #<MemoizedDemo:0x00007f95e5d9d398> demo.value Crunching Numbers => 19 demo.value => 19 MemoizedDemo.new.value Crunching Numbers => 93
Có thể thay bằng biến cấp class (với @@) cho giá trị memoize:
def value @@value ||= computed_value end
Kết quả trở thành:
demo = MemoizedDemo.new => #<MemoizedDemo:0x00007f95e5d9d398> demo.value Crunching Numbers => 60 demo.value => 60 MemoizedDemo.new.value => 60
Bạn có thể không thường muốn class-level memoization, nhưng nó là tùy chọn. Tuy nhiên, nếu cần cache ở cấp này, đáng xem xét cache với external store như Redis hoặc memcached.
Các use case Memoization phổ biến trong Rails
Trong ứng dụng Rails, use case phổ biến nhất tôi thấy cho memoization là giảm database calls, đặc biệt khi giá trị không đổi trong một request. Phương thức "Finder" lookup record trong controller là ví dụ database call như:
def current_user @current_user ||= User.find(params[:user_id]) end
Nơi phổ biến khác là nếu dùng kiến trúc decorator/presenter/view-model cho render view. Phương thức trong object này thường có ứng cử viên memoization tốt vì chỉ tồn tại suốt request, dữ liệu thường không mutate, và một số phương thức có thể bị gọi nhiều lần khi render view.
Cạm bẫy Memoization
Một cạm bẫy lớn là memoize khi không thực sự cần. Việc như string interpolation trông như ứng cử viên memoization dễ, nhưng thực tế, chúng khó gây impact đáng kể đến hiệu năng site (trừ khi dùng string cực lớn hoặc lượng lớn string manipulation), ví dụ:
def title # memoization ở đây không có nhiều impact performance @title ||= "#{@object.published_at} - #{@object.title}" end
Điều khác cần cảnh giác là cache invalidation, đặc biệt nếu giá trị memoize phụ thuộc state của object. Cách giúp ngăn ngừa là cache ở cấp thấp nhất có thể. Thay vì cache phương thức tính a + b, tốt hơn cache riêng phương thức a và b.
# Thay vì này def profit # bất kỳ ai gọi 'revenue' hoặc 'losses' không hưởng caching ở đây # và nếu giá trị 'revenue' hoặc 'losses' thay đổi, chúng ta nhớ update profit? @profit ||= (revenue - losses) end # hãy thử này def profit # không còn cached, nhưng trừ là phép tính nhanh revenue - losses end def revenue @revenue ||= Invoice.all.sum(:amount) end def losses @losses ||= Purchase.all.sum(:amount) end
Cạm bẫy cuối do cách lazy evaluation hoạt động - bạn phải làm custom hơn nếu cần memoize giá trị falsey (tức nil hoặc false), vì idiom ||= luôn thực thi bên phải nếu giá trị lưu là falsey. Theo kinh nghiệm, không thường cần cache giá trị này, nhưng nếu cần, có thể thêm boolean flag chỉ đã tính, hoặc dùng cơ chế caching khác.
def last_post # nếu user không có bài viết, sẽ hit database mỗi lần gọi phương thức này @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first end # Workaround đơn giản: def last_post return @last_post if @last_post_checked @last_post_checked = true @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first end
Khi Memoization không đủ
Memoization có thể cách rẻ và hiệu quả cải thiện hiệu năng phần ứng dụng, nhưng không khuyết điểm. Một điểm lớn là persistence; đối với instance-level memoization phổ biến, giá trị chỉ lưu cho object cụ thể đó. Điều này làm memoization tuyệt vời lưu giá trị cho vòng đời web request, nhưng không cho lợi ích caching đầy đủ nếu giá trị giống nhau cho nhiều request và bị tính lại mỗi lần.
Class-level memoization có thể giúp, nhưng khó quản lý cache invalidation hơn. Không kể nếu server reboot, giá trị cache mất, và không chia sẻ giữa nhiều web server.
Trong bài tiếp theo của loạt bài caching, chúng ta sẽ xem giải pháp của Rails cho vấn đề này - low-level caching. Cho phép cache giá trị vào external store chia sẻ giữa server, và quản lý cache invalidation với timeout hết hạn và dynamic cache key.