前文再續,書接上一回。上次講到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)。