http://www.infoq.com/cn/articles/built-cache-management-module-in-nodejs/

为什么要搭建自己的缓存管理模块?

这个问题其实也是在问,为什么不使用现有的Cache存储系统,比如Redis,比如Memcached。不是说Redis不够好,只是在处理某些场景中使用的Redis会显的太“笨重”了——Redis的优势之一在于能够供多进程共享,有完善的备份和恢复机制。但反过来想,如果你的缓存仅供单个进程,单个Node实例使用,并且可以容忍缓存的丢失,承受冷启动。那么是值得用不到500行的代码来搭建一个速度更快的缓存模块。

在Node中做缓存最简单的作法莫过于使用一个Object对象,将缓存以key-value的形式存入这个对象中,并且这么做的理由只有一个,就是更快的存取速度。相比Redis通过TCP连接的形式与客户端进行通信,在程序中直接使用对象进行存储的效率会是Redis的40倍。在文章的最后给出的完整的源代码中,有一个Redis与这个500行代码的性能对比测试:10000次的set操作,Redis使用的时间为12.5秒左右,平均运算次数为(operations per second)为8013 o/s,而如果使用原生的Object对象,10000次操作只需要0.3秒,平均运算次数为322581 o/s

搭建自己的Cache模块需要解决什么问题

缓存淘汰算法

介于缓存只能够有限的使用内存,任何Cache系统都需要一个如何淘汰缓存的方案(缓存淘汰算法,等同于页面置换算法)。在Node中无法像Redis那样设置使用内存大小(通过Redis中的maxmemory配置选项),所以我们只能通过设置缓存的个数(key-value对数)来间接对缓存大小进行控制。但这同时也赋予了我们另一自由,就是用何种算法来淘汰多余的缓存,以便能提高命中率。

 

Redis只提供五种淘汰方案(maxmemory-policy):

  • volatile-lru: remove a key among the ones with an expire set, trying to remove keys not recently used(根据过期时间,移除最长时间没有使用过的).
  • volatile-ttl: remove a key among the ones with an expire set, trying to remove keys with short remaining time to live(根据过期时间,移除即将过期的).
  • volatile-random: remove a random key among the ones with an expire set(根据过期时间任意移除一个).
  • allkeys-lru: like volatile-lru, but will remove every kind of key, both normal keys or keys with an expire set(无论是否有过期时间,根据LRU原则来移除).
  • allkeys-random: like volatile-random, but will remove every kind of keys, both normal keys and keys with an expire set(无论是否有过期时间,随机移除).

可见Redis的移除策略大部分是根据缓存的过期时间和LRU(Least Recently Used,最近最少使用,,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”)算法。

但过期时间和LRU算法并非适用于任何的业务逻辑:

 
  1. 有的业务可以无需给缓存设置过期时间;
  2. 在某些场景中LFU(Least Frequently Used,最近最多使用,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”)算法比LRU更优,能够减少缓存缓存污染。

同时正因为LRU算法存在一定的缺陷(存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降),才会有一系列的LRU算法的变形,比如LRU-K, Two queues, Multi Queue等。

所以我们决定在缓存模块中嵌入多个淘汰算法,不仅仅如此,我还设想将当用户不确定他所需要的淘汰算法时,我们可以同时运行多个算法,比如对前100000次get操作的各个算法进行命中率统计,100000次操作之后自动切换至命中率最高的算法。

数据结构

以LRU算法为例,因为需要根据缓存访问的新鲜度来淘汰冷门缓存,非常明显这会是一个队首进热门数据,队尾出冷门数据的队列,假设我们用数组来实现:

Recently used unshift in
Cold cache pop
------>[{key: value}, {key: value},{key: value}......
{key: value}]------>
| |
|<--------------Recently used--------------------|

每一项的数据结构如下:

var cache = [
{
key: key,
value: value,
expire: 1000 * 3
},
{
key: key,
value: value,
expire: 1000 * 3
}
...
]

那么在每一次取缓存时(get操作),就不得不对这个数组进行遍历。因为遍历的时间复杂度会是O(n),如果当n较大时,遍历花费的时间(包括遍历判断是否过期,以及过期之后的连锁操作)是相当可观的。

所以我们应该避免遍历——为了争取时间上的优势,就不得不在空间上有所牺牲。

仅仅考虑优化get操作的话,最理想的状态是把所有的key-value缓存都存入一个Object中,这样以来每次get操作都无需遍历,直接通过key就可以取得相应的value值:

var cache = {
key1: {
value: "value1",
expire: 2000,
...
},
key2: {
value: "value2",
expire: 2000,
...
}
} // Get 方法
var get = function (key) {
return cache[key];
}

// Get 方法 var get = function (key) { return cache[key]; } ```

那么的队列如何体现?我的解决方案是另提供一个索引链表,仅将所以的key存入链表中:

head => key1 <=> key2 <=> key3 <=> ...<=> keyn <= tail

那么如何将索引与缓存关联起来呢?Key吗?根据用户传入的key再去索引链表中查找位置吗?这又回到了遍历,并且比数组更耗费时间。

众所周知,链表是通过无数个节点以前后指针的形式连接起来的,考虑到避免遍历,便于插入,删除等操作,该链表应该是双向链表,每一个key在链表中对应一个节点结构为:

var node = {
key: "key",
count: 0 //访问次数,供LFU算法使用
prev: null,
next: null
}

每当有新的缓存插入时,链表应该返回被插入的节点的引用,缓存除了记录value,expire参数外,还应该记录自己节点在链表中的引用

var cache = {
key1: {
value: "value1",
expire: 2000,
node: node //在链表中对应位置的引用
}
}

这样以来,当我们尝试get某个缓存时,我们能通过节点的引用(以上代码中的node字段)很快的得到该缓存在队列中的位置,并且跳过遍历,仅通过修改相关节点的指针,来对队列的顺序进行调整,以便能即使反馈数据的冷热程度。

缓存逻辑与算法的分离

在上一节我讲过希望能使用户根据自己的业务需求选择相应的缓存淘汰算法,那么就要考虑将算法独立出来,并提供相同的接口,供上一层调用。结构如下图所示:

|  Cache    Algorithm      Link
|
|---set---|---insert---|---unshift(LRU)
| |
| |---push(LFU)
| |
| |---pop
|
|---get---|---update---|---moveHead(LRU)
| | |
| | |---forward(LFU)
| | |
| | |---backward(LFU)
| |
|-expire--|---del------|---del

注意到在Algorithm算法层,虽然每个算法提供的接口都看上去相同并且非常简单,仅有插入链表(insert),更新链表(update),删除节点(del)三个接口,内部的实现却大相径庭,但实质上是对链表各个方法的调用。

以插入链表(insert)为例,在LRU算法中最近访问的数据在队首,较长时间未访问数据靠近队尾,所以数据务必从队首进,队尾出,所以插入队首时应该调用的是链表的unshift方法,并且插入之后如果队列超长,那么需要调用链表的pop方法将队尾元素弹出。

而LFU算法不同,虽然热门数据同样待在队首,但介于新数据的访问次数少热度低,应该从队尾进,所以插入时应该调用的方法是push,并且如果无位置插入,需要先将队尾的冷门数据用方法pop弹出。所以LFU队列的数据是队尾进,队尾出。

实现

当数据结构,接口,架构决定好之后,实现不过就是按部就班的事情了。

在这里只是把一些关键步骤代码列出来,并且给予适当的注释。

链表的实现在这里就忽略了,这部分内容可以参考数据结构的相关书籍

Cache.set

var set = function (key, value, expire) {
// 缓存对象
var _cache = this.cache;
// 索引链表
var _queue = this.queue; // 如果已经存在该值,则重新赋值,更新过期时间
if (_cache[key]) { // 重新赋值
_cache[key].value = value;
_cache[key].expire = expire; // 更新之后,同时也要更新索引队列
// 但在这里无需关心细节,LRU与LFU的更新规则不同
// 只需调用统一的接口
_queue.update(_cache[key].node); // 如果新插入缓存
} else { var returnNode = _queue.insert(key);
/*
注意上面的returnNode,
var returnNode = {
node: node, // 新插入索引节点的引用
delArr: delArr // 需要删除的缓存key
}
1. 它并不仅仅返回被插入索引节点的引用
2. 它还返回了一个数组,存储了因为插入新节点,而导致链表超长
而需要被删除缓存的key
*/ _cache[key] = {
value: value,
expire: expire,
/*
除了value和过期时间外
还要存储多余的信息
比如插入缓存的时间,以便对比是否过期
*/
insertTime: +new Date(),
node: returnNode.node
} // 删除多余缓存
returnNode.delArr.forEach(function (key) {
_cache[key] = null;
});
}
}

有一点需要注意,为什么在最后我会用置为null _cache[key] = null来删除缓存,而不用更明显的delete _cache[key]?要知道delete并非强制将_cache[key]引用的对象的内存释放,因为在V8中我们是无法强制进行Garbage Collection(在其他引擎中应该也不行)。所以置为null与delete,两者的原理其实相同,删除的都是_cache[key]的引用(详细原理可以参考文章最后给出的参考文献)。

使用null的原因只有一个,那就是更高的效率,你可以在Node环境或者浏览器中执行下面这段代码

var maxRound = 100 * 100 * 20;
(function () {
var obj = {}; for (var i = 0; i < maxRound; i++) {
obj["key_" + i] = "value_"+ i;
} var start = +new Date(); for (var key in obj) {
delete obj[key];
//obj[key] = null;
} var end = +new Date(); console.log("Delete | Total cost:", end - start, "ms");
})()

你可以把代码中注释的obj[key] = null;delete obj[key];互换,来对比执行效率,很明显置为null会比delete节约一半时间。

Cache.get

var get = function (key) {
var _cache = this.cache;
var _queue = this.queue; // 如果存在该值
if (_cache[key]) {
var insertTime = _cache[key].insertTime;
var expire = _cache[key].expire;
var node = _cache[key].node;
var curTime = +new Date(); // 如果不存在过期时间 或者 存在过期时间但尚未过期
if (!expire || (expire && curTime - insertTime < expire)) { // 已经使用过,更新索引队列
_queue.update(node); // 只需返回用户所要的value
return _cache[key].value; // 如果已经过期
} else if (expire && curTime - insertTime > expire) { // 从队列中删除
_queue.del(node);
return null
} } else {
return null;
}
}

LFU算法中更新索引:

var update = function (node) {
// 访问次数+1
node.count++; var prevNode = node.prev;
var nextNode = node.next;
var queue = this.queue; // 高访问频率的节点在队首
// 或者说一个节点的前节点的访问次数应该比当前节点高
// 如果相反了,表示不需要调换位置
// 直到前节点的访问次数应该比当前节点高
if (prevNode && prevNode.count < node.count) { while (prevNode && prevNode.count < node.count) {
// 与前一个节点调换位置
queue.forward(node);
prevNode = node.prev;
}
// 情况与上一个if分支刚好相反
// 一个节点的后节点的访问次数应该比当前节点低
} else if (nextNode && nextNode > node.count) { while (nextNode && nextNode > node.count) {
queue.backward(node);
nextNode = node.next;
}
}
}

根据命中率选择适合的算法

如果你不确定你的业务适合哪一种的,我们可以加入机器学习的机制,根据前三万次访问的命中率来选择哪一种算法:

var Cache_LRU = null,
Cache_LFU = null,
// Cache_FIN用来指向最终选择的算法
Cache_FIN = null; // 统计前3万次
var round = 100 * 100 * 3; var Manage = {
// 独立统计每个算法的成功次数
// total表示该算法get方法被调用次数
// suc表示成功次数
"lru": {
cache: Cache.createCache("LRU", 100 * 100 * 5),
suc: 0,
total: 0
},
"lfu": {
cache: Cache.createCache("LFU", 100 * 100 * 5),
suc: 0,
total: 0
}
} exports.set = function (key, value, expire) {
// 如果已经结束了统计命中率的前三万轮
// 表示已经找到了合适的算法
if (!round) {
return Cache_FIN.set(key, value, expire);
} // 用户的每次get与set实际上同时在对所有的算法同时做
// 同时有两份Cache在工作
for (var name in Manage) {
Manage[name].cache.set(key, value, expire);
}
} exports.get = function (key) {
// 如果已经结束了统计命中率的前三万轮
// 表示已经找到了合适的算法
if (!round) {
return Cache_FIN.get(key);
} var value = null; // 测试每一个算法是否能获得请求的cache
for (var name in Manage) {
Manage[name].total++;
value = Manage[name].cache.get(key);
if (value) {
Manage[name].suc++;
}
} // 如果测试完毕,算出命中率
if (!--round) {
var hitRate = {},
max = {
key: "",
rate: 0
}; for (var key in Manage) {
// 算法命中率
hitRate[key] = Manage[key].suc / parseFloat(Manage[key].total); // 找到最高命中率
if (hitRate[key] > max["rate"]) {
max.key = key;
max.rate = hitRate[key];
}
}
// 找到合适的算法
Cache_FIN = Manage[max.key].cache;
} return value;
}

结束

正如本文开头所说,这只是一个简易的Cache模块,不适用多实例,跨进程的场景,甚至一些意想不到更复杂的场景。当然它还有一些提升的空间,比如可以加入更多的淘汰算法,可以加入备份机制。

完整的代码已经放在github上了,包括文章中完整的代码片段与提及的性能测试:https://github.com/hh54188/Node-Simple-Cache

nodejs 搭建自己的简易缓存cache管理模块的更多相关文章

  1. Nodejs+MongoDB+Bootstrap+esj搭建的个人简易博客

    github:https://github.com/yehuimmd/myNodeBloy Nodejs+MongoDB+jQuery+Bootstrap-esj搭建的个人简易博客 主要功能 前台 : ...

  2. POCO库——Foundation组件之缓存Cache

    缓存Cache:内部提供多种缓存Cache机制,并对不同机制的管理缓存策略不同实现: ValidArgs.h :ValidArgs有效键参数类,模板参数实现,_key:键,_isValid:是否有效, ...

  3. 缓存Cache

    转载自  博客futan 这篇文章将全面介绍有关 缓存 ( 互动百科 | 维基百科 )cache以及利用PHP写缓存caching的技术. 什么是缓存Cache? 为什么人们要使用它? 缓存 Cach ...

  4. Java 中常用缓存Cache机制的实现

    所谓缓存,就是将程序或系统经常要调用的对象存在内存中,一遍其使用时可以快速调用,不必再去创建新的重复的实例.这样做可以减少系统开销,提高系统效率. 所谓缓存,就是将程序或系统经常要调用的对象存在内存中 ...

  5. Java 中常用缓存Cache机制的实现《二》

    所谓缓存,就是将程序或系统经常要调用的对象存在内存中,一遍其使用时可以快速调用,不必再去创建新的重复的实例.这样做可以减少系统开销,提高系统效率. AD: Cache 所谓缓存,就是将程序或系统经常要 ...

  6. nodejs搭建http-server

      很多时候我们都需要搭建一个简单的服务器,部署在IIS,阿帕奇,或者用nodejs,网上很多关于nodejs搭建server的文章,但都是要创建server.js,很麻烦, 在这里我分享一个创建ht ...

  7. Java中常用缓存Cache机制的实现

    缓存,就是将程序或系统经常要调用的对象存在内存中,一遍其使用时可以快速调用,不必再去创建新的重复的实例. 这样做可以减少系统开销,提高系统效率. 缓存主要可分为二大类: 一.通过文件缓存,顾名思义文件 ...

  8. web前端效率提升-nginx+nodejs搭建本地生态

    1.起因 编写的项目是一个偏向于后台管理的web系统,使用了angular框架,在绑定数据的时候就依赖于后台的接口格式. 以前是后台写好接口后,我在绑定,在这之前一些逻辑是没法做的,有时候后台接口给的 ...

  9. .NET持续集成与自动化部署之路第二篇——使用NuGet.Server搭建公司内部的Nuget(包)管理器

    使用NuGet.Server搭建公司内部的Nuget(包)管理器 前言     Nuget是一个.NET平台下的开源的项目,它是Visual Studio的扩展.在使用Visual Studio开发基 ...

随机推荐

  1. 20175209 实验三《敏捷开发与XP实践》实验报告

    20175209 实验三<敏捷开发与XP实践>实验报告 一.实验内容 编码标准:在IDEA中使用工具(Code->Reformate Code)把下面代码重新格式化,再研究一下Cod ...

  2. RANSAC与 最小二乘(LS, Least Squares)拟合直线的效果比较

    代码下载地址: 1.Matlab版本:http://pan.baidu.com/s/1eQIzj3c.进入目录后,请自行定位到该博客的源代码与数据的目录“

  3. JS判断指定dom元素是否在屏幕内的方法实例

    前言 刷网页的时候,有时会遇到这样一个情景,当某个dom元素滚到可见区域时,或者图片的懒加载效果,它就会展现显示动画,十分有趣.那么这是如何实现的呢? 实现原理 想要实现这个功能,就要知道具体的实现原 ...

  4. $2018/8/19 = Day5$学习笔记 + 杂题整理

    \(\mathcal{Morning}\) \(Task \ \ 1\) 容斥原理 大概这玩意儿就是来用交集大小求并集大小或者用并集大小求交集大小的\(2333\)? 那窝萌思考已知\(A_1,A_2 ...

  5. day 88 Vue学习之八geetest滑动验证

      本节目录 一 geetest前端web中使用 二 xxx 三 xxx 四 xxx 五 xxx 六 xxx 七 xxx 八 xxx 一 geetest前端web中使用 下载gt文件,官网地址,下面我 ...

  6. AWVS使用手册

    目录: 0×00.什么是Acunetix Web Vulnarability Scanner ( What is AWVS?) 0×01.AWVS安装过程.主要文件介绍.界面简介.主要操作区域简介(I ...

  7. 2017-2018-1 20155320第十周课下作业-IPC

    2017-2018-1 20155320第十周课下作业-IPC 研究Linux下IPC机制:原理,优缺点,每种机制至少给一个示例,提交研究博客的链接 共享内存 管道 FIFO 信号 消息队列 共享内存 ...

  8. 20145234黄斐《Java程序设计》课程总结

    每周作业链接汇总 预习作业一:http://www.cnblogs.com/taigenzhenjun/p/6492903.html 对专业的期望 预习作业二:http://www.cnblogs.c ...

  9. BZOJ 2395 [Balkan 2011]Time is money

    题面 题解 将\(\sum_i c_i\)和\(\sum_i t_i\)分别看做分别看做\(x\)和\(y\),投射到平面直角坐标系中,于是就是找\(xy\)最小的点 于是可以先找出\(x\)最小的点 ...

  10. CF13E Holes LCT

    CF13E Holes LG传送门 双倍经验题,几乎同[HNOI2010]弹飞绵羊,LCT练手题,LG没有LCT题解于是发一波. 从当前点向目标点连边,构成一棵树,带修改就用LCT动态维护答案,由于不 ...