精华 Web 开发后端缓存思路
发布于 4 年前 作者 alsotang 25620 次浏览 最后一次编辑是 3 年前 来自 分享

Web 应用是个典型的 io 数据流,

QQ20150405-4.png

首先,浏览器发来一个 input,服务器获取之后,做一些查询或者计算,然后把生成的 output 返回给浏览器。

这些查询或计算,还会有衍生的子 io 流。

缓存的目的就是让把 input 变成一个 key,在条件允许的情况下,跳过计算,直接生成 output。在主流程中,或子流程中。

数据查询缓存

resource: http://robbinfan.com/blog/3/orm-cache

n + 1 问题

n + 1 问题是 orm 竟然被诟病的地方。什么是 n + 1 问题呢? 比如一个用户,它写了 20 篇博客。当我们查询这个用户的首页时,需要列出他的所有博客。 “高效”的思路是使用一个 join 语句,把 user 表和 blog 表做 join,然后一条语句取出所有想要的字段。 而 orm,会先取出 user 的记录,再做 20 次遍历,分别用 20 条语句取出他所有的博客。 按照 robbin 的说法,join 语句的结果很难被缓存利用,因为它发生的场景太过特定。 但如果使用 orm,按照 n + 1 的方式取数据。由于数据缓存的粒度比较小,缓存的命中率得到了提高。 首先,orm 内置的缓存一般会在同一个连接中,缓存同一 sql 语句的结果;其次,数据库的缓存会记下特定 sql 语句的对应的结果,当再次收到相同语句时,数据库不必进行扫描,可以直接 O(1) 复杂度地返回缓存结果。

robbin 认为,在这种情况下,n + 1 的查询反而因为有效利用了缓存,而比 join 语句更快。

robbin 得出了这样的结论:即使不使用对象缓存,ORM的n+1条SQL性能仍然很有可能超过SQL的大表关联查询,而且对数据库磁盘IO造成的压力要小很多

缓存层加入

利用 redis 或者 memcached,这个话题 google 一下会有很多。

json to orm 问题

CNode 使用的是 mongoose 这个 odm 来访问 mongodb。在 mongoose 的 model 中,我们定义了不少【虚拟属性】,所谓虚拟属性,就是指:一个 user 实例,它有 first_namelast_name 字段,当我们定义一个名字为 full_name 的虚拟属性时,user.full_name 会根据定义的函数自动拼接 first_name 和 last_name。也就是面向对象编程中的 getter 方法。

当缓存一个 mongoose 取出的文档到 redis 时,我们会将它先装换成 json,再以字符串形式存入。 再次取出并 JSON.parse 的时候,会发现 mongoose model 定义的虚拟属性全都被丢弃了。所以这时,需要重新把这个 json 传入 model 初始化一次,得到一个 model 实例。这样,我们就恢复了原来内存中的那个 model 实例了。

数据写入缓存:

在数据库与服务端之间利用 redis

这是一个很常见的场景。比如文章的浏览数,每次文章被浏览时,浏览数都 +1。如果每次都回写数据库,不免数据量太大。加上数据库看似简单,其实做了不少关于一致性(请看官了解一下所谓【一致性】,【base】,【acid】)的检查。 而同时,浏览数并不要求保证一致性,只要大概准确就行了。 所以这时候,我们可以先将浏览数写入 redis,满足一定条件后,再回写数据库。 比如,在 controller 中,让每次浏览都在 redis 上 +1,+1 完成后,检查浏览数是否除以 10 后余数为 0(count % 10 === 0),是的话,则回写数据库,并将缓存置为 0。

缓存过期策略

可以通过过期时间来控制内容新鲜期

那么就设置设缓存过期时间。比如在一个网站上,总会有一些每日之星用户,或者今日推荐文章。

这些内容的新鲜期都很长,比如每日之星的数据,如果 20 分钟更新一次,用户也不会有异议。那么,我们在查询出这些用户后,可以将结果集存入缓存中,并设置过期时间为 20 分钟。待自动失效后,再重新查询。

无法通过过期时间来控制内容新鲜期

这时,又有两个策略了。一个是【主动过期】策略,一个是【被动过期】策略。比如想要缓存一篇文章的内容 HTML,但文章的页面中包含了评论信息。一些老文章被大量访问而无人添加评论时,缓存的效果杠杠的。但一些近期文章会被用户添加评论, 我们无法判断用户何时会添加评论,所以无法得到一个最佳实践的文章过期时间。

主动过期

顾名思义,主动地去 delete 缓存。还是上面的文章例子。我们可以在评论的 model 中,设置一个回调逻辑。每当评论被更新时,同时去删除评论所对应的文章的缓存内容。

被动过期

被动过期也不是完全不需要回调逻辑,只是相对主动过期来说。它不必理解缓存层的存在。

还是上面的例子,当我们缓存一个文章页面时,不仅以文章的 id 为 cache key,还在 cache key 中拼入文章的 update_at 字段。 当评论更新时,让评论去 touch 一下对应的文章,更新文章的最后修改日期。那么当用户再次访问文章时,由于 cache key 变动,过期的内容就不会被展现,从而实现了被动过期。

同样的例子还有,一篇文章是以 markdown 写成,每次输出的时候,都要进行 markdown 渲染,这是个耗时操作。于是我们可以将 'markdown_result_' + artical.id + artical.updated_at 作为 key,来缓存 markdown 的渲染结果。每当文章更新时,被动地废弃旧有的缓存结果。

当然,这里不能说主动过期好,还是被动过期好。细心的看客也许在上面两个例子中发现了问题,那就是,当文章的内容没有进行改变,而评论添加时,文章却要重新渲染 markdown,可渲染结果其实是一样的。

HTML 片段缓存

resource: https://ruby-china.org/topics/21488

QQ20150405-5.png

CNode 为例,我简单地划分了 1 2 3 4 四个部分。每个部分在逻辑上都是一个相对独立的 setion,它们使用不同的数据进行渲染。在代码组织上,这些部分也是属于不同的 view 文件来负责。

4 的部分就是我们所说的,可以通过过期时间来管理的片段。这个部分 10 分钟更新一次没有问题。

3 的部分类似上面 markdown 的例子,渲染是耗时的,而数据是经常不变的。所以我们可以通过类似 'user_profile' + user.id + user.updated_at 的 cache key 来将其缓存。

而 1 和 2 的部分,就类似上面【被动过期】的例子。1 中,不仅有帖子的标题,还有帖子的作者信息,还有帖子的最后回复者信息,粗略一算,这都是 3 条查询。如果能缓存起来,那是大大滴有用。而 2,包含了所有 1 类似的部分,也可以被缓存。但如果 1 动了,2 怎么办?所以在缓存 2 时,我们可以使用所有 1 中最新的那个帖子的更新时间来作为 key,当有帖子更新后,更新时间对不上,缓存就被动过期了。

如果是个大型站点,1 的内容频繁动,那么会导致 2 的缓存命中率很低。这时,从业务上,我们判断,主页的新鲜期是可以在 5s 内不变的。这时,缓存策略可以改为,最新的帖子的更新时间,如果离现在的时间不超过 5s,则返回之前缓存的内容。我们一下就从【被动过期】的策略,变回【过期时间】的策略了。

所以具体采用什么策略,根据业务场景可以灵活选择。

【被动过期】策略时,切记要让上层片段的缓存 key 可以被下层 touch 更新。【过期时间】策略时,需要我们判断一下内容的新鲜期。

并且有一点比较深入的知识点是,不同的 touch 策略,会对缓存命中率产生影响。这个知识点请参照本小节 resource 部分的链接去看看 Tower 在面对这个情况时的方案。

如果你要问我 CNode 在片段缓存上是怎么选择的,我可以负责任并潇洒地告诉你:目前没有这方面的缓存~~~~

说起来啊,一是访问量比较小,懒得做。二是,从技术上说,渲染是同步的,而在 Node.js 中,数据查询是异步的。我思考了一下,做这个片段缓存不是简单的事情。而 Rails 中做起来就简单多了,虽然玩 Node 的人总是觉得 Node 可以原生异步并发取数据是一件优越的事情。但同步 io 模型在这个地方带来的好处就是【惰性求值】 。Rails 在渲染时,可以判断一下到底是【查询 + 渲染】还是【直接取缓存】。而 Node 由于异步查询和同步渲染之间的冲突,要解决这个问题,必须有个方便地支持异步渲染的模板方案出现。

last_modified 和 etag

resource: http://robbinfan.com/blog/13/http-cache-implement

这节我们讨论的是静态页面在浏览器中的缓存思路。所以不是 max-age 和 cache-control 那套针对静态资源的方案,而是 last_modified 和 etag 这一套。

上面的内容,一直在说数据库,缓存数据库。但有一点不可忽视的是,浏览器中其实也缓存了我们页面的副本,这部分的缓存,也应该有效地利用起来。 最简单利用方式,就是让服务器判断一下最终页面生成的 etag 与浏览器 header 中传来的 etag 是否相同的,相同的话,则返回 304,省去网络传输的带宽开销。

注意,最简单的方式是判断最终内容生成的 etag!其实我们可以自定义 etag。在这里,etag 也可以理解成一定意义上上述的 cache key,只是这回,储存介质变成了用户的浏览器。

还是上面那个文章内容页面的例子,我们文章页面由 文章内容 + 评论 内容决定是否缓存。这时,我们可以把文章内容的更新时间和最新评论的更新时间拼成一个 etag,返回给用户。下次用户再访问时,如果 etag 对得上,服务端根本都不需要再去缓存数据库中取 HTML 片段数据,直接告诉用户一个 304,【内容与上次一样,没变化】。这时浏览器就直接从自己的缓存中取出页面进行展示了。既节省了宽带占用,又节省了查询开销。

etag as cookie

这里说点题外话,etag 在一定意义上是可以拿来当 cookie 用的。首先我们要了解,浏览器针对每一个 url(包括 querystring 部分)都可以存储一个 etag 值。

比如我是一个广告服务商,我的广告页面是 https://cnodejs.org/ads。每当不同的用户访问这个页面时,我都根据大数据黑魔法定位到这个匿名用户到底是谁,然后返回他感兴趣的内容。可如果用户禁用了 cookie 的话,我该怎么定位用户呢?这时候可以使用 etag。每当用户不带 etag 访问时,都生成一个不冲突的 etag 给它,那么下次他再访问我 url 时,etag 就回来了。

OK,结束了,结尾语是:Rails 社区代表 Web 开发世界的最先进生产力。

42 回复

给力啊,这么快就发上来了,学习了 自豪地采用 CNodeJS ionic

刚好也在些缓存相关得文章,打个小广告,有兴趣得可以看看 《通过redis缓存express路由数据》 http://www.jianshu.com/p/9852d59280ca

大干货啊,认真学习了!

渲染是同步的,而在 Node.js 中,数据查询是异步的。我思考了一下,做这个片段缓存不是简单的事情。而 Rails 中做起来就简单多了

就算是异步,也是先取数据,后渲染啊!!!

用的ejs的话,给 partial 里加一层cache 就可以

ejs 居然不支持partials 。。。 只有一个巨弱的include,怎么会有人用!!!

好文,收藏!

如果大家对异步模版 有兴趣,可以关注一下 老雷得 tinyliquid 项目,两年前 就已经实现了。

真心是干货,好好拜读一下

支持下,当年我学这些东西的时候就没想过写出文章来,基本都是迫不及待去写代码。

@alsotang

不记得哪个模板可以可以像上面rails view里,给partials传locals了!

@magicdawn ejs 原来可以的,现在加了插件也可以。

有兴趣的话可以看看这个叫 TinyLiquid 的模板引擎,使用 Liquid 语法,支持在模板内调用异步函数,支持 includeextends 语法 1、项目主页:https://github.com/leizongmin/tinyliquid 2、在Express中使用TinyLiqui的:https://github.com/leizongmin/express-liquid 3、与ejs的使用对比:https://github.com/leizongmin/gz-nodeparty-201412-tinyliquid 4、Liquid语法参考:http://liquidmarkup.org/

Q & A

1、什么叫『支持在模板内调用异步函数』? 比如模板中 {{user_id|get_user_display_name}} 表示执行函数 get_user_display_name(user_id) 并输出结果(类似于ejs中的<%= get_user_display_name(user_id) %>,而在TinyLiquid中,get_user_display_name() 这个函数可以是一个异步函数,它的定义可能是这样的:

function get_user_display_name (user_id, callback) {
  // 这里进行异步数据查询,查询完成后通过callback()返回结果
  callback(null, display_name);
}

只要在初始化TinyLiquid模板引擎时使用一定的方法注册了这个函数,那么在模板里面使用是不需要区分是同步还是异步的,完全有模板引擎解决。 2、『include』和『extends』语法分别有什么功能? include表示包含某个模板文件,比如 {% include "header" %}表示把header这个模板插入到当前位置; extends表示将当前模板插入到某个主模板里面,比如{% extends "main" %}表示把当前模板放入main模板的指定位置 3、TinyLiquid为何能做到支持异步? 因为它把模板编译成了一种中间码,渲染的时候实际上是在解释执行这些中间码,所以它不受同步还是异步的影响。 4、TinyLiquid这种渲染机制运行效率如何? 不算很慢,一般的渲染速度比ejs慢三倍,但是它提供了更强大的功能,值。

好教程,之后对着看下代码。 自豪地采用 CNodeJS ionic

支持,同步的渲染模型确实是node不擅长的,因为客户端需要等待异步io的结果。

看到大牛的分享,收益匪浅啊,关于缓存处理的干活!

https://github.com/coordcn/sml 类jade模板,半成品。最近没时间搞了。

渲染同步问题估计是不可避免的,顶多通过缓存渲染结果来改善,或者干脆给数据,让客户端渲染。

这个东西只要这么思考,渲染就是字符串拼装,那么自然就是消耗CPU周期,那么在哪个阶段消耗其实都是要消耗的,在总体上,不会因为异步而获得好处,异步本质上是事情让别人做,在等待结果的时候让出CPU做其他事情。异步渲染,顶多能实现让CPU负责渲染,渲染结果再由处理网络的CPU发送。但是这个过程还是有通信损耗的,不及缓存和客户端渲染来得实在爽快。

清一下缓存etag就没了

@coordcn 恩恩,我明白渲染的 cpu 是省不掉的。我这里的异步模板指的是编程模型方面的抽象概念。

robbin 那个 orm 与 join 之间的下的结论是有偏差的
orm 和 mysql join 的复杂度其实是差不多的
mysql join 内部实现也是基于 buffer pool 缓存的
mysql 把buffer pool开大点,效果是一样的
而造成全表扫描的唯一可能就是SQL写搓了,没用索引
这个sql好解决,ORM遇到此问题就除非你真的精通ORM实现。。。

mysql join 与 ORM with cache 之间的另外一个区别就是 mysql 的 RTT 少,就一个来回就解决了,而 ORM 那种就 n+1 了,并发吞吐就地了

总的来说 SQL 全集,ORM 子集

@fantasyni mysql join 的 buffer pool 缓存的级别是 sql 语句级别吧?

@alsotang 有sql语句级别的 sql query cache
buffer pool 实现是基于页的,页miss才会从磁盘读取,而一般优化 页会预读的,页其实就相当于内存缓存
因此如果一个数据库的数据能在内存中存下,那么就没必要用redis这种cache
mysql innodb 的表是b+tree索引表,select join 查询的时候,还是会一个个遍历的(快慢就在于使用了索引遍历还是全表遍历)
关系型数据库操作的集合操作,join 其实就是做了笛卡尔积,最后还是会归结于一个个找的,先找内存,然后去磁盘

@alsotang 我的 Toshihiko 这个 ORM 自带缓存层,默认用 Memcached,有个好基友 @luicfer 写了个 Toshihiko-redis,然后可以通过接口实现任意缓存 Storage。

https://github.com/XadillaX/Toshihiko https://github.com/XadillaX/Toshihiko-Memcached https://github.com/luicfer/Toshihiko-Redis

mark ! Rails 社区代表 Web 开发世界的最先进生产力 !???

大表我觉得必须上缓存,简单业务能不用缓存还是尽量不要用,搞搞冗余也不错。因为我觉得滥用redis或者memcache的话,后面会搞得代码维护起来很麻烦。

冒出个Rails

赞 最近正因为性能头疼呢

马克

来自酷炫的 CNodeMD

回到顶部