字段级 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 中实现字段级缓存失效,您可以防止应用程序中不必要的缓存失效。