在数据库查询遇到瓶颈时,我们通常可以采用缓存来提升查询速度,同时缓解数据库压力。常用的缓存数据库有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实现缓存系统的资料请关注代码网其它相关文章!
发表评论