Web 缓存 以及 Rails 缓存实践 – 后端篇
1、 Rails 中的 ETag
在 Rails 中默认使用Rack::ETag middleware
中间件,它会自动给无 ETag 的 response 加上 ETag,再根据 ETag 来判断资源是否更改,从而返回 200 / 304。
注意
: 返回304并不是说服务器没有处理资源直接就返回304,而是完全的处理完了资源,生成了response准备响应了,才进行生成 ETag,然后进行判断,这样虽然也会消耗服务器的资源,但是可以大大减少客户端传输网络的时间。
之前有个疑惑:
1 2 3 |
|
2、Nginx
Nginx 1.7.3 以前如果开启了 gzip on
,Nginx 会将 Rails响应中的 ETag 头去掉。
Nginx 1.7.3 后开始支持 Weak_ETag
,会将 Rails响应中的 ETag 转换为 Weak_ETag
,不过 Rack::ETag在1.6.0 也默认生成 Weak ETag
了
Nginx 默认就会给静态文件打上 ETag 和 Last-Modified
3、Rails的缓存机制
服务器端缓存区存储有两种方式:
- 硬盘
- 内存
运行流程图:
画得有点呵呵,将就着看吧。
说明几点:
- 每次请求都会重新计算
cache_key
,而计算cache_key
则会进入到数据库去查询,比如products
需要查询products.max(&:updated_at)
。 - 缓存本质倒不是查不查,而是把一个长查询转化成短查询。
- 当资源更新时,对应缓存区的缓存会过期,但过期不是删除,缓存还是会存在缓存区中,只是不再命中,
所以基于 file 的缓存方案会产生许多重复的垃圾,dhh 推荐使用 memcache 就是把删除缓存的操作交给 memcache 来做。
4、Rails中使用缓存
在 Rails 中使用缓存是个大学问
4.1、fresh_when
前文说到,Rails 默认的中间件Rack::ETag middleware
会自动给无ETag
的response
加上ETag
,那么,有没办法在生成完整的response之前(节省资源)我们自己加上ETag
呢? Rails 提供了 fresh_when
helper
1 2 3 4 5 6 |
|
下次用户再访问的时候,会对比request header里面的If-Modified-Since和If-None-Match,如果相符合,就直接返回304,而不再生成response body。
但是这样会遇到一个问题,假设我们的网站导航有用户信息,一个用户在未登陆专题访问了一下,然后登陆以后再访问,会发现页面上显示的还是未登陆状态。或者在app访问一篇文章,做了一下收藏,下次再进入这篇文章,还是显示未收藏状态。解决这个问题的方法很简单,将用户相关的变量也加入到etag的计算里面:
1 2 |
|
和fresh_when相比,自动etag能够节省的只是客户端时间
4.2 Fragment Caching
Rails 中有几种 cache,这种是最常见使用最多的。
如下,有个项目页面:
接下来我们看看,如果这个页面的某一条数据,比如「任务 A-1」 的内容被改变了,会发生什么。
首先,这条任务自己对应的 L4 Todo Item 缓存失效了,所以在拼装外面的 L3 级「任务清单A」缓存的时候,会从缓存里获取任务 A-2、A-3 的缓存,速度嗖嗖快,快到可以忽略不计,然后对任务 A-1 重新渲染一次,放入缓存,这样「任务清单A」通过直接从缓存里读取两条任务(A-2 和 A-3),以及渲染一条新的( A-1 )生成了整个 L3 Todolist Item 的页面片段。剩下的「任务清单B」和「任务清单C」,都没有变化,因此由在生成「任务清单」Section 缓存的时候,直接拼装即可。
其它几个 Section 片段因为和任务没有任何关系,所有缓存都不会过期,因此这几个 Section 的页面片段都是直接从缓存里捞出来,同样嗖嗖快。
最后,整个项目详情页把这几个 Section 拼装起来,返回给客户。从上面的过程可以看出,只有「任务 A-1」 这个片段的页面被重新渲染了。
所以,这种套娃式的缓存,能够保证页面缓存利用率的最大化,任何数据的更新,只会导致某一个片段的缓存失效,这样在组装完整页面的时候,由于大量的页面片段都是直接从缓存里读取,所以页面生成的时间开销就很小。
那么,套娃是如何在缓存中存取页面片段的呢?主要是靠一个叫做 cache_key 的东西来决定的。
4.2.1、cache_key
代码初级版:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
单个资源
可以看到,最外层有个<% cache @project %>
,这个cache
使用@project
作为参数,cache内部会对该对象进行处理,计算出cache_key
大概是:views/projects/1-20140906112338
Rails 会使用该cache_key
作为key
,将包含的所有东西(包括数据和html)通通放入缓存区。每次有请求到来时,就会重新计算 cache_key,然后到缓存区进行查找,命中则返回,不命中则服务器查询资源,重新将资源放入缓存区。
当资源更新时,cache_key
也随之变化(注意,cache_key
大部分需要自己设计),因此就不会命中缓存,缓存自然过期。
注意:
如果不生成cache_key
,缓存永远都不会变!!!无论资源是否更新
注意:
缓存过期不代表缓存被删除,因为缓存区会存在大量垃圾,需要memcached来解决。
多个资源
那如果是资源列表呢?如果我们直接把 @top3_topics
对象作为 cache 的参数 <% cache @top3_topics %>
,得到的 cache_key
实际上会是这样的形式:
views/topics/3-20140906112338/topics/2-20140906102338/topics/3-20140906092338
显然,这样做不太好,因此我们可以取这组数据内最新一个被更新的数据的updated_at时间戳,这样会生成:
views/20140906112338
但这样会出现一个问题,假如此时@top_topics
为空,那么我们此时是对 nil 进行缓存,不幸的是,所有 nil 的 cache_key
都一模一样,这样就会造成混乱。再加上,加入任务清单 Section 和讨论 Section 最后更新的那条数据的 updated_at 时间戳恰好一样,也会造成两个缓存片混淆的问题。
解决方法就是给每个cache都加上model名
<% cache [:topics, @top3_topics.max(&:updated_at)] %>
代码更新版:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
4.2.2 touch!机制
我们回过头来再看看套娃缓存的读取机制,访问项目详情页的时候,首先读取最外层的大套娃 <% cache @project %> ,如果这个缓存片对应的 cache_key
在缓存里能找到,则直接取出来并且返回,如果缓存过期,则读取第二级套娃 — 几个列表 Section 缓存,这些缓存根据列表里最新一条数据的更新时间生成 cache_key,如果最新一条数据的更新时间没有变化,则缓存不过期,直接取出来供页面拼装用,如果缓存过期,则继续读取各自的第三级套娃。
等等,这里有个问题,如果我改变了一条任务的内容,也就是作废了任务 partial 自己的缓存,但是包裹任务的任务清单,以及包裹任务清单的项目都没有变化,这样当页面加载的时候,读取到的第一个大套娃 – <% cache @project %> 都没有更新,会直接返回被缓存了的整个项目详情页,所以根本不会走到渲染更新的任务 partial 那里去。对于这个问题的解决方案,是 Rails 模型层的 touch 机制。
简单的说,我们需要让里面的子套娃在数据更新了以后,touch 一下处在外面的套娃,告诉它,嘿,我更新了,你也得更新才行。我们直接看看这个代码片段:
1 2 3 4 5 6 7 8 9 10 11 |
|
由于 touch 的存在,当 todo 的某条任务修改时,会自动通知到 todolist, 让其修改 updated_at 属性,而 todolist 也会修改到 project,一层层保证整个缓存系统的正常运作。
5、Rails 里使用缓存的坑
1 2 3 4 5 |
|
当出现以上情况时,属于父级cache的属性出现在子级cache内,当@project的内容更改了,这时 @todolists 的 cache_key 并没有改变,也就是说这段缓存没有过期,<%= @project.name %>
显示的还是过去的 name
解决方法:
1 2 3 4 5 |
|
1 2 3 4 5 |
|
这样子当修改@project时,也会修改子级的 cache_key
6、注意
当请求过来时,还是会经过controller,进行lazy select,再经过erb,再进行真正的 cache_key
计算,当然,此时 controller 中查询的那些还没有真正的查询,就仅仅只是计算了 cache_key
, 比如:
1 2 3 |
|
因为是 lazy select,所以这时候连 @articles 都没真正查询出来,直接变成了查询最大 updated_at
的语句。
(有些在 controller 中对那些查询对象进行了操作,就不是 lazy select了)
分割好粒度,否则会得不偿失
还有第二个坑,写得脑仁疼,不写啦,看下面参考文
参考
总结 web 应用中常用的各种 cache: https://ruby-china.org/topics/19389
Cache 在 Ruby China 里面的应用: https://ruby-china.org/topics/19436
说说 Rails 的套娃缓存机制: https://ruby-china.org/topics/21488?page=1#replies