如何在Node.js中实现一个缓存系统?

在数据库查询遇到瓶颈时,我们通常可以采用缓存来提升查询速度,同时缓解数据库压力。常用的缓存数据库有Redis、Memcached等。在一些简单场景中,我们也可以自己实现一个缓存系统,避免使用额外的缓存中间件。这篇文章将带你一步步实现一个完善的缓存系统,它将包含过期清除、数据克隆、事件、大小限制、多级缓存等功能。(如果觉得有帮助,欢迎点赞收藏哦🤗~)

一个最简单的缓存

js 复制代码
class Cache {
  constructor() {
    this.cache = new Map();
  }

  get = (key) => {
    return this.data[key];
  };

  set = (key, value) => {
    this.data[key] = value;
  };

  del = (key) => {
    delete this.data[key];
  };
}

我们使用Map结构来保存数据,使用方式也很简单:

js 复制代码
const cache = new Cache();
cache.set("a", "aaa");

cache.get("a") // aaa

添加过期时间

接下来我们尝试为缓存设置一个过期时间。在获取数据时,如果数据已经过期了,则清除它。

js 复制代码
class Cache {
  constructor(options) {
    this.cache = new Map();
    this.options = Object.assign(
      {
        stdTTL: 0, // 缓存有效期,单位为s。为0表示永不过期
      },
      options
    );
  }

  get = (key) => {
    const ret = this.cache.get(key);
    if (ret && this._check(key, ret)) {
      return ret.v;
    } else {
      return void 0;
    }
  };

  set = (key, value, ttl) => {
    ttl = ttl ?? this.options.stdTTL;
    // 设置缓存的过期时间
    this.cache.set(key, { v: value, t: ttl === 0 ? 0 : Date.now() + ttl * 1000 });
  };

  del = (key) => {
    this.cache.delete(key);
  };

  // 检查缓存是否过期,过期则删除
  _check = (key, data) => {
    if (data.t !== 0 && data.t < Date.now()) {
      this.del(key);
      return false;
    }
    return true;
  };
}

module.exports = Cache;

我们写个用例来测试一下:

js 复制代码
const cache = new Cache({ stdTTL: 1 }); // 默认缓存1s

cache.set("a", "aaa");

console.log(cache.get("a")); // 输出: aaa

setTimeout(() => {
  console.log(cache.get("a")); // 输出: undefined
}, 2000);

可见,超过有效期后,再次获取时数据就不存在了。

私有属性

前面的代码中我们用_开头来标明私有属性,我们也可以通过Symbol来实现,像下面这样:

js 复制代码
const LENGTH = Symbol("length");

class Cache {
  constructor(options) {
    this[LENGTH] = options.length;
  }

  get length() {
    return this[LENGTH];
  }
}

Symbols 在 for...in 迭代中不可枚举。另外,Object.getOwnPropertyNames() 不会返回 symbol 对象的属性,但是你能使用 Object.getOwnPropertySymbols() 得到它们。

js 复制代码
const cache = new Cache({ length: 100 });

Object.keys(cache); // []

Object.getOwnPropertySymbols(cache); // [Symbol(length)]

定期清除过期缓存

之前只会在get时判断缓存是否过期,然而如果不对某个key进行get操作,则过期缓存永远不会被清除,导致无效的缓存堆积。接下来我们要实现定期自动清除过期缓存的功能。

js 复制代码
class Cache {
  constructor(options) {
    this.cache = new Map();
    this.options = Object.assign(
      {
        stdTTL: 0, // 缓存有效期,单位为s。为0表示永不过期
        checkperiod: 600, // 定时检查过期缓存,单位为s。小于0则不检查
      },
      options
    );
    this._checkData();
  }

  get = (key) => {
    const ret = this.cache.get(key);
    if (ret && this._check(key, ret)) {
      return ret.v;
    } else {
      return void 0;
    }
  };

  set = (key, value, ttl) => {
    ttl = ttl ?? this.options.stdTTL;
    this.cache.set(key, { v: value, t: Date.now() + ttl * 1000 });
  };

  del = (key) => {
    this.cache.delete(key);
  };

  // 检查是否过期,过期则删除
  _check = (key, data) => {
    if (data.t !== 0 && data.t < Date.now()) {
      this.del(key);
      return false;
    }
    return true;
  };

  _checkData = () => {
    for (const [key, value] of this.cache.entries()) {
      this._check(key, value);
    }

    if (this.options.checkperiod > 0) {
      const timeout = setTimeout(
        this._checkData,
        this.options.checkperiod * 1000
      );

      // https://nodejs.org/api/timers.html#timeoutunref
      // 清除事件循环对timeout的引用。如果事件循环中不存在其他活跃事件,则直接退出进程
      if (timeout.unref != null) {
        timeout.unref();
      }
    }
  };
}

module.exports = Cache;

我们添加了一个checkperiod的参数,同时在初始化时开启了定时检查过期缓存的逻辑。这里使用了timeout.unref()来清除清除事件循环对timeout的引用,这样如果事件循环中不存在其他活跃事件了,就可以直接退出

js 复制代码
const timeout = setTimeout(
  this._checkData,
  this.options.checkperiod * 1000
);

// https://nodejs.org/api/timers.html#timeoutunref
// 清除事件循环对timeout的引用。如果事件循环中不存在其他活跃事件,则直接退出进程
if (timeout.unref != null) {
  timeout.unref();
}

克隆数据

当我们尝试在缓存中存入对象数据时,我们可能会遇到下面的问题:

js 复制代码
const cache = new Cache();

const data = { val: 100 };

cache.set("data", data);

data.val = 101;

cache.get("data") // { val: 101 }

由于缓存中保存的是引用,可能导致缓存内容被意外的更改,这就让人不太放心的。为了用起来没有顾虑,我们需要支持一下数据的克隆,也就是深拷贝。

js 复制代码
const cloneDeep = require("lodash.clonedeep");

class Cache {
  constructor(options) {
    this.cache = new Map();
    this.options = Object.assign(
      {
        stdTTL: 0, // 缓存有效期,单位为s
        checkperiod: 600, // 定时检查过期缓存,单位为s
        useClones: true, // 是否使用clone
      },
      options
    );
    this._checkData();
  }

  get = (key) => {
    const ret = this.cache.get(key);
    if (ret && this._check(key, ret)) {
      return this._unwrap(ret);
    } else {
      return void 0;
    }
  };

  set = (key, value, ttl) => {
    this.cache.set(key, this._wrap(value, ttl));
  };

  del = (key) => {
    this.cache.delete(key);
  };

  // 检查是否过期,过期则删除
  _check = (key, data) => {
    if (data.t !== 0 && data.t < Date.now()) {
      this.del(key);
      return false;
    }
    return true;
  };

  _checkData = () => {
    for (const [key, value] of this.cache.entries()) {
      this._check(key, value);
    }

    if (this.options.checkperiod > 0) {
      const timeout = setTimeout(
        this._checkData,
        this.options.checkperiod * 1000
      );

      if (timeout.unref != null) {
        timeout.unref();
      }
    }
  };

  _unwrap = (value) => {
    return this.options.useClones ? cloneDeep(value.v) : value.v;
  };

  _wrap = (value, ttl) => {
    ttl = ttl ?? this.options.stdTTL;
    return {
      t: ttl === 0 ? 0 : Date.now() + ttl * 1000,
      v: this.options.useClones ? cloneDeep(value) : value,
    };
  };
}

我们使用lodash.clonedeep来实现深拷贝,同时添加了一个useClones的参数来设置是否需要克隆数据。需要注意,在对象较大时使用深拷贝是比较消耗时间的。我们可以根据实际情况来决定是否需要使用克隆,或实现更高效的拷贝方法。

添加事件

有时我们需要在缓存数据过期时执行某些逻辑,所以我们可以在缓存上添加事件。我们需要使用到EventEmitter类。

js 复制代码
const { EventEmitter } = require("node:events");
const cloneDeep = require("lodash.clonedeep");

class Cache extends EventEmitter {
  constructor(options) {
    super();

    this.cache = new Map();
    this.options = Object.assign(
      {
        stdTTL: 0, // 缓存有效期,单位为s
        checkperiod: 600, // 定时检查过期缓存,单位为s
        useClones: true, // 是否使用clone
      },
      options
    );
    this._checkData();
  }

  get = (key) => {
    const ret = this.cache.get(key);
    if (ret && this._check(key, ret)) {
      return this._unwrap(ret);
    } else {
      return void 0;
    }
  };

  set = (key, value, ttl) => {
    this.cache.set(key, this._wrap(value, ttl));

    this.emit("set", key, value);
  };

  del = (key) => {
    this.cache.delete(key);

    this.emit("del", key, oldVal.v);
  };

  // 检查是否过期,过期则删除
  _check = (key, data) => {
    if (data.t !== 0 && data.t < Date.now()) {
      this.emit("expired", key, data.v);

      this.del(key);
      return false;
    }
    return true;
  };

  _checkData = () => {
    for (const [key, value] of this.cache.entries()) {
      this._check(key, value);
    }

    if (this.options.checkperiod > 0) {
      const timeout = setTimeout(
        this._checkData,
        this.options.checkperiod * 1000
      );

      if (timeout.unref != null) {
        timeout.unref();
      }
    }
  };

  _unwrap = (value) => {
    return this.options.useClones ? cloneDeep(value.v) : value.v;
  };

  _wrap = (value, ttl) => {
    ttl = ttl ?? this.options.stdTTL;
    return {
      t: ttl === 0 ? 0 : Date.now() + ttl * 1000,
      v: this.options.useClones ? cloneDeep(value) : value,
    };
  };
}

module.exports = Cache;

继承EventEmitter类后,我们只需在判断数据过期时通过this.emit()触发事件即可。如下:

js 复制代码
this.emit("expired", key, value);

这样使用缓存时就能监听过期事件了。

js 复制代码
const cache = new Cache({ stdTTL: 1 });

cache.on("expired", (key ,value) => {
  // ...
})

到这里,我们基本上就实现了node-cache库的核心逻辑了。

限制缓存大小!!

稍等,我们似乎忽略了一个重要的点。在高并发请求下,如果缓存激增,则内存会有被耗尽的风险。无论如何,缓存只是用来优化的,它不能影响主程序的正常运行。所以,限制缓存大小至关重要!

我们需要在缓存超过最大限制时自动清理缓存,一个常用的清除算法就是LRU,即清除最近最少使用的那部分数据。这里使用了yallist来实现LRU队列,方案如下:

  • LRU队列里的首部保存最近使用的数据,最近最少使用的数据则会移动到队尾。在缓存超过最大限制时,优先移除队列尾部数据。
  • 执行get/set操作时,将此数据节点移动/插入到队首
  • 缓存超过最大限制时,移除队尾数据
js 复制代码
const { EventEmitter } = require("node:events");
const clone = require("clone");
const Yallist = require("yallist");

class Cache extends EventEmitter {
  constructor(options) {
    super();

    this.options = Object.assign(
      {
        stdTTL: 0, // 缓存有效期,单位为s
        checkperiod: 600, // 定时检查过期缓存,单位为s
        useClones: true, // 是否使用clone
        lengthCalculator: () => 1, // 计算长度
        maxLength: 1000,
      },
      options
    );
    this._length = 0;
    this._lruList = new Yallist();
    this._cache = new Map();
    this._checkData();
  }

  get length() {
    return this._length;
  }

  get data() {
    return Array.from(this._cache).reduce((obj, [key, node]) => {
      return { ...obj, [key]: node.value.v };
    }, {});
  }

  get = (key) => {
    const node = this._cache.get(key);
    if (node && this._check(node)) {
      this._lruList.unshiftNode(node); // 移动到队首
      return this._unwrap(node.value);
    } else {
      return void 0;
    }
  };

  set = (key, value, ttl) => {
    const { lengthCalculator, maxLength } = this.options;

    const len = lengthCalculator(value, key);
    // 元素本身超过最大长度,设置失败
    if (len > maxLength) {
      return false;
    }

    if (this._cache.has(key)) {
      const node = this._cache.get(key);
      const item = node.value;

      item.v = value;
      this._length += len - item.l;
      item.l = len;

      this.get(node); // 更新lru
    } else {
      const item = this._wrap(key, value, ttl, len);
      this._lruList.unshift(item); // 插入到队首
      this._cache.set(key, this._lruList.head);
      this._length += len;
    }

    this._trim();
    this.emit("set", key, value);
    return true;
  };

  del = (key) => {
    if (!this._cache.has(key)) {
      return false;
    }
    const node = this._cache.get(key);
    this._del(node);
  };

  _del = (node) => {
    const item = node.value;
    this._length -= item.l;
    this._cache.delete(item.k);
    this._lruList.removeNode(node);

    this.emit("del", item.k, item.v);
  };

  // 检查是否过期,过期则删除
  _check = (node) => {
    const item = node.value;
    if (item.t !== 0 && item.t < Date.now()) {
      this.emit("expired", item.k, item.v);

      this._del(node);
      return false;
    }

    return true;
  };

  _checkData = () => {
    for (const node of this._cache) {
      this._check(node);
    }

    if (this.options.checkperiod > 0) {
      const timeout = setTimeout(
        this._checkData,
        this.options.checkperiod * 1000
      );

      if (timeout.unref != null) {
        timeout.unref();
      }
    }
  };

  _unwrap = (item) => {
    return this.options.useClones ? clone(item.v) : item.v;
  };

  _wrap = (key, value, ttl, length) => {
    ttl = ttl ?? this.options.stdTTL;
    return {
      k: key,
      v: this.options.useClones ? clone(value) : value,
      t: ttl === 0 ? 0 : Date.now() + ttl * 1000,
      l: length,
    };
  };

  _trim = () => {
    const { maxLength } = this.options;

    let walker = this._lruList.tail;
    while (this._length > maxLength && walker !== null) {
      // 删除队尾元素
      const prev = walker.prev;
      this._del(walker);
      walker = prev;
    }
  };
}

代码中还增加了两个额外的配置选项:

js 复制代码
options = {
  lengthCalculator: () => 1, // 计算长度
  maxLength: 1000, // 缓存最大长度
}

lengthCalculator支持我们自定义数据长度的计算方式。默认情况下maxLength指的就是缓存数据的数量。然而在遇到Buffer类型的数据时,我们可能希望限制最大的字节数,那么就可以像下面这样定义:

js 复制代码
const cache = new Cache({
  maxLength: 500,
  lengthCalculator: (value) => {
    return value.length;
  },
});

const data = Buffer.alloc(100);
cache.set("data", data);

console.log(cache.length); // 100

这一部分的代码就是参考社区中的lru-cache实现的。

多级缓存

如果应用本身已经依赖了数据库的话,我们不妨再加一层数据库缓存,来实现多级缓存:将内存作为一级缓存(容量小,速度快),将数据库作为二级缓存(容量大,速度慢) 。有两个优点:

  1. 能够存储的缓存数据大大增加。虽然数据库缓存查询速度比内存慢,但相比原始查询还是要快得多的。
  2. 重启应用时能够从数据库恢复缓存。

通过下面的方法可以实现一个多级缓存:

js 复制代码
function multiCaching(caches) {
  return {
    get: async (key) => {
      let value, i;
      for (i = 0; i < caches.length; i++) {
        try {
          value = await caches[i].get(key);
          if (value !== undefined) break;
        } catch (e) {}
      }

      // 如果上层缓存没查到,下层缓存查到了,需要同时更新上层缓存
      if (value !== undefined && i > 0) {
        Promise.all(
          caches.slice(0, i).map((cache) => cache.set(key, value))
        ).then();
      }

      return value;
    },
    set: async (key, value) => {
      await Promise.all(caches.map((cache) => cache.set(key, value)));
    },
    del: async (key) => {
      await Promise.all(caches.map((cache) => cache.del(key)));
    },
  };
}

const multiCache = multiCaching([memoryCache, dbCache]);
multiCache.set(key, value)

dbCache对数据量大小不是那么敏感,我们可以在执行get/set操作时设置数据的最近使用时间,并在某个时刻清除最近未使用数据,比如在每天的凌晨自动清除超过30天未使用的数据。

另外我们还需要在初始化时加载数据库缓存到内存中,比如按最近使用时间倒序返回3000条数据,并存储到内存缓存中。

参考

相关推荐
丰云3 小时前
一个简单封装的的nodejs缓存对象
缓存·node.js
泰伦闲鱼3 小时前
nestjs:GET REQUEST 缓存问题
服务器·前端·缓存·node.js·nestjs
敲啊敲95274 小时前
5.npm包
前端·npm·node.js
j喬乔5 小时前
Node导入不了命名函数?记一次Bug的探索
typescript·node.js
z千鑫6 小时前
【前端】入门指南:Vue中使用Node.js进行数据库CRUD操作的详细步骤
前端·vue.js·node.js
小马哥编程12 小时前
原型链(Prototype Chain)入门
css·vue.js·chrome·node.js·原型模式·chrome devtools
蜜獾云18 小时前
npm淘宝镜像
前端·npm·node.js
dz88i818 小时前
修改npm镜像源
前端·npm·node.js
CodeChampion1 天前
61.基于SpringBoot + Vue实现的前后端分离-在线动漫信息平台(项目+论文)
java·vue.js·spring boot·后端·node.js·maven·idea
小王码农记1 天前
解决npm publish发布包后拉取时一直提示 Couldn‘t find any versions for “包名“ that matches “版本号“
前端·npm·node.js