在之前的文章NodeJs:腾讯新闻构建高性能的 react 同构直出方案里,我们简单介绍了下缓存的使用,不过讲解的不深,这里我再着重讲解下。
缓存有很多种方式:
- 前端缓存:例如 cookie, localStorage, 状态管理等,对单个设备有效;
- 浏览器缓存:设置 cache-control 或者 etag,对单个设备有效;
- nginx 缓存;
- 进程缓存:把数据缓存到进程中,无需额外的 I/O 开销,读写速度快;但缺点是数据容易失效,一旦程序出现异常时缓存直接丢失,同时内存缓存无法达到进程之间的共享。
- 分布式缓存:使用独立的第三方缓存,如 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 的协商缓存通常用来缓存可变化的页面,例如展示数据的页面,这些页面的缓存根据内容的变化来进行缓存。
我博客的页面则是使用了强缓存与协商缓存并存的策略,因为博客文章在生成之后,除非有错误修改,一般也不会再进行变动了。
3. 内存缓存 #
这里主要是 node 的进程缓存和远端的 redis 或 memcached 等缓存。
3.1 进程缓存 #
把数据缓存到进程中,无需额外的 I/O 开销,读写速度快;但缺点是数据容易失效,一旦程序出现异常时缓存直接丢失,同时内存缓存无法达到进程之间的共享。
我们的 node 服务在启动时采用的是 cluster 模式,即按照当前机器中的 CPU 数量来启动进程。若我们把数据存储到本地进程后,读取数据也是非常的快。在这里我使用的是 lru-cache 模块,即采用的最近最少使用策略淘汰多余的多余的缓存。
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 服务更新最新的内容。
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 来缓存的时候,又会因为扫描等带有随机参数的措施,产生缓存穿透
,导致缓存失效,会把正常的缓存内容给挤掉。而且缓存整个页面时,粒度太粗,无法精细化控制。
总结一下产生的问题是:
- 使用 path 缓存时,无法根据参数展示不同的参数;
- 带着参数缓存时,要么控制好对应的参数,要么使用整个 url;
- 使用控制好的参数,则当参数修改时,缓存的参数也要同步修改,不方便;
- 使用整个 url 时,容易因参数的变动,产生缓存穿透;
- 项目在不同的客户端内访问,展示的内容也不一样,要根据 ua 判断;
- 简单,但粒度太粗,无法精细化控制;
最终的方案是:接口缓存
。按照接口进行精细化的缓存管理,可以设置每个接口的缓存时间,同时不受页面 url 的影响,而且也会根据 ua 的不同,请求不同的后端的接口。
// 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 中,则读取/设置缓存数据:
// 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);
}
};
这里我们定义了几个规则,可以很方便的控制每个接口的缓存粒度:
- 每个接口都可以通过 cache 字段设置缓存的时间,若没有设置 cache 字段,则自动添加一个随机数字段;
- 每个接口是按照整个 url+参数来进行缓存的,若想穿透缓存读取新的数据,则在请求参数加入随机字段即可;
- 请求接口前,请求模块会根据当前的 ua 来请求主版或者极速版的接口,业务层不用关心;
- 缓存的模块即为进程缓存和 redis 缓存;
3.4 组件缓存 #
对一些不常更新的组件,例如head.jsx
或foot.jsx
等,我们可以将编译后产生的 html 进行缓存,这样在下次访问时,可以直接使用已编译好的内容。
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 一直保持不变),可以直接使用浏览器缓存,当接口数据更新后,浏览器缓存也会相应的更新。