字段級 Rails 緩存失效:一種 DSL 解決方案
已發表: 2022-03-11在現代 Web 開發中,緩存是一種快速而強大的加速方式。 如果做得好,緩存可以顯著提高應用程序的整體性能。 如果做錯了,它肯定會以災難告終。
您可能知道,緩存失效是計算機科學中三個最困難的問題之一——另外兩個是命名事物和非一個錯誤。 一種簡單的解決方法是在發生變化時使左右所有內容無效。 但這違背了緩存的目的。 您只想在絕對必要時使緩存無效。
如果您想充分利用緩存,您需要非常注意您的無效內容,並避免您的應用程序在重複工作上浪費寶貴的資源。
在這篇博文中,您將學習一種更好地控制 Rails 緩存行為方式的技術:具體而言,實現字段級緩存失效。 這種技術依賴於 Rails ActiveRecord 和ActiveSupport::Concern
以及對touch
方法行為的操作。
這篇博文基於我最近在一個項目中的經驗,在該項目中,我們看到在實施字段級緩存失效後性能有了顯著提高。 它有助於減少不必要的緩存失效和模板的重複呈現。
Rails、Ruby 和性能
Ruby 不是最快的語言,但總體而言,它是涉及開發速度的合適選擇。 此外,它的元編程和內置的特定領域語言 (DSL) 功能為開發人員提供了極大的靈活性。
有像 Jakob Nielsen 的研究這樣的研究表明,如果一項任務需要超過 10 秒,我們就會失去注意力。 重新集中註意力需要時間。 所以這可能會出乎意料地昂貴。
不幸的是,在 Ruby on Rails 中,模板生成很容易超過 10 秒的閾值。 你不會在任何“hello world”應用程序或小型寵物項目中看到這種情況,但在實際項目中,很多東西都加載到一個頁面上,相信我,模板生成很容易開始拖拽。
而且,這正是我在項目中必須解決的問題。
簡單優化
但是你究竟是如何加快速度的呢?
答案:基準測試和優化。
在我的項目中,兩個非常有效的優化步驟是:
- 消除 N+1 個查詢
- 為模板介紹一種良好的緩存技術
N+1 個查詢
修復 N+1 個查詢很容易。 您可以做的是檢查您的日誌文件——每當您在日誌中看到多個 SQL 查詢(如下所示)時,通過將它們替換為預加載來消除它們:
Learning Load (0.4ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ? Learning Load (0.3ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ? Learning Load (0.4ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ?
有一個寶石被稱為子彈來幫助檢測這種低效率。 您還可以瀏覽每個用例,同時通過根據上述模式檢查日誌來檢查日誌。 通過消除所有 N+1 的低效率,您可以有足夠的信心確保您的數據庫不會超載,並且您在 ActiveRecord 上花費的時間將顯著減少。
進行這些更改後,我的項目已經運行得更加活躍了。 但我決定把它提升到一個新的水平,看看我是否可以進一步縮短加載時間。 模板中仍然發生了一些不必要的渲染,最終,這就是片段緩存提供幫助的地方。
片段緩存
片段緩存通常有助於顯著減少模板生成時間。 但是默認的 Rails 緩存行為並沒有為我的項目削減它。
Rails 片段緩存背後的想法非常棒。 它提供了一種超級簡單有效的緩存機制。
Ruby On Rails 的作者在 Signal v. Noise 中寫了一篇非常好的文章,介紹了片段緩存的工作原理。
假設您有一些顯示實體的某些字段的用戶界面。
- 在頁面加載時,Rails 根據實體的類和
updated_at
字段計算cache_key
。 - 使用該
cache_key
,它會檢查緩存中是否有與該鍵關聯的任何內容。 - 如果緩存中沒有任何內容,則為視圖呈現該片段的 HTML 代碼(並且新呈現的內容存儲在緩存中)。
- 如果緩存中存在具有該鍵的任何現有內容,則使用緩存的內容呈現視圖。
這意味著緩存永遠不需要顯式失效。 每當我們更改實體並重新加載頁面時,都會為實體呈現新的緩存內容。
默認情況下,Rails 還提供了在子實體更改時使父實體的緩存無效的功能:
belongs_to :parent_entity, touch: true
當包含在模型中時,當孩子被觸摸時,它將自動觸摸父母。 您可以在此處了解有關touch
的更多信息。 有了這個,Rails 為我們提供了一種簡單有效的方法來使父實體的緩存與子實體的緩存同時失效。
Rails 中的緩存
但是,在 Rails 中創建緩存是為了服務用戶界面,其中代表父實體的 HTML 片段包含僅代表父實體的子實體的 HTML 片段。 換句話說,在這個範例中代表子實體的 HTML 片段不能包含來自父實體的字段。
但這不是現實世界中發生的事情。 您很可能需要在 Rails 應用程序中做一些違反此條件的事情。
您將如何處理用戶界面在表示子實體的 HTML 片段中顯示父實體的字段的情況?
如果子實體包含來自父實體的字段,那麼您會遇到 Rails 的默認緩存失效行為。
每次修改來自父實體的那些字段時,您都需要觸摸屬於該父實體的所有子實體。 例如,如果Parent1
被修改,您將需要確保Child1
和Child2
視圖的緩存都無效。
顯然,這會導致巨大的性能瓶頸。 每當父項發生更改時,觸摸每個子實體將導致大量數據庫查詢,沒有充分的理由。
另一個類似的場景是當與has_and_belongs_to
關聯關聯的實體出現在列表中時,修改這些實體會通過關聯鏈啟動級聯緩存失效。
class Event < ActiveRecord::Base has_many :participants has_many :users, through: :participants end class Participant < ActiveRecord::Base belongs_to :event belongs_to :user end class User < ActiveRecord::Base has_many :participants has_many :events, through :participants end
因此,對於上述用戶界面,當用戶位置發生變化時觸摸參與者或事件是不合邏輯的。 但是當用戶名發生變化時,我們應該同時觸及事件和參與者,不是嗎?

因此,如上所述,Signal v. Noise 文章中的技術對於某些 UI/UX 實例效率低下。
儘管 Rails 對於簡單的事情非常有效,但實際項目有其自身的複雜性。
字段級 Rails 緩存失效
在我的項目中,我一直在使用小型 Ruby DSL 來處理上述情況。 它使您能夠以聲明方式指定將通過關聯觸發緩存失效的字段。
讓我們看幾個真正有用的例子:
示例 1:
class Event < ActiveRecord::Base include Touchable ... has_many :tasks ... touch :tasks, in_case_of_modified_fields: [:name] ... end class Task < ActiveRecord::Base belongs_to :event end
這個片段利用了 Ruby 的元編程能力和內部 DSL 能力。
更具體地說,只有事件中的名稱更改才會使其相關任務的片段緩存失效。 更改事件的其他字段(如目的或位置)不會使任務的片段緩存無效。 我將此稱為字段級細粒度緩存失效控制。
示例 2:
讓我們看一個通過has_many
關聯鏈顯示緩存失效的示例。
下面顯示的用戶界面片段顯示了一個任務及其所有者:
對於此用戶界面,表示任務的 HTML 片段應僅在任務更改或所有者名稱更改時失效。 如果所有者的所有其他字段(如時區或首選項)更改,則任務緩存應保持不變。
這是使用此處顯示的 DSL 實現的:
class User < ActiveRecord::Base include Touchable touch :tasks, in_case_of_modified_fields: [:first_name, :last_name] ... end class Task < ActiveRecord::Base has_one owner, class_name: :User end
DSL的實現
DSL的主要本質是touch
方法。 它的第一個參數是一個關聯,下一個參數是觸發對該關聯的touch
的字段列表:
touch :tasks, in_case_of_modified_fields: [:first_name, :last_name]
該方法由Touchable
模塊提供:
module Touchable extend ActiveSupport::Concern included do before_save :check_touchable_entities after_save :touch_marked_entities end module ClassMethods def touch association, options @touchable_associations ||= {} @touchable_associations[association] = options end end end
在這段代碼中,重點是我們存儲了touch
調用的參數。 然後,在保存實體之前,如果指定的字段被修改,我們將關聯標記為臟。 如果關聯是臟的,我們會在保存後觸摸該關聯中的實體。
然後,關注的私人部分是:
... private def klass_level_meta_info self.class.instance_variable_get('@touchable_associations') end def meta_info @meta_info ||= {} end def check_touchable_entities return unless klass_level_meta_info.present? klass_level_meta_info.each_pair do |association, change_triggering_fields| if any_of_the_declared_field_changed?(change_triggering_fields) meta_info[association] = true end end end def any_of_the_declared_field_changed?(options) (options[:in_case_of_modified_fields] & changes.keys.map{|x|x.to_sym}).present? end …
在check_touchable_entities
方法中,我們檢查聲明的字段是否更改。 如果是這樣,我們通過將meta_info[association]
設置為true
來將關聯標記為臟。
然後,在保存實體後,我們檢查臟關聯並在必要時觸摸其中的實體:
… def touch_marked_entities return unless klass_level_meta_info.present? klass_level_meta_info.each_key do |association_key| if meta_info[association_key] association = send(association_key) association.update_all(updated_at: Time.zone.now) meta_info[association_key] = false end end end …
而且,就是這樣! 現在,您可以使用簡單的 DSL 在 Rails 中執行字段級緩存失效。
結論
Rails 緩存承諾相對容易地提高應用程序的性能。 但是,現實世界的應用程序可能很複雜,並且通常會帶來獨特的挑戰。 默認的 Rails 緩存行為適用於大多數場景,但在某些場景中,對緩存失效進行更多優化可能會有很長的路要走。
現在您知道如何在 Rails 中實現字段級緩存失效,您可以防止應用程序中不必要的緩存失效。