轉載請註明出處:葡萄城官網,葡萄城爲開發者提供專業的開發工具、解決方案和服務,賦能開發者。
在文章的開始我們需要了解什麼是緩存?緩存是預先根據數據列表準備一些重要數據。沒有緩存的話,系統的吞吐量就取決於存儲速度最慢的數據,因此保持應用程序高性能的一個重要優化就是緩存。web應用程序中有兩項很重要的工作,分別是文件和視頻Blob的緩存和快速訪問頁面模板。而在NodeJS中,非異步功能操作的延遲會決定系統什麼時候爲其他客戶端提供服務,儘管操作系統有自己的文件緩存機制,但是同一個服務器中有多個web應用程序同時運行,且其中一個應用正在傳輸大量視頻數據的時候,其他應用的緩存內容就可能會頻繁失效,此時程序效率會大幅降低。
而針對應用程序資源的LRU算法能有效解決這個問題,使應用程序不被同一服務器中的其他應用程序緩存所影響。考慮到存儲速度最慢數據決系統吞吐量的這一點,LRU緩存的存在能將系統性能提高2倍至100倍;同時,異步LRU會隱藏全部高速緩存未命中的延遲。
接下來我們一起來看具體實現的內容。
代碼展示
- 首先構建一個用來構造LRU對象模塊的文件:
1 'use strict'; 2 let Lru = function(cacheSize,callbackBackingStoreLoad,elementLifeTimeMs=1000){ 3 let me = this; 4 let maxWait = elementLifeTimeMs; 5 let size = parseInt(cacheSize,10); 6 let mapping = {}; 7 let mappingInFlightMiss = {}; 8 let buf = []; 9 for(let i=0;i<size;i++) 10 { 11 let rnd = Math.random(); 12 mapping[rnd] = i; 13 buf.push({data:"",visited:false, key:rnd, time:0, locked:false}); 14 } 15 let ctr = 0; 16 let ctrEvict = parseInt(cacheSize/2,10); 17 let loadData = callbackBackingStoreLoad; 18 this.get = function(key,callbackPrm){ 19 20 let callback = callbackPrm; 21 if(key in mappingInFlightMiss) 22 { 23 setTimeout(function(){ 24 me.get(key,function(newData){ 25 callback(newData); 26 }); 27 },0); 28 return; 29 } 30 31 if(key in mapping) 32 { 33 // RAM speed data 34 if((Date.now() - buf[mapping[key]].time) > maxWait) 35 { 36 if(buf[mapping[key]].locked) 37 { 38 setTimeout(function(){ 39 me.get(key,function(newData){ 40 callback(newData); 41 }); 42 },0); 43 } 44 else 45 { 46 delete mapping[key]; 47 48 me.get(key,function(newData){ 49 callback(newData); 50 }); 51 } 52 } 53 else 54 { 55 buf[mapping[key]].visited=true; 56 buf[mapping[key]].time = Date.now(); 57 callback(buf[mapping[key]].data); 58 } 59 } 60 else 61 { 62 // datastore loading + cache eviction 63 let ctrFound = -1; 64 while(ctrFound===-1) 65 { 66 if(!buf[ctr].locked && buf[ctr].visited) 67 { 68 buf[ctr].visited=false; 69 } 70 ctr++; 71 if(ctr >= size) 72 { 73 ctr=0; 74 } 75 76 if(!buf[ctrEvict].locked && !buf[ctrEvict].visited) 77 { 78 // evict 79 buf[ctrEvict].locked = true; 80 ctrFound = ctrEvict; 81 } 82 83 ctrEvict++; 84 if(ctrEvict >= size) 85 { 86 ctrEvict=0; 87 } 88 } 89 90 mappingInFlightMiss[key]=true; 91 let f = function(res){ 92 delete mapping[buf[ctrFound].key]; 93 buf[ctrFound] = 94 {data: res, visited:false, key:key, time:Date.now(), locked:false}; 95 mapping[key] = ctrFound; 96 callback(buf[ctrFound].data); 97 delete mappingInFlightMiss[key]; 98 }; 99 loadData(key,f); 100 } 101 }; 102 }; 103 104 exports.Lru = Lru;
- 文件緩存示例:
1 let Lru = require("./lrucache.js").Lru; 2 let fs = require("fs"); 3 let path = require("path"); 4 5 let fileCache = new Lru(500, async function(key,callback){ 6 // cache-miss data-load algorithm 7 fs.readFile(path.join(__dirname,key),function(err,data){ 8 if(err) { 9 callback({stat:404, data:JSON.stringify(err)}); 10 } 11 else 12 { 13 callback({stat:200, data:data}); 14 } 15 }); 16 },1000 /* cache element lifetime */);
使用LRU構造函數獲取參數(高速緩存大小、高速緩存未命中的關鍵字和回調、高速緩存要素生命週期)來構造CLOCK高速緩存。
- 異步緩存未命中回調的工作方式如下:
1.一些get()在緩存中找不到密鑰
2.算法找到對應插槽
3.運行此回調:
在回調中,重要計算異步完成
回調結束時,將回調函數的回調返回到LRU緩存中
4. 再次訪問同一密鑰的數據來自RAM
該依賴的唯一實現方法get():
1 fileCache.get("./test.js",function(dat){ 2 httpResponse.writeHead(dat.stat); 3 httpResponse.end(dat.data); 4 });
結果數據還有另一個回調,因此可以異步運行
工作原理
- 現在大多LRU的工作過程始終存在從鍵到緩存槽的“映射”對象,就緩存槽的數量而言實現O(1)鍵搜索時間複雜度。但是用JavaScript就簡單多了:
映射對象:
1 let mapping = {};
在映射中找到一個(字符串/整數)鍵:
1 if(key in mapping) 2 { 3 // key found, get data from RAM 4 }
高效且簡單
- 只要映射對應一個緩存插槽,就可以直接從其中獲取數據:
1 buf[mapping[key]].visited=true; 2 buf[mapping[key]].time = Date.now(); 3 callback(buf[mapping[key]].data);
visited用來通知CLOCK指針(ctr和ctrEvict)保存該插槽,避免它被驅逐。time字段用來管理插槽的生命週期。只要訪問到高速緩存命中都會更新time字段,把它保留在高速緩存中。
用戶使用callback函數給get()函數提供用於檢索高速緩存插槽的數據。
- 想要直接從映射插槽獲取數據之前,需要先查看它的生命週期,如果生命週期已經結束,需要刪除映射並用相同鍵重試使高速緩存丟失:
1 if((Date.now() - buf[mapping[key]].time) > maxWait) 2 { 3 delete mapping[key]; 4 me.get(key,function(newData){ 5 callback(newData); 6 }); 7 }
刪除映射後其他異步訪問不會再影響其內部狀態
- 如果在映射對象中沒找到密鑰,就運行LRU逐出邏輯尋找目標:
1 let ctrFound = -1; 2 while(ctrFound===-1) 3 { 4 if(!buf[ctr].locked && buf[ctr].visited) 5 { 6 buf[ctr].visited=false; 7 } 8 ctr++; 9 if(ctr >= size) 10 { 11 ctr=0; 12 } 13 14 if(!buf[ctrEvict].locked && !buf[ctrEvict].visited) 15 { 16 // evict 17 buf[ctrEvict].locked = true; 18 ctrFound = ctrEvict; 19 } 20 21 ctrEvict++; 22 if(ctrEvict >= size) 23 { 24 ctrEvict=0; 25 } 26 }
第一個“ if”塊檢查“第二次機會”指針(ctr)指向的插槽狀態,如果是未鎖定並已訪問會將其標記爲未訪問,而不是驅逐它。
第三“If”塊檢查由ctrEvict指針指向的插槽狀態,如果是未鎖定且未被訪問,則將該插槽標記爲“ locked”,防止異步訪問get() 方法,並找到逐出插槽,然後循環結束。
對比可以發現ctr和ctrEvict的初始相位差爲50%:
1 let ctr = 0; 2 let ctrEvict = parseInt(cacheSize/2,10);
並且在“ while”循環中二者均等遞增。這意味着,這二者循環跟隨另一方,互相檢查。高速緩存插槽越多,對目標插槽搜索越有利。對每個鍵而言,每個鍵至少停留超過N / 2個時針運動才從從逐出中保存。
- 找到目標插槽後,刪除映射防止異步衝突的發生,並在加載數據存儲區後重新創建映射:
1 mappingInFlightMiss[key]=true; 2 let f = function(res){ 3 delete mapping[buf[ctrFound].key]; 4 buf[ctrFound] = {data: res, visited:false, key:key, time:Date.now(), locked:false}; 5 mapping[key] = ctrFound; 6 callback(buf[ctrFound].data); 7 delete mappingInFlightMiss[key]; 8 }; 9 10 loadData(key,f);
由於用戶提供的緩存缺失數據存儲加載功能(loadData)可以異步進行,所以該緩存在運行中最多可以包含N個緩存缺失,最多可以隱藏N個緩存未命中延遲。隱藏延遲是影響吞吐量高低的重要因素,這一點在web應用中尤爲明顯。一旦應用中出現了超過N個異步緩存未命中/訪問就會導致死鎖,因此具有100個插槽的緩存可以異步服務多達100個用戶,甚至可以將其限制爲比N更低的值(M),並在多次(K)遍中進行計算(其中M x K =總訪問次數)。
我們都知道高速緩存命中就是RAM的速度,但因爲高速緩存未命中可以隱藏,所以對於命中和未命中而言,總體性能看起來的時間複雜度都是O(1)。當插槽很少時,每個訪問可能有多個時鐘指針迭代,但如果增加插槽數時,它接近O(1)。
在此loadData回調中,將新插槽數據的locked字段設置爲false,可以使該插槽用於其他異步訪問。
- 如果存在命中,並且找到的插槽生命週期結束且已鎖定,則訪問操作setTimeout將0 time參數延遲到JavaScript消息隊列的末尾。鎖定操作(cache-miss)在setTimeout之前結束的概率爲100%,就時間複雜度而言,仍算作具有較大的延遲的O(1),但它隱藏在鎖定操作延遲的延遲的之後。
1 if(buf[mapping[key]].locked) 2 { 3 setTimeout(function(){ 4 me.get(key,function(newData){ 5 callback(newData); 6 }); 7 },0); 8 }
- 最後,如果某個鍵處於進行中的高速緩存未命中映射中,則通過setTimeout將其推遲到消息隊列的末尾:
1 if(key in mappingInFlightMiss) 2 { 3 4 setTimeout(function(){ 5 me.get(key,function(newData){ 6 callback(newData); 7 }); 8 },0); 9 return; 10 }
這樣,就可以避免數據的重複。
標杆管理
- 異步高速緩存未命中基準
1 "use strict"; 2 // number of asynchronous accessors(1000 here) need to be equal to or less than 3 // cache size(1000 here) or it makes dead-lock 4 let Lru = require("./lrucache.js").Lru; 5 6 let cache = new Lru(1000, async function(key,callback){ 7 // cache-miss data-load algorithm 8 setTimeout(function(){ 9 callback(key+" processed"); 10 },1000); 11 },1000 /* cache element lifetime */); 12 13 let ctr = 0; 14 let t1 = Date.now(); 15 for(let i=0;i<1000;i++) 16 { 17 cache.get(i,function(data){ 18 console.log("data:"+data+" key:"+i); 19 if(i.toString()+" processed" !== data) 20 { 21 console.log("error: wrong key-data mapping."); 22 } 23 if(++ctr === 1000) 24 { 25 console.log("benchmark: "+(Date.now()-t1)+" miliseconds"); 26 } 27 }); 28 }
爲了避免死鎖的出現,可以將LRU大小選擇爲1000,或者for只允許循環迭代1000次。
輸出:
1 benchmark: 1127 miliseconds
由於每個高速緩存未命中都有1000毫秒的延遲,因此同步加載1000個元素將花費15分鐘,但是重疊的高速緩存未命中會更快。這在I / O繁重的工作負載(例如來自HDD或網絡的流數據)中特別有用。
- 緩存命中率基準
10%的命中率:
密鑰生成:隨機,可能有10000個不同的值
1000個插槽
1 "use strict"; 2 // number of asynchronous accessors(1000 here) need to be equal to or less than 3 // cache size(1000 here) or it makes dead-lock 4 let Lru = require("./lrucache.js").Lru; 5 6 let cacheMiss = 0; 7 let cache = new Lru(1000, async function(key,callback){ 8 cacheMiss++; 9 // cache-miss data-load algorithm 10 setTimeout(function(){ 11 callback(key+" processed"); 12 },100); 13 },100000000 /* cache element lifetime */); 14 15 let ctr = 0; 16 let t1 = Date.now(); 17 let asynchronity = 500; 18 let benchRepeat = 100; 19 let access = 0; 20 21 function test() 22 { 23 ctr = 0; 24 for(let i=0;i<asynchronity;i++) 25 { 26 let key = parseInt(Math.random()*10000,10); // 10% hit ratio 27 cache.get(key.toString(),function(data){ 28 access++; 29 if(key.toString()+" processed" !== data) 30 { 31 console.log("error: wrong key-data mapping."); 32 } 33 if(++ctr === asynchronity) 34 { 35 console.log("benchmark: "+(Date.now()-t1)+" miliseconds"); 36 console.log("cache hit: "+(access - cacheMiss)); 37 console.log("cache miss: "+(cacheMiss)); 38 console.log("cache hit ratio: "+((access - cacheMiss)/access)); 39 if(benchRepeat>0) 40 { 41 benchRepeat--; 42 test(); 43 } 44 } 45 }); 46 } 47 } 48 49 test();
結果:
1 benchmark: 10498 miliseconds 2 cache hit: 6151 3 cache miss: 44349 4 cache hit ratio: 0.1218019801980198
由於基準測試是按100個步驟進行的,每個緩存丟失的延遲時間爲100毫秒,因此產生了10秒的時間(接近100 x 100毫秒)。命中率接近預期值10%。
50%命中率測試
1 let key = parseInt(Math.random()*2000,10); // 50% hit ratio 2 3 Result: 4 5 benchmark: 10418 miliseconds 6 cache hit: 27541 7 cache miss: 22959 8 cache hit ratio: 0.5453663366336634
99%命中率測試
1 let key = parseInt(Math.random()*1010,10); // 99% hit ratio 2 3 Result: 4 5 benchmark: 10199 miliseconds 6 cache hit: 49156 7 cache miss: 1344 8 cache hit ratio: 0.9733861386138614
結果產生了0.9733比率的鍵的隨機性
100%命中率測試
1 let key = parseInt(Math.random()*999,10); // 100% hit ratio
基準測試的第一步(無法逃避緩存未命中)之後,所有內容都來自RAM,並大大減少了總延遲。
總結:
文本詳細介紹了NodeJS中LRU算法緩存的實現,希望可以爲大家提供新的思路,更好的在開發中提升系統性能。
拓展閱讀:
Vue 是一套用於構建用戶界面的漸進式框架,與其它 JS 框架不同,Vue 被設計爲可以自底向上逐層應用,由於其核心庫只關注視圖層,因此 Vue 更易上手,且很容易與第三方庫或既有項目整合,當其與現代化的工具鏈或各種支持類庫相結合時,Vue 也能爲複雜的單頁應用提供驅動。
SpreadJS 是一款基於 HTML5 的純前端表格控件,可以以原生的方式嵌入各類應用,並與前後端技術框架相結合。將 SpreadJS 與 Vue 集成,可在 Vue 框架中實現類似 Excel 的電子表格功能,包括對 450 多種計算公式的支持、在線導入導出 Excel 文檔、數據透視表和可視化分析,使應用程序具備極高的處理性能和響應速度。