Wenzi

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 模块,即采用的最近最少使用策略淘汰多余的多余的缓存。

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 来缓存的时候,又会因为扫描等带有随机参数的措施,产生缓存穿透,导致缓存失效,会把正常的缓存内容给挤掉。而且缓存整个页面时,粒度太粗,无法精细化控制。

总结一下产生的问题是:

  1. 使用 path 缓存时,无法根据参数展示不同的参数;
  2. 带着参数缓存时,要么控制好对应的参数,要么使用整个 url;
    • 使用控制好的参数,则当参数修改时,缓存的参数也要同步修改,不方便;
    • 使用整个 url 时,容易因参数的变动,产生缓存穿透;
  3. 项目在不同的客户端内访问,展示的内容也不一样,要根据 ua 判断;
  4. 简单,但粒度太粗,无法精细化控制;

最终的方案是:接口缓存。按照接口进行精细化的缓存管理,可以设置每个接口的缓存时间,同时不受页面 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);
    }
};

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

  1. 每个接口都可以通过 cache 字段设置缓存的时间,若没有设置 cache 字段,则自动添加一个随机数字段;
  2. 每个接口是按照整个 url+参数来进行缓存的,若想穿透缓存读取新的数据,则在请求参数加入随机字段即可;
  3. 请求接口前,请求模块会根据当前的 ua 来请求主版或者极速版的接口,业务层不用关心;
  4. 缓存的模块即为进程缓存和 redis 缓存;

3.4 组件缓存 #

对一些不常更新的组件,例如head.jsxfoot.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 一直保持不变),可以直接使用浏览器缓存,当接口数据更新后,浏览器缓存也会相应的更新。

标签:nodecache
阅读(1398)
Simple Empty
No data