NodeJS中的LRU快取(CLOCK-2-hand)實現

葡萄城技術團隊發表於2021-04-30

轉載請註明出處:葡萄城官網,葡萄城為開發者提供專業的開發工具、解決方案和服務,賦能開發者。

原文參考:https://www.codeproject.com/Articles/5299328/LRU-Cache-CLOCK-2-hand-Implementation-In-NodeJS

在文章的開始我們需要了解什麼是快取?快取是預先根據資料列表準備一些重要資料。沒有快取的話,系統的吞吐量就取決於儲存速度最慢的資料,因此保持應用程式高效能的一個重要優化就是快取。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演算法快取的實現,希望可以為大家提供新的思路,更好的在開發中提升系統效能。

 

 

相關文章