1. 程式人生 > >redis的LRU演算法(二)

redis的LRU演算法(二)

前文再續,書接上一回。上次講到redis的LRU演算法,文章實在精妙,最近可能有機會用到其中的技巧,順便將下半部翻譯出來,實現的時候參考下。

搏擊俱樂部的第一法則:用裸眼觀測你的演算法

Redis2.8的LRU實現已經上線了,在不同的負載環境下經過測試,使用者沒有抱怨Redis的清理機制。為了繼續改進,我希望能觀察到演算法的效能,同時不會浪費大量CPU,不增加1位元空間佔用。

我設計了一個測試用例。匯入指定數量的key,然後順序訪問他們,好讓他們的最近訪問時間順序遞減。再新增50%的key,那麼之前的key有50%就會被淘汰掉。理想情況下,被淘汰的應該是前50%的。如下圖:

綠色的是新新增的key,灰色的是第一次新增的key,白色表示被移除的。

LRU v2:不要丟掉重要資訊

當採用了N-key取樣時,預設會建立16個key的池,將裡面的key按空閒時間排序。新key只會在池不滿或者空閒時間大於池裡最小的,才能進池。

這個實現極大的提升了效能,實現又簡單,沒有大bug。只要一點點效能監控和一些memmove()就完成了。

同時,新的redis-cli模式(-lru-test)支援測試LRU精度,可以更接近真實的負載來看新演算法的工況,嘗試不同演算法的時候,至少可以發現明顯的速度退化。

最近最少訪問(LFU)

我最近又部分重構了Redis cache的換頁演算法。這些工作源於一個issue:當你在Redis 3.2有多個數據庫的時候,演算法總是做區域性選擇。比如DB 0的所有key都用的很頻繁,DB 1的所有key都用的很少。Redis會從每個DB裡丟棄一個key。理性的選擇應該是先丟棄DB 1的key,丟完以後再丟DB 0的

Redis用作cache的時候,通常不會跟不同DB混合用,但我還是開始著手改進,最後將db的id包括在池裡,然後所有DB都共用一個池,這個實現比原始先快20%

這次改進激起了我對Redis這塊子系統的好奇心。我花了好些天進行優化,如果我用一個大點的池子,會好點嗎?如果選擇key的時候考慮了流逝的時間,效果會不會更好?

最後,我終於明白到,LRU演算法會受到取樣數量限制,只要數量足夠,效果就很好,很難再改進。正如上圖所示,每次取樣10個鍵,已經和理論上的LRU幾乎一樣準確了。

因為原始演算法難以改進,我開始想其他辦法。回顧前文,其實我們真正想要的,是保留未來最有可能訪問的key,即是最常訪問的key,而不是最新訪問的key。這就是LFU演算法。理論上LFU的實現很簡單,只要給每個key掛一個計數器,我們就可以知道給定的key是不是比另一個key訪問更多了。

當然,LFU的實現上有幾個通用的難點:

1. LFU裡沒法使用連結串列法轉移到頭部的技巧了。因為完美LFU需要key嚴格按訪問量排序。當訪問量一致時,排序演算法可能劣化為O(N),即使計數器只變了一點點

2. LFU沒法簡單的只在訪問時對計數器加一。因為訪問模式會隨著時間發生變化,所以一個高分的key需要隨著時間流逝而分數遞減。

在Redis裡第一個問題不是問題,我們可以沿用LRU的隨機取樣方法。第二個問題仍然存在,我們需要一個方法來遞減分數,或者隨著時間流逝將計數器折半。

24bit空間實現的LFU

在Redis裡,我們可以用的就是LRU的24bit空間,需要一些奇技淫巧來實現。

在24bit空間裡,需要塞下:

1. 某種型別的訪問計數器

2. 足夠的資訊來決定何時折半計數器

我的解決方案如下:

           16 bits      8 bits
      +----------------+--------+
      + Last decr time | LOG_C  |
      +----------------+--------+

8bit用來計數,16bit用來記錄上次遞減的時間

你可能會認為,8bit計數器很快就會溢位了吧?這就是技巧所在:我用的是對數計數器。具體程式碼如下:

  uint8_t LFULogIncr(uint8_t counter) {
      if (counter == 255) return 255;
      double r = (double)rand()/RAND_MAX;
      double baseval = counter - LFU_INIT_VAL;
      if (baseval < 0) baseval = 0;
      double p = 1.0/(baseval*server.lfu_log_factor+1);
      if (r < p) counter++;
      return counter;
  }

計數器的值越大,真正加一的概率越小。上述程式碼算出一個概率p,介乎0到1之間,計數器越大,p越小。然後生成0-1之間的隨機數r,只有r<p的時候,計數器才會加一。

現在我們來看看計數器折半的問題。轉成分鐘為單位的unix時間,低16位會存在上面保留的16位空間內。當Redis進行隨機取樣,掃描key空間的時候,所有遇到的key都會被檢查是否應該遞減。如果上次遞減實在N分鐘之前(N是可配置的),並且計數器的值是高分值,那計數器就會被折半。如果計數器是低分值,則只會遞減。(希望我們可以更好的分辨少訪問量的key,因為我們的計數器精度比較低)

還有一個問題,新的key需要一個生存的機會。Redis裡新key會從5分開始。上面的遞減演算法已經考慮到這個分數,如果key分數低於5分,更容易被丟棄(一般是長時間沒訪問的非活躍key)。