为什么要搭建自己的缓存管理模块?
这个问题其实也是在问,为什么不使用现有的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算法并非适用于任何的业务逻辑:
- 有的业务可以无需给缓存设置过期时间;
- 在某些场景中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);// 只需返回用户所要的valuereturn _cache[key].value;// 如果已经过期} else if (expire && curTime - insertTime > expire) {// 从队列中删除 _queue.del(node);return null}} else {return null;} }
LFU算法中更新索引:
var update = function (node) {// 访问次数+1node.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;// 测试每一个算法是否能获得请求的cachefor (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
参考文献:
Javascript Style Tip: Use “in” and “delete”
Deleting Objects in JavaScript
delete
Understanding delete
delete vs undefined vs null
What is the performance of Objects/Arrays in JavaScript? (specifically for Google V8)
Garbage Collection
Redis ignores maxmemory setting
Redis as an LRU cache Friday
Redis configuration
转自:http://www.infoq.com/cn/articles/built-cache-management-module-in-nodejs/
转载于:https://www.cnblogs.com/duhuo/p/4769716.html
为什么要搭建自己的缓存管理模块?相关推荐
- [Java开发]搭建人力资源管理系统——简历管理模块(附带下载链接)
最近一位老哥让我给他的公司开发一套人力资源管理系统,并详细描述了这个系统的一些功能,我也查找了一些人力资源的资料.因为跟老哥关系不错,就答应了他.大家都知道,人力资源管理就是管人的,从给公司开始投递简 ...
- 尚硅谷在线教育一:尚硅谷在线教育相关的基本搭建配置以及讲师管理模块
文章目录 1.项目的总体说明 1.1项目的功能模块说明 1.2项目设计的技术 2创建一个名为guli的springboot的父项目 3. 在guli的项目下创建一个子模块common用于公共使用的模块 ...
- 做网站应该搭建哪些功能栏目管理模块?
中国的互联网在调整发展,带企业带来了巨大的网络营销市场与机遇.如果一家传统企业不再加入互联网营销的大潮,则很快会被老的市场运营模式淘汰.也要跟上互联网的发展节奏与市场,不能被互联网抛弃.想要加入互联网 ...
- 如何高效使用和管理Bitmap--图片缓存管理模块的设计与实现
传送门☞轮子的专栏☞转载请注明☞http://blog.csdn.net/leverage_1229 上周为360全景项目引入了图片缓存模块.因为是在Android4.0平台以上运作,出于惯性,都会在 ...
- flutter图片识别_Flutter 图片解码与缓存管理研究
写作费时,敬请点赞,关注,收藏三连. 图片解码和缓存管理是渲染引擎的一个重要模块,这是因为图片解码的耗时很长,特别是对于设计为跨平台的通用渲染引擎来说,依赖于CPU来做图片解码,会消耗大量的CPU时间 ...
- Glide核心设计二:缓存管理
原文链接:Glide核心设计二:缓存管理 引言 Glide作为一个优秀的图片加载框架,缓存管理是必不可少的一部分,这篇文章主要通过各个角度.从整体设计到代码实现,深入的分析Glide的缓存管理模块,力 ...
- 使用shiro的会话管理和redis缓存管理来构建登录模块spring+struts+hibernate(SSH)
shiro是一个很好用的安全框架,主要表现在用户认证,权限认证,会话管理,如果想优化还可以做Cache管理,我们不需要做太多工作在用户身份token安全方面(记录shiro及用redis开发的步骤及一 ...
- 电商平台搭建--订单管理模块开发(一)
Hi,大家好,我是Steafan!项目开发到这里,基本上已经进行了90%了.项目中最关键的也是最难开发的就是今天我要向大家介绍的订单管理模块了.在实际的项目开发中,订单管理模块和支付模块往往是并行开发 ...
- 管理后台项目-06-用户管理角色管理模块
目录 1-路由信息搭建和api文件信息创建 2-用户管理模块 2.1-列表数据获取以及动态渲染 2.2-添加 | 修改 用户 2.3-删除-批量删除用户 2.4-分配角色 3-角色管理 3.1-修改角 ...
最新文章
- 吴恩达机器学习笔记 —— 10 神经网络参数的反向传播算法
- startsWith(),endsWith()的作用,用法,判断字符串a 是不是以字符串b开头或结尾
- python json提取器_【编号0004】爬虫中常用到的数据提取手段
- html怎么样取jsp中的路径,jsp中获得路径的两种方法和获得url路径的方法(推荐)
- 记录一个找问题的经过
- 元气骑士里的超级计算机,元气骑士:本以为“素颜相机”够火,直到看到它,一秒笑出猪叫声...
- Redis Bloom布隆过滤器的安装及使用
- php$_GET 变量
- Gradle中的默认任务和任务依赖关系设置
- Selenium 控制浏览器
- [USACO] Beef McNuggets
- java+跑多线程_java项目怎么多线程跑单元测试,ide是intellij?
- 8位可控加减法电路设计_C++手撕底层:位、字节、原码、反码、补码的深入理解...
- 【Python实例第34讲】高斯过程分类:XOR数据集
- sftp连不上服务器 vscode_vscode+sftp 开发模式环境的同步
- 怎样才能叫高级程序员?
- 程序包com.wonhyoo.common.entity不存在, 找不到符号
- android精选源码,erp交互效果摩天轮高仿斗鱼直播View炫酷进度条源码
- 腾讯云服务器部署教程
- mysql判断叠字_. 请在以下作品中选出皆使用了“叠字”手法的作品。( ___ )