node同构直出中多级缓存的使用

蚊子前端博客
发布于 2020-08-17 15:17
node同构直出项目,多级缓存是如何保障我们项目运行的?

在之前的文章NodeJs:腾讯新闻构建高性能的 react 同构直出方案里,我们简单介绍了下缓存的使用,不过讲解的不深,这里我再着重讲解下。

缓存有很多种方式:

  1. 前端缓存:例如 cookie, localStorage, 状态管理等,对单个设备有效;

  2. 浏览器缓存:设置 cache-control 或者 etag,对单个设备有效;

  3. nginx 缓存;

  4. 进程缓存:把数据缓存到进程中,无需额外的 I/O 开销,读写速度快;但缺点是数据容易失效,一旦程序出现异常时缓存直接丢失,同时内存缓存无法达到进程之间的共享。

  5. 分布式缓存:使用独立的第三方缓存,如 Redis 或 Memcached,好处时多个进程之间可以共享,同时减少项目本身对缓存淘汰算法的处理。

1. 前端缓存

因这种缓存通过只在单个设备(cookie,localstorage)或者访问周期内(mobx)有效,一般只是缓存一些跟用户相关的个性化数据。

通常会缓存一些个性化的数据,例如用户今日是否已点击过某个按钮,引导性弹窗是否今天是否已弹出过,已请求到的用户相关的数据缓存到 redux, mobx 等中(避免重复请求接口)。

2. 浏览器缓存

网上已经有很多讲解浏览器缓存的文章了,这里我们只是简单的介绍下 Cache-Control 和 etag 的使用。

2.1 Cache-Control

Cache-Control 属于强缓存类型。

强缓存:不会向服务器发送请求,直接从缓存中读取资源,在 chrome 控制台的 Network 选项中可以看到该请求返回 200 的状态码,并且 Size 显示 from disk cache 或 from memory cache。强缓存可以通过设置两种 HTTP Header 实现:Expires 和 Cache-Control。

设置Cache-Control: max-age=300后,则代表在这个请求正确返回时间(浏览器也会记录下来)的 5 分钟内再次加载资源,就会命中强缓存。

2.2 etag

etag 属于协商缓存。

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程,主要有以下两种情况。

Etag 是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成),只要资源有变化,Etag 就会重新生成。浏览器在下一次加载资源向服务器发送请求时,会将上一次返回的 Etag 值放到 request header 里的 If-None-Match 里,服务器只需要比较客户端传来的 If-None-Match 跟自己服务器上该资源的 ETag 是否一致,就能很好地判断资源相对客户端而言是否被修改过了。如果服务器发现 ETag 匹配不上,那么直接以常规 GET 200 回包形式将新的资源(当然也包括了新的 ETag)发给客户端;如果 ETag 是一致的,则直接返回 304 知会客户端直接使用本地缓存即可。

2.3 总结

强制缓存优先于协商缓存进行,若强制缓存(Expires 和 Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified / If-Modified-Since 和 Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,返回 200,重新返回资源和缓存标识,再存入浏览器缓存中;生效则返回 304,继续使用缓存。

Cache-Control 通常用于静态资源的缓存,而且一般缓存时间都比较长,比如有 24 小时的,有 30 天的。而 Etag 的协商缓存通常用来缓存可变化的页面,例如展示数据的页面,这些页面的缓存根据内容的变化来进行缓存。

缓存 cache-蚊子的前端博客

我博客的页面则是使用了强缓存与协商缓存并存的策略,因为博客文章在生成之后,除非有错误修改,一般也不会再进行变动了。

3. 内存缓存

这里主要是 node 的进程缓存和远端的 redis 或 memcached 等缓存。

3.1 进程缓存

把数据缓存到进程中,无需额外的 I/O 开销,读写速度快;但缺点是数据容易失效,一旦程序出现异常时缓存直接丢失,同时内存缓存无法达到进程之间的共享。

我们的 node 服务在启动时采用的是 cluster 模式,即按照当前机器中的 CPU 数量来启动进程。若我们把数据存储到本地进程后,读取数据也是非常的快。在这里我使用的是 lru-cache 模块,即采用的最近最少使用策略淘汰多余的多余的缓存。

COPYJAVASCRIPT

const LruCache = require('lru-cache'); const lru = new LruCache(50); // 最多缓存50条数据 lru.set('testKey', '123test', 1000 * 10); // 缓存10秒钟 const data = lru.get('testKey'); console.log(data);

3.2 redis 缓存

进程缓存之间的数据是无法共享的,因此可能会出现多个进程之间的数据产生不同步的现象。针对这个问题,我们可以把进程缓存的时间的设置的短一些,然后再读取 redis 缓存保持同步。若 redis 缓存也失效的时候,则通过 node 服务更新最新的内容。

COPYJAVASCRIPT

const LruCache = require('lru-cache'); const { getRedisCache, setRedisCache } = require('./redis'); const nodeLogger = require('./node_logger'); // 本地日志 const nodeAtta = require('./node_atta'); // atta上报 const lru = new LruCache(50); // https://www.npmjs.com/package/lru-cache const cache = { open: process.env.NEXT_APP_ENV === 'production' || process.env.NEXT_APP_ENV === 'pre', // 整个缓存的开关 openRedis: process.env.NEXT_APP_ENV === 'production' || process.env.NEXT_APP_ENV === 'pre', // redis缓存的开关 lruTimeout: 20, redisTimeout: 60, async get(key, lruTimeout) { if (this.open) { if (lru.has(key)) { return Promise.resolve({ value: lru.get(key), from: 'lru', // 缓存来自lru }); } // 穿透lru缓存时,进行上报,统计lru缓存穿透率 nodeAtta({ level: 'info', file: '[shell][cache]', fn: 'cache.get', msg: `penetration lru cache, key: ${key}`, }); try { if (this.openRedis) { const value = await getRedisCache(key); if (value) { // 不能直接调用this.set,会造成key无限不过期的 lru.set( key, value, (lruTimeout || this.lruTimeout) * 1000 ); return Promise.resolve({ value, from: 'redis', // 缓存来自redis }); } // 穿透redis缓存时,进行上报,统计redis缓存穿透率 nodeAtta({ level: 'info', file: '[shell][cache]', fn: 'cache.get', msg: `penetration redis cache, key: ${key}`, }); } } catch (error) { nodeLogger.error('getRedisCache error', error); nodeAtta({ file: '[shell][cache]', fn: 'cache.get.redis', msg: `${key}, ${error.message}`, }); } } return Promise.resolve({ value: null, from: null }); // 没有命中缓存 }, // 设置缓存 set(key, value, timeout) { if (this.open) { const lruTimeout = timeout && timeout.lruTimeout ? timeout.lruTimeout : this.lruTimeout; const redisTimeout = timeout && timeout.redisTimeout ? timeout.redisTimeout : this.redisTimeout; lru.set(key, value, lruTimeout * 1000); if (this.openRedis) { try { setRedisCache(key, value, redisTimeout); } catch (error) { // 设置Redis缓存失败 nodeLogger.error('setRedisCache error', error); nodeAtta({ file: '[shell][cache]', fn: 'cache.set.catch', msg: `${key}, ${error.message}`, }); } } } }, del(key) { lru.del(key); }, }; module.exports = cache;

3.3 接口缓存

我们可以把接口数据,页面整个 html 都缓存到内存缓存中,当遇到缓存后,则直接返回,否则进行后续的操作。

注意: 开发过程中,想用req.path作为 key 来存储整个页面的 html,但后来发现一个问题,是页面会根据传入参数的不同,展示不同的页面,例如若 url 中有个参数noticetab,则首页中展示对应的大图。那么这时就要把参数考虑进去。同时,端外携带某个 noticetab 带入到端内后,也要展示这个大图,但参数会变成_addparams

因此,要么考虑这两个参数,要么考虑使用整个 url 来作为 key 进行缓存。同时,我们的抢金达人是在新闻客户端和新闻客户端极速版中,同时访问的。而且使用的是同一个 url,当时考虑的是 url+ua 来作为 key 进行缓存。可是用整个 url 来缓存的时候,又会因为扫描等带有随机参数的措施,产生缓存穿透,导致缓存失效,会把正常的缓存内容给挤掉。而且缓存整个页面时,粒度太粗,无法精细化控制。

总结一下产生的问题是:

  1. 使用 path 缓存时,无法根据参数展示不同的参数;

  2. 带着参数缓存时,要么控制好对应的参数,要么使用整个 url;

    • 使用控制好的参数,则当参数修改时,缓存的参数也要同步修改,不方便;

    • 使用整个 url 时,容易因参数的变动,产生缓存穿透;

  3. 项目在不同的客户端内访问,展示的内容也不一样,要根据 ua 判断;

  4. 简单,但粒度太粗,无法精细化控制;

最终的方案是:接口缓存。按照接口进行精细化的缓存管理,可以设置每个接口的缓存时间,同时不受页面 url 的影响,而且也会根据 ua 的不同,请求不同的后端的接口。

COPYJAVASCRIPT

// request.ts const request = async ( url: string, options: RequestProps = { params: {} } ): Promise<any> => { // 兼容主版与极速版 const app = /qqnewslite/.test(getUserAgent(options.req)) ? 'liteapp' : 'newsapp'; let params = options?.params?.cache ? { env } : { env, _: Math.random() }; params = { ...params, ...options.params, ...{ app } }; // cache: 设置缓存的时间,若cache为0则不缓存 // mock: 设置mock读取的接口地址,这里不表 if (/^\/v1/.test(url)) { // getApiOrigin根据当前的env环境和cache等参数,返回对应的接口地址 const serverUrl = getApiOrigin({ cache: params.cache, mock: params.mock, }); url = serverUrl + url; } };

在服务文件 server.js 中,则读取/设置缓存数据:

COPYJAVASCRIPT

// server.js const apiHandler = async (req, res) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.header('Access-Control-Allow-Headers', 'X-Requested-With'); res.header('Access-Control-Allow-Headers', 'Content-Type'); res.header('x-cache-from', 'server-proxy'); const { path, query } = req; const { mock, env } = query; // 这里参数cache的名称跟缓存控制模块的名字冲突 if (!query.cache) { // 若没有设置缓存,则直接请求接口并返回 return req.pipe(requestApi(getApiOrigin(env) + req.url)).pipe(res); } const { url } = req; // 接口的url地址 const { value } = await cache.get(url); if (value) { return res.send(JSON.parse(value)); } const reqUrl = getApiOrigin(env) + url; try { const result = await requestApi(reqUrl); // 请求接口 if (result && result.code === 0) { // 当接口返回正常,且code===0时才缓存数据 cache.set(url, JSON.stringify(result), query.cache); } // 其他情况交给接入层处理 res.send(result); } catch (error) { nodeLogger.error('server.js[apihandle]', reqUrl, error); } };

这里我们定义了几个规则,可以很方便的控制每个接口的缓存粒度:

  1. 每个接口都可以通过 cache 字段设置缓存的时间,若没有设置 cache 字段,则自动添加一个随机数字段;

  2. 每个接口是按照整个 url+参数来进行缓存的,若想穿透缓存读取新的数据,则在请求参数加入随机字段即可;

  3. 请求接口前,请求模块会根据当前的 ua 来请求主版或者极速版的接口,业务层不用关心;

  4. 缓存的模块即为进程缓存和 redis 缓存;

3.4 组件缓存

对一些不常更新的组件,例如head.jsxfoot.jsx等,我们可以将编译后产生的 html 进行缓存,这样在下次访问时,可以直接使用已编译好的内容。

COPYJAVASCRIPT

const getComponentCache = (Component) => { const { name, cache } = Component.type; // 若已经有缓存的内容,则直接返回 if (name && cache.has(name)) { return cache.get(name); } const html = renderToStaticMarkup(Component); // 若需要缓存,则缓存起来 if (cache && name) { cache.set(name, html, cache); } return html; };

4. 总结

我们在上面讲解了缓存的手段和缓存的粒度。其实从最开始设想的直接缓存整个页面编译出来的 html,简直不要太粗暴,后面我们采用了接口粒度和组件粒度的缓存方式,能够更加精细化的控制。

缓存-蚊子的前端博客

同时,多种缓存方式的结合,也能为我们提供更好的效果,当接口已经缓存后,那么在相应的一段时间内,浏览器缓存也是奏效的(Etag 一直保持不变),可以直接使用浏览器缓存,当接口数据更新后,浏览器缓存也会相应的更新。

标签:
阅读(571) 评论(0)

公众号:

qrcode

微信公众号:前端小茶馆