说说 Rails 的套娃缓存机制

Rails 4.0 之后,开始推广一种称为「俄罗斯套娃」的缓存机制,这是一种使用 Fragment Caching(http://guides.rubyonrails.org/caching_with_rails.html#fragment-caching) 技术的缓存机制,在数据库作完查询之后,若是记录没有变化,那么对应的页面不会被 Rails 从新渲染,而是直接从缓存里取出,拼装好之后,返回给客户。html

Tower 正是借鉴了这套缓存机制,给那些访问 Tower 的用户提供流畅的使用体验,今天跟你们分享一下咱们的经验,和一些须要注意的坑。数据库

1. 套娃是怎么套的缓存

拿 Tower 的项目详情页为例,咱们能够把这个页面明显的分红一个个的 Section,好比「讨论区」、「任务清单区」、「文件区」、「文档区」、「日历事件区」,咱们能够把每个列表区域设置为一个独立的缓存,这样,若是列表的数据没有更新的话,在渲染项目详情页的时候,就能够直接从缓存里读取以前生成好的数据。ruby

那么,所谓的套娃在哪儿呢?实际上,除了能够把上面的 Section 放到缓存里,咱们也能够把整个项目详情页放入缓存,这样,若是某一个项目里的数据没有任何更新,访问这个详情页就能够直接读取详情页的缓存,连下面的 Section 缓存都不用碰了。ide

一样,对于某一个列表缓存,好比「讨论区」,咱们能够看到里面会放三条讨论 item,这里其实每个 item 也能够对应放入一个独立的缓存,这样若是只有其中一条 item 有更新的话,其它两条数据是不会被从新渲染,而是直接从缓存区读取的。这样分拆下去,咱们大概能够把整个项目详情页的缓存弄成下面这个模样:post

这样不就一层一层的套起来了么?网站

接下来咱们看看,若是这个页面的某一条数据,好比「任务 A-1」 的内容被改变了,会发生什么。ui

首先,这条任务本身对应的 L4 Todo Item 缓存失效了,因此在拼装外面的 L3 级「任务清单A」缓存的时候,会从缓存里获取任务 A-二、A-3 的缓存,速度嗖嗖快,快到能够忽略不计,而后对任务 A-1 从新渲染一次,放入缓存,这样「任务清单A」经过直接从缓存里读取两条任务(A-2 和 A-3),以及渲染一条新的( A-1 )生成了整个 L3 Todolist Item 的页面片断。剩下的「任务清单B」和「任务清单C」,都没有变化,所以由在生成「任务清单」Section 缓存的时候,直接拼装便可。spa

其它几个 Section 片断由于和任务没有任何关系,全部缓存都不会过时,所以这几个 Section 的页面片断都是直接从缓存里捞出来,一样嗖嗖快。设计

最后,整个项目详情页把这几个 Section 拼装起来,返回给客户。从上面的过程能够看出,只有「任务 A-1」 这个片断的页面被从新渲染了。

因此,这种套娃式的缓存,可以保证页面缓存利用率的最大化,任何数据的更新,只会致使某一个片断的缓存失效,这样在组装完整页面的时候,因为大量的页面片断都是直接从缓存里读取,因此页面生成的时间开销就很小。

那么,套娃是如何在缓存中存取页面片断的呢?主要是靠一个叫作 cache_key 的东西来决定的。

2. cache_key

咱们在页面上可使用一个叫作 cache 的方法,把一坨 HTML 代码片断放在一个 Fragment Cache 里,以项目详情页为例,咱们的代码多是这个样子的:

能够看到,在整个页面的最外面,有个最大的「套娃」:<% cache @project %>,这个 cache 使用 @project 做为方法参数,在 cache 方法内部,会把这个对象进行一番处理,最后生成一个字符串,大概是这个样子:

views/projects/1-20140906112338

这就是所谓的 cache_key,Rails 会使用这串字符串做为 key,对应的页面片断做为内容,存储进缓存系统里。每次渲染页面的时候,Rails 会根据 cache 里的元素计算出对应的 cache_key,而后拿着这个 cache_key 到缓存里去找对应的内容,若是有,则直接从缓存里取出,若是没有,则渲染 cache 里的 HTML 代码片断,而且把内容存储进缓存里。

对于一个具体的 Model 对象,cache_key 的生成机制简单来讲,就是:对象对应的模型名称/对象数据库ID-对象的最后更新时间。

这里咱们可以很容易分析出,一个缓存判断最后是否过时,其实很大程度上只和数据最后更新时间有关,由于在系统里,数据对象对应的模型名称是不变的,对象在数据库里的 ID 通常也是不变的,惟一可能变化的就是最后更新时间。Rails 在建立模型数据表的时候,通常会建立两个默认的 datetime 类型字段,一个是 created_at,一个是 updated_at,然后者正是用来生成 cache_key 的最后更新时间。并且这个时间通常来讲不须要咱们手动更新,咱们都知道若是对一个模型对象调用 save 方法,Rails 会自动帮咱们更新这个 updated_at 字段,这样,若是我修改了项目名称,项目的 updated_at 会发生变化,天然的,页面上项目对应的 cache_key 也就会发生变化,所以咱们的 <%cache @project %> 也就自动过时了。

继续项目详情页里的示例代码,接下来咱们看看第二级套娃:各个 Section 缓存。

拿这个 <% cache @top3_topics.max(&:updated_at) %> 为例,它比 <% cache @project %> 稍微复杂了点。咱们首先应该知道的是,@top3_topics 存储的是对应项目里最新建立的三条讨论,这里比较奇怪的是,咱们为何要用一个 max(&:updated_at) 方法呢?

若是咱们直接把 @top3_topics 对象做为 cache 的参数 <% cache @top3_topics %>,获得的 cache_key 实际上会是这样的形式:

views/topics/3-20140906112338/topics/2-20140906102338/topics/3-20140906092338

看的出来,是每一个对象的 cache_key 的组合,咱们并不太但愿 cache_key 变得这么复杂,特别是当列表元素超过 3 个,好比说有 20 条记录的时候,因此最简单的办法,是取这组数据里最新一个被更新的数据的 updated_at 时间戳,这样生成的 cache_key 就是下面的样子了:

views/20140906112338

可是注意,这里有一个问题,就是假如 @top3_topics 一条数据都没有,会出现什么状况?好比我新建的项目,里面理所固然的一条讨论都没有,这个时候,实际上 cache 的是一个空的 relation,对这个空对象调用 max(&:updated) 方法,返回的值永远都是 nil,因此实际上咱们是对 nil 进行 cache,不幸的是,全部 nil 的 cache_key 都如出一辙,致使这样的缓存片根本不可用,你不知道到底是对什么数据进行的缓存。另外,加入任务清单 Section 和讨论 Section 最后更新的那条数据的 updated_at 时间戳刚好同样,也会形成两个缓存片混淆的问题。

而解决这个问题的方法很简单,就是给 cache 参数里增长一个特定的字符串标识,好比把 <% cache @top3_topics.max(&:updated_at) %> 改为 <% cache [:topics, @top3_topics.max(&:updated_at)] %>,这样一来,若是 @top3_topics 里一条数据都没有,生成的 cache_key 是这样的:

views/topics/20140906112338

带上了「topics」本身的标识,这样就能和其它 nil 类型的缓存区分开了。修改后的项目详情页代码片断以下:

3. Touch!

咱们回过头来再看看套娃缓存的读取机制,访问项目详情页的时候,首先读取最外层的大套娃 <% cache @project %> ,若是这个缓存片对应的 cache_key 在缓存里能找到,则直接取出来而且返回,若是缓存过时,则读取第二级套娃 — 几个列表 Section 缓存,这些缓存根据列表里最新一条数据的更新时间生成 cache_key,若是最新一条数据的更新时间没有变化,则缓存不过时,直接取出来供页面拼装用,若是缓存过时,则继续读取各自的第三级套娃。

等等,这里有个问题,若是我改变了一条任务的内容,也就是做废了任务 partial 本身的缓存,可是包裹任务的任务清单,以及包裹任务清单的项目都没有变化,这样当页面加载的时候,读取到的第一个大套娃 -- <% cache @project %> 都没有更新,会直接返回被缓存了的整个项目详情页,因此根本不会走到渲染更新的任务 partial 那里去。对于这个问题的解决方案,是 Rails 模型层的 touch 机制。

简单的说,咱们须要让里面的子套娃在数据更新了之后,touch 一下处在外面的套娃,告诉它,嘿,我更新了,你也得更新才行。咱们直接看看这个代码片断:

在这里,咱们使用 Rails model 的 belongs_to 来声明模型的从属关系,好比一个 Todo 属于一个 Todolist,一个 Todolist 属于一个 Project,而在 belongs_to 后面,咱们还传入了一个 touch: true 的参数,这样,当一条 Todo 更新的时候,会自动更新它对应的 Todolist 对象的 updated_at 字段,而后又由于 Todolist 和 Project 之间也有 touch 机制,因此对应 Project 对象的 updated_at 字段也会被更新。放到咱们的套娃缓存片里面看的话,就是当一条任务更新之后,「包裹」它的任务清单的缓存片也会被更新,由于对应的 Todolist 对象的 updated_at 时间改变了,而「包裹」这个任务清单的任务清单列表 Section 的缓存片也会失效,由于 @todolists.max(:updated_at) 改变了,接着是「包裹」列表 Section 的项目缓存片过时,由于 @project 对应的 updated_at 也被更新了。

就是经过这么重重 touch 的机制,咱们能确保子元素在更新之后,它的父容器的缓存也能过时,整个套娃机制才能正常运做。下面是整个 Tower 里面,各个模型层的 Touch 结构图:

4. 那些踩过的坑

通过上面的介绍,你们应该已经明白了套娃的实际使用方式,看上去很完美不是么?但在咱们的实际使用过程当中,套娃缓存仍是有一些坑须要注意的,这里跟你们分享一下。

咱们在开发过程当中常常遇到的一个问题,是缓存模板里若是存在「父」元素的状况。咱们把 Project 定义为 Todolist 的父元素,把 Todolist 定义为 Todo 的父元素,由于 touch 机制是自底向上的,从子 touch 到父,可是若是咱们的模板是下面这个样子:

在任务清单模板里,咱们须要显示一下项目的名称,也就是一个子元素的模板里,包含了父元素,这个时候若是缓存是 <% cache [:todolists, @todolists.max(&:updated_at) %> 的话,当咱们把项目名称修改了,这个缓存片是不会过时的,所以任务清单列表里的项目名也不会改变。

解决这个问题的办法,一是修改任务清单的缓存的 cache_key,改为:

这样修改项目名称,就能致使缓存片过时,这也是一个广泛的手段,就是把缓存里面存在的全部模型对象统一归入 cache_key 里面,可是这样存在一个问题,就是由于项目自己是常常被 touch 的,修改任务也会、建立评论也会,因此致使这个任务清单的缓存片会随时失效,缓存命中率下降,因此使用这种方法的时候要仔细考虑,引入父元素做为 cache_key 的一部分,是否会致使这个问题。

另外一个办法是,使用实际须要的模型字段来作缓存,好比上面的例子,咱们实际上只是须要项目名称,因此能够把缓存改成:

这样只会在项目名称发生改变的时候,更新缓存片,这个方法可能性价比最高,不过若是一个缓存里出现多个模型字段的时候,就要写一串这样的 cache_key,和咱们「只对一个具体资源缓存」的原则有些差距,因此通常来讲,缓存的具体字段最好不要超过一个。

还有一个处理方法是,在 HTML 结构上作调整,基于咱们上面所说的「只对一个具体资源缓存」的原则,这里咱们若是针对的是 @todolists 作缓存,那么就应该把其它无关的资源从 HTML 结构里提取出来,好比放到一个外层的 hidden input 里面:

这样能够经过 JS 读取这个属性,再从新注入到模板相应的元素里面。选择这种方案,须要提早根据设计作好规划,把那些须要提取出来的元素放在缓存之外。

最后还有一个方法,就是不理会它。若是你相信任务清单不会长期不变,而项目名称不会常常变化的话,那么缓存里的项目名称不会随时都是最新版本,就是一个能够被接受的事实了,这须要在产品层面上考虑,咱们建议若是遇到这样的问题,不妨先用这种最简单的方式处理,看看用户反馈再决定是否进行调整。

——————————————— 我是分割线 —————————————————

咱们遇到的第二个问题比第一个问题更加让人头疼,这个问题发生在咱们为 Tower 引入一个叫作「访客锁」的新功能的时候。在 Tower 里,用户被分为普通成员、管理员和访客三种,在一个项目里,有些资源好比一条任务清单,是能够设置对访客不可见的,这个在模型层处理起来很简单,只须要增长一个字段来标识一个资源是不是对访客不可见便可,可是一旦和 Fragment Caching 结合的时候,就有问题了。在引入访客锁功能以前,任务清单列表的 Cache 是这样的:

这里 @todolists 是从项目里取出来的全部未完成的任务清单,而后使用 max(&:updated_at) 时间戳来做为 cache_key,这样在一条任务清单更新之后,这个最后更新时间会变化,cache_key 也就变化了。可是在引入访客锁之后,这就会有潜在问题了。假如咱们如今有以下图所示的三条任务清单:

咱们首先将「任务清单B」加锁,而后再去修改一下「任务清单A」的名称,这个时候整个清单列表的 max(&:updated_at) 时间就是「任务清单A」的 updated_at 时间,若是一个普通成员先打开项目详情页,根据这个更新时间,会缓存一个含有三条任务清单的页面,接着一个访客再打开同一个项目详情页,会出现什么状况呢?这个访客会看到三条一样的任务清单,「任务清单B」加锁是无效的!这是由于对于访客来讲,虽然在控制器里查询出来的任务清单只有 A 和 C 两条,可是对于这两条任务清单,最后更新的是 A 的 updated_at 时间戳,这个和能看到三条清单的普通成员以及管理员是同样的,所以他们的任务清单列表的 cache_key 是同样的,取出来的缓存片也同样。

关于这个问题咱们考虑了好久,最后发现只有两种解决方案,要么是完全放弃对这种列表类型的片断作缓存,要么就是遍历列表里的全部子元素,把各自元素的 cache_key 组合起来再求一个 MD5 值,最后咱们选择了后者,具体的作法是在有列表缓存须要的 Model 里,引入一个 Concern:

这样,在须要对列表进行缓存的时候,咱们的写法就再也不是 <% cache [:todolists, @todolists.max(&:updated_at)] %>,而是这样:

这种办法是目前咱们能想到的最佳解决方案,不知道有没有更好的处理方式。

绕过这个最大的坑之后,还剩下最后一个地方须要修改,就是咱们最外层的那个套娃,咱们使用的是 <% cache @project %> 来对整个项目详情页作缓存的,可是由于引入了访客锁,因此访客看到的页面,和普通成员以及管理员看到的页面,是不同的,若是都用 @project 做为 cache_key,会致使和上面列表模式同样的问题,好在这个地方的解决方法比较简单,把缓存改为 <% cache [@project, current_user.visitor?] %> 便可,只是对于同一个项目详情页,须要存储两份缓存了。

5. 小结

以上就是咱们在 Tower 里使用套娃缓存的一些经验,除了 Fragment Caching 以外,咱们也没有额外再使用 Page Caching 或者 Action Caching 之类的技术,37signals 在这篇 Blog 里(https://signalvnoise.com/posts/3690-the-performance-impact-of-russian-doll-caching)统计过他们使用套娃后的缓存命中率,这个值是 67%,而 Tower 目前 8G 的 Memcache 的缓存命中率是 45%,相比之下还有差距,不过整站使用体验上,速度并非一个显著的短板,若是能把 Fragment Cache 的细粒度继续作下去,应该会有更好的效果。

综上,套娃缓存机制仍是蛮适合于小团队用来加速本身的网站(实际上 Tower 的 Hybird 模式的移动客户端也是用这种方式来作加速的),只要在模板设计的时候,尽可能按照资源作好规划,后面逐步增长套娃的数量和层级,因为只涉及到模板部分的更改,整体来讲是一个性价比很高的方案。

相关文章
相关标签/搜索