在Nodejs中实现一个缓存系统的方法详解
作者:Knockkk
在数据库查询遇到瓶颈时,我们通常可以采用缓存来提升查询速度,同时缓解数据库压力。常用的缓存数据库有Redis、Memcached等。在一些简单场景中,我们也可以自己实现一个缓存系统,避免使用额外的缓存中间件。这篇文章将带你一步步实现一个完善的缓存系统,它将包含过期清除、数据克隆、事件、大小限制、多级缓存等功能。
一个最简单的缓存
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结构来保存数据,使用方式也很简单:
const cache = new Cache(); cache.set("a", "aaa"); cache.get("a") // aaa
添加过期时间
接下来我们尝试为缓存设置一个过期时间。在获取数据时,如果数据已经过期了,则清除它。
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;
我们写个用例来测试一下:
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来实现,像下面这样:
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() 得到它们。
const cache = new Cache({ length: 100 }); Object.keys(cache); // [] Object.getOwnPropertySymbols(cache); // [Symbol(length)]
定期清除过期缓存
之前只会在get时判断缓存是否过期,然而如果不对某个key进行get操作,则过期缓存永远不会被清除,导致无效的缓存堆积。接下来我们要实现定期自动清除过期缓存的功能。
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的引用,这样如果事件循环中不存在其他活跃事件了,就可以直接退出。
const timeout = setTimeout( this._checkData, this.options.checkperiod * 1000 ); // https://nodejs.org/api/timers.html#timeoutunref // 清除事件循环对timeout的引用。如果事件循环中不存在其他活跃事件,则直接退出进程 if (timeout.unref != null) { timeout.unref(); }
克隆数据
当我们尝试在缓存中存入对象数据时,我们可能会遇到下面的问题:
const cache = new Cache(); const data = { val: 100 }; cache.set("data", data); data.val = 101; cache.get("data") // { val: 101 }
由于缓存中保存的是引用,可能导致缓存内容被意外的更改,这就让人不太放心的。为了用起来没有顾虑,我们需要支持一下数据的克隆,也就是深拷贝。
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
类。
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()
触发事件即可。如下:
this.emit("expired", key, value);
这样使用缓存时就能监听过期事件了。
const cache = new Cache({ stdTTL: 1 }); cache.on("expired", (key ,value) => { // ... })
到这里,我们基本上就实现了node-cache库的核心逻辑了。
限制缓存大小!!
稍等,我们似乎忽略了一个重要的点。在高并发请求下,如果缓存激增,则内存会有被耗尽的风险。无论如何,缓存只是用来优化的,它不能影响主程序的正常运行。所以,限制缓存大小至关重要!
我们需要在缓存超过最大限制时自动清理缓存,一个常用的清除算法就是LRU,即清除最近最少使用的那部分数据。这里使用了yallist来实现LRU队列,方案如下:
- LRU队列里的首部保存最近使用的数据,最近最少使用的数据则会移动到队尾。在缓存超过最大限制时,优先移除队列尾部数据。
- 执行get/set操作时,将此数据节点移动/插入到队首。
- 缓存超过最大限制时,移除队尾数据。
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; } }; }
代码中还增加了两个额外的配置选项:
options = { lengthCalculator: () => 1, // 计算长度 maxLength: 1000, // 缓存最大长度 }
lengthCalculator
支持我们自定义数据长度的计算方式。默认情况下maxLength
指的就是缓存数据的数量。然而在遇到Buffer类型的数据时,我们可能希望限制最大的字节数,那么就可以像下面这样定义:
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实现的。
多级缓存
如果应用本身已经依赖了数据库的话,我们不妨再加一层数据库缓存,来实现多级缓存:将内存作为一级缓存(容量小,速度快),将数据库作为二级缓存(容量大,速度慢) 。有两个优点:
- 能够存储的缓存数据大大增加。虽然数据库缓存查询速度比内存慢,但相比原始查询还是要快得多的。
- 重启应用时能够从数据库恢复缓存。
通过下面的方法可以实现一个多级缓存:
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条数据,并存储到内存缓存中。
参考
以上就是在Nodejs中实现一个缓存系统的方法详解的详细内容,更多关于Nodejs实现缓存系统的资料请关注脚本之家其它相关文章!