精品 Web 开发后端缓存思路
 发布于 10 年前  作者 alsotang  31951 次预览  最后一次回复是 9 年前  来自 分享 

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 回复
luoyjx

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

youyudehexie

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

DavidCai1993

大干货啊,认真学习了!

magicdawn

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

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

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

magicdawn

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

SoaringTiger

好文,收藏!

youyudehexie

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

CocaCola183

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

captainblue2013

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

magicdawn

@alsotang

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

alsotang

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

leizongmin

有兴趣的话可以看看这个叫 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慢三倍,但是它提供了更强大的功能,值。

nqdy666

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

dayuoba

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

DoubleSpout

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

coordcn

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

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

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

flex1988

清一下缓存etag就没了

alsotang

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

fantasyni

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 子集

alsotang

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

fantasyni

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

XadillaX

@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

zysam

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

yaonie084

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

NexusLee

赞 最近正因为性能头疼呢

zhuimengmayi

马克

来自酷炫的 CNodeMD