本文從源碼層面分析了 redis 的緩存淘汰機制,並在文章末尾描述使用 Java 實現的思路,以供參考。java
爲了適配用做緩存的場景,redis 支持緩存淘汰(eviction)並提供相應的了配置項:node
設置內存使用上限,該值不能設置爲小於 1M 的容量。
選項的默認值爲 0,此時系統會自行計算一個內存上限。git
熟悉 redis 的朋友都知道,每一個數據庫維護了兩個字典:github
db.dict
:數據庫中全部鍵值對,也被稱做數據庫的 keyspacedb.expires
:帶有生命週期的 key 及其對應的 TTL(存留時間),所以也被稱做 expire set 當達到內存使用上限maxmemory
時,可指定的清理緩存所使用的策略有:redis
noeviction
當達到最大內存時直接返回錯誤,不覆蓋或逐出任何數據allkeys-lfu
淘汰整個 keyspace 中最不經常使用的 (LFU) 鍵 (4.0 或更高版本)allkeys-lru
淘汰整個 keyspace 最近最少使用的 (LRU) 鍵allkeys-random
淘汰整個 keyspace 中的隨機鍵volatile-ttl
淘汰 expire set 中 TTL 最短的鍵volatile-lfu
淘汰 expire set 中最不經常使用的鍵 (4.0 或更高版本)volatile-lru
淘汰 expire set 中最近最少使用的 (LRU) 鍵volatile-random
淘汰 expire set 中的隨機鍵 當 expire set
爲空時,volatile-*
與 noeviction
行爲一致。算法
爲了保證性能,redis 中使用的 LRU 與 LFU 算法是一類近似實現。
簡單來講就是:算法選擇被淘汰記錄時,不會遍歷全部記錄,而是以 隨機採樣 的方式選取部分記錄進行淘汰。
maxmemory-samples
選項控制該過程的採樣數量,增大該值會增長 CPU 開銷,但算法效果能更逼近實際的 LRU 與 LFU 。數據庫
清理緩存就是爲了釋放內存,但這一過程會阻塞主線程,影響其餘命令的執行。
當刪除某個巨型記錄(好比:包含數百條記錄的 list)時,會引發性能問題,甚至致使系統假死。
延遲釋放 機制會將巨型記錄的內存釋放,交由其餘線程異步處理,從而提升系統的性能。
開啓該選項後,可能出現使用內存超過 maxmemory
上限的狀況。數組
一個完整的緩存淘汰機制須要解決兩個問題:緩存
緩存能使用的內存是有限的,當空間不足時,應該優先淘汰那些未來再也不被訪問的數據,保留那些未來還會頻繁訪問的數據。所以淘汰算法會圍繞 時間局部性 原理進行設計,即:若是一個數據正在被訪問,那麼在近期極可能會被再次訪問。安全
爲了適應緩存讀多寫少的特色,實際應用中會使用哈希表來實現緩存。當須要實現某種特定的緩存淘汰策略時,須要引入額外的簿記 book keeping
結構。
下面回顧 3 種最多見的緩存淘汰策略。
越早進入緩存的數據,其再也不被訪問的可能性越大。
所以在淘汰緩存時,應選擇在內存中停留時間最長的緩存記錄。
使用隊列便可實現該策略:
優勢:實現簡單,適合線性訪問的場景
缺點:沒法適應特定的訪問熱點,緩存的命中率差
簿記開銷:時間 O(1)
,空間 O(N)
一個緩存被訪問後,近期再被訪問的可能性很大。
能夠記錄每一個緩存記錄的最近訪問時間,最近未被訪問時間最長的數據會被首先淘汰。
使用鏈表便可實現該策略:
當更新 LRU 信息時,只需調整指針:
優勢:實現簡單,能適應訪問熱點
缺點:對偶發的訪問敏感,影響命中率
簿記開銷:時間 O(1)
,空間 O(N)
原始的 LRU 算法緩存的是最近訪問了 1 次的數據,所以不能很好地區分頻繁和不頻繁緩存引用。
這意味着,部分冷門的低頻數據也可能進入到緩存,並將本來的熱點記錄擠出緩存。
爲了減小偶發訪問對緩存的影響,後續提出的 LRU-K 算法做出了以下改進:
K 值越大,緩存命中率越高,但適應性差,須要通過大量訪問才能將過時的熱點記錄淘汰掉。
綜合各類因素後,實踐中經常使用的是 LRU-2 算法:
優勢:減小偶發訪問對緩存命中率的影響
缺點:須要額外的簿記開銷
簿記開銷:時間 O(1)
,空間 O(N+M)
一個緩存近期內訪問頻率越高,其再被訪問的可能性越大。
能夠記錄每一個緩存記錄的最近一段時間的訪問頻率,訪問頻率低的數據會被首先淘汰。
實現 LFU 的一個簡單方式,是在緩存記錄設置一個記錄訪問次數的計數器,而後將其放入一個小頂堆:
爲了保證數據的時效性,還要以必定的時間間隔對計數器進行衰減,保證過時的熱點數據可以被及時淘汰:
常見刪除策略能夠分爲如下幾種:
實時刪除:
每次增長新的記錄時,當即查找可淘汰的記錄,若是存在則將該記錄從緩存中刪除
惰性刪除:
在緩存中設置兩個計數器,一個統計訪問緩存的次數,一個統計可淘汰記錄的數量
每通過 N 次訪問後或當前可淘汰記錄數量大於 M,則觸發一次批量刪除(M 與 N 可調節)
異步刪除:
設置一個獨立的定時器線程,每隔固定的時間觸發一次批量刪除
redis 中實現了 LRU 與 LFU 兩種淘汰策略
爲了節省空間,redis 沒有使用前面描述的簿記結構實現 LRU 或 LFU,而是在 robj
中使用一個 24bits 的空間記錄訪問信息:
#define LRU_BITS 24 typedef struct redisObject { ... unsigned lru:LRU_BITS; /* LRU 時間 (相對與全局 lru_clock 的時間) 或 * LFU 數據 (8bits 記錄訪問頻率,16 bits 記錄訪問時間). */ } robj;
每當記錄被命中時,redis 都會更新 robj.lru
做爲後面淘汰算法運行的依據:
robj *lookupKey(redisDb *db, robj *key, int flags) { // ... // 根據 maxmemory_policy 選擇不一樣的更新策略 if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { updateLFU(val); } else { val->lru = LRU_CLOCK(); } }
LFU 與 LRU 的更新關鍵在於 updateLFU
函數與 LRU_CLOCK
宏,下面分別進行分析。
當時使用 LRU 算法時,robj.lru
記錄的是最近一次訪問的時間戳,能夠據此找出長時間未被訪問的記錄。
爲了減小系統調用,redis 設置了一個全局的時鐘 server.lruclock
並交由後臺任務進行更新:
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */ #define LRU_CLOCK_RESOLUTION 1000 /* 以毫秒爲單位的時鐘精度 */ /** * server.lruclock 的更新頻率爲 1000/server.hz * 若是該頻率高於 LRU 時鐘精度,則直接用 server.lruclock * 避免調用 getLRUClock() 產生額外的開銷 */ #define LRU_CLOCK() ((1000/server.hz <= LRU_CLOCK_RESOLUTION) ? server.lruclock : getLRUClock()) unsigned int getLRUClock(void) { return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX; }
計算 LRU 時間方法以下:
unsigned long long estimateObjectIdleTime(robj *o) { unsigned long long lruclock = LRU_CLOCK(); if (lruclock >= o->lru) { return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION; } else { // 處理 LRU 時間溢出的狀況 return (lruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION; } }
當LRU_CLOCK_RESOLUTION
爲 1000ms 時,robj.lru
最長可記錄的 LRU 時長爲 194 天0xFFFFFF / 3600 / 24
。
當時使用 LFU 算法時,robj.lru
被分爲兩部分:16bits 記錄最近一次訪問時間,8bits 用做計數器
void updateLFU(robj *val) { unsigned long counter = LFUDecrAndReturn(val); // 衰減計數 counter = LFULogIncr(counter); // 增長計數 val->lru = (LFUGetTimeInMinutes()<<8) | counter; // 更新時間 }
前 16bits 用於保存最近一次被訪問的時間:
/** * 獲取 UNIX 分鐘時間戳,且只保留最低 16bits * 用於表示最近一次衰減時間 LDT (last decrement time) */ unsigned long LFUGetTimeInMinutes(void) { return (server.unixtime/60) & 65535; }
後 8bits 是一個對數計數器 logarithmic counter
,裏面保存的是訪問次數的對數:
#define LFU_INIT_VAL 5 // 對數遞增計數器,最大值爲 255 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; }
當 server.lfu_log_factor = 10
時,p = 1/((counter-LFU_INIT_VAL)*server.lfu_log_factor+1)
的增加函數如圖所示:
使用函數 rand()
生成的介於 0 與 1 之間隨機浮點數 r
符合均勻分佈,隨着 counter
的增大,其自增成功的機率迅速下降。
下列表格展現了 counter
在不一樣 lfu_log_factor
狀況下,達到飽和(255)所需的訪問次數:
+--------+------------+------------+------------+------------+------------+ | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits | +--------+------------+------------+------------+------------+------------+ | 0 | 104 | 255 | 255 | 255 | 255 | +--------+------------+------------+------------+------------+------------+ | 1 | 18 | 49 | 255 | 255 | 255 | +--------+------------+------------+------------+------------+------------+ | 10 | 10 | 18 | 142 | 255 | 255 | +--------+------------+------------+------------+------------+------------+ | 100 | 8 | 11 | 49 | 143 | 255 | +--------+------------+------------+------------+------------+------------+
一樣的,爲了保證過時的熱點數據可以被及時淘汰,redis 使用以下衰減函數:
// 計算距離上一次衰減的時間 ,單位爲分鐘 unsigned long LFUTimeElapsed(unsigned long ldt) { unsigned long now = LFUGetTimeInMinutes(); if (now >= ldt) return now-ldt; return 65535-ldt+now; } /** * 衰減函數,返回根據 LDT 時間戳衰減後的 LFU 計數 * 不更新計數器 */ unsigned long LFUDecrAndReturn(robj *o) { unsigned long ldt = o->lru >> 8; unsigned long counter = o->lru & 255; /** * 衰減因子 server.lfu_decay_time 用於控制計數器的衰減速度 * 每過 server.lfu_decay_time 分鐘訪問計數減 1 * 默認值爲 1 */ unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0; if (num_periods) counter = (num_periods > counter) ? 0 : counter - num_periods; return counter; }
16bits 最多能保存的分鐘數,換算整天數約爲 45 天,所以 LDT 時間戳每隔 45 天就會重置一次。
每當客戶端執行命令產生新數據時,redis 會檢查內存使用是否超過 maxmemory
,若是超過則嘗試根據 maxmemory_policy
淘汰數據:
// redis 處理命令的主方法,在真正執行命令前,會有各類檢查,包括對OOM狀況下的處理: int processCommand(client *c) { // ... // 設置了 maxmemory 時,若是有必要,嘗試釋放內存(evict) if (server.maxmemory && !server.lua_timedout) { int out_of_memory = (performEvictions() == EVICT_FAIL); // ... // 若是釋放內存失敗,而且當前將要執行的命令不容許OOM(通常是寫入類命令) if (out_of_memory && reject_cmd_on_oom) { rejectCommand(c, shared.oomerr); // 向客戶端返回OOM return C_OK; } } }
實際執行刪除的是 performEvictions
函數:
int performEvictions(void) { // 循環,嘗試釋放足夠大的內存 while (mem_freed < (long long)mem_tofree) { // ... if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) || server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) { /** * redis 使用的是近似 LRU / LFU 算法 * 在淘汰對象時不會遍歷全部記錄,而是對記錄進行採樣 * EvictionPoolLRU 被用於臨時存儲應該被優先淘汰的樣本數據 */ struct evictionPoolEntry *pool = EvictionPoolLRU; // 根據配置的 maxmemory-policy,拿到一個能夠釋放掉的bestkey while(bestkey == NULL) { unsigned long total_keys = 0, keys; // 遍歷全部的 db 實例 for (i = 0; i < server.dbnum; i++) { db = server.db+i; dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ? db->dict : db->expires; // 根據 policy 選擇採樣的集合(keyspace 或 expire set) if ((keys = dictSize(dict)) != 0) { // 採樣並填充 pool evictionPoolPopulate(i, dict, db->dict, pool); total_keys += keys; } } // 遍歷 pool 中的記錄,釋放內存 for (k = EVPOOL_SIZE-1; k >= 0; k--) { if (pool[k].key == NULL) continue; bestdbid = pool[k].dbid; if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) { de = dictFind(server.db[pool[k].dbid].dict, pool[k].key); } else { de = dictFind(server.db[pool[k].dbid].expires, pool[k].key); } // 將記錄從 pool 中剔除 if (pool[k].key != pool[k].cached) sdsfree(pool[k].key); pool[k].key = NULL; pool[k].idle = 0; if (de) { // 提取該記錄的 key bestkey = dictGetKey(de); break; } else { /* Ghost... Iterate again. */ } } } } // 最終選中了一個 bestkey if (bestkey) { // 若是配置了 lazyfree-lazy-eviction,嘗試異步刪除 if (server.lazyfree_lazy_eviction) dbAsyncDelete(db,keyobj); else dbSyncDelete(db,keyobj); // ... } else { goto cant_free; /* nothing to free... */ } } }
負責採樣的 evictionPoolPopulate
函數:
#define EVPOOL_SIZE 16 #define EVPOOL_CACHED_SDS_SIZE 255 struct evictionPoolEntry { unsigned long long idle; /* LRU 空閒時間 / LFU 頻率倒數(優先淘汰該值較大的記錄) */ sds key; /* 參與淘汰篩選的鍵 */ sds cached; /* 鍵名緩存 */ int dbid; /* 數據庫ID */ }; // evictionPool 數組用於輔助 eviction 操做 static struct evictionPoolEntry *evictionPoolEntry; /** * 在給定的 sampledict 集合中進行採樣 * 並將其中應該被淘汰的記錄記錄至 evictionPool */ void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) { int j, k, count; dictEntry *samples[server.maxmemory_samples]; // 從 sampledict 中隨機獲取 maxmemory_samples 個樣本數據 count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples); // 遍歷樣本數據 for (j = 0; j < count; j++) { // 根據 maxmemory_policy 計算樣本空閒時間 idle if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) { idle = estimateObjectIdleTime(o); } else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { idle = 255-LFUDecrAndReturn(o); } else { // ... } k = 0; // 根據 idle 定位樣本在 evictionPool 中的索引(樣本按照 idle 升序) while (k < EVPOOL_SIZE && pool[k].key && pool[k].idle < idle) k++; if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) { // 樣本空閒時間不夠長,不參與該輪 eviction continue; } else if (k < EVPOOL_SIZE && pool[k].key == NULL) { // 樣本對應的位置爲空,能夠直接插入至該位置 } else { // 樣本對應的位置已被佔用,移動其餘元素空出該位置 } // ... // 將樣本數據插入其對應的位置 k int klen = sdslen(key); if (klen > EVPOOL_CACHED_SDS_SIZE) { pool[k].key = sdsdup(key); } else { // 若是 key 長度不超過 EVPOOL_CACHED_SDS_SIZE,則複用 sds 對象 } pool[k].idle = idle; pool[k].dbid = dbid; } }
在瞭解以上知識後,嘗試使用 Java 實現 線程安全 的淘汰策略。
在一個多線程安全的緩存中,很重要的一點是減小簿記:
所以參考 redis 使用計數器來記錄訪問模式:
/** * 緩存記錄 */ public abstract class CacheEntry { // CAS Updater private static final AtomicLongFieldUpdater<CacheEntry> TTL_UPDATER = AtomicLongFieldUpdater.newUpdater(CacheEntry.class, "ttl"); // 緩存記錄的剩餘存活時間(無符號長整數) private volatile long ttl; protected CacheEntry(long ttl) { this.ttl = ttl; } public long ttl() { return ttl; } // 支持併發更新 TTL public boolean casTTL(long old, long ttl) { return TTL_UPDATER.compareAndSet(this, old, ttl); } }
/** * 淘汰策略 */ public interface EvictStrategy { // 更新緩存記錄的 TTL void updateTTL(CacheEntry node); // 根據當前時間戳,計算緩存記錄的 TTL long weightTTL(CacheEntry node, long now); }
受限於簿記結構,redis 只能經過採樣來規避大量的遍歷,減小 實時刪除 策略對主線程的阻塞。
而在對於內存限制沒那麼嚴謹的狀況下,可使用 懶惰刪除 策略,減小單次請求的開銷:
public abstract class EvictableCache { EvictStrategy evicting; // 淘汰策略 /** * 在讀寫緩存記錄時,更新該記錄的 TTL * @param entry 最近被訪問的緩存記錄 */ void accessEntry(CacheEntry entry) { evicting.updateTTL(entry); } /** * 批量淘汰緩存 * @param evictSamples 緩存樣本 * @param evictNum 最大淘汰數量 * @return 應該被淘汰的記錄 */ Collection<CacheEntry> evictEntries(Iterable<CacheEntry> evictSamples, int evictNum) { // 比較兩個 CacheEntry 的 TTL(優先淘汰 TTL 較小的記錄) Comparator<CacheEntry> comparator = new Comparator<CacheEntry>() { final long now = System.currentTimeMillis(); public int compare(CacheEntry o1, CacheEntry o2) { long w1 = evicting.weightTTL(o1, now); long w2 = evicting.weightTTL(o2, now); return -Long.compareUnsigned(w1, w2); } }; // 使用大頂堆記錄 TTL 最小的 K 個 CacheEntry PriorityQueue<CacheEntry> evictPool = new PriorityQueue<>(evictNum, comparator); Iterator<CacheEntry> iterator = evictSamples.iterator(); while (iterator.hasNext()) { CacheEntry entry = iterator.next(); if (evictPool.size() < evictNum) { evictPool.add(entry); } else { // 若是 CacheEntry 的 TTL 小於堆頂記錄 // 則彈出堆頂記錄,並將 TTL 更小的記錄放入堆中 CacheEntry top = evictPool.peek(); if (comparator.compare(entry, top) < 1) { evictPool.poll(); evictPool.add(entry); } } } return evictPool; } }
/** * FIFO 策略 */ public class FirstInFirstOut implements EvictStrategy { // 計數器,每發生一次訪問操做自增 1 private final AtomicLong counter = new AtomicLong(0); // 第一次訪問時才更新 TTL public void updateTTL(CacheEntry node) { node.casTTL(0, counter.incrementAndGet()); } // 返回第一次被訪問的序號 public long weightTTL(CacheEntry node, long now) { return node.ttl(); } }
/** * LRU-2 策略 */ public class LeastRecentlyUsed implements EvictStrategy { // 邏輯時鐘,每發生一次訪問操做自增 1 private final AtomicLong clock = new AtomicLong(0); /** * 更新 LRU 時間 */ public void updateTTL(CacheEntry node) { long old = node.ttl(); long tick = clock.incrementAndGet(); long flag = old == 0 ? Long.MIN_VALUE: 0; // flag = Long.MIN_VALUE 表示放入 History Queue // flag = 0 表示放入 LRU Cache long ttl = (tick & Long.MAX_VALUE) | flag; while ((old & Long.MAX_VALUE) < tick && ! node.casTTL(old, ttl)) { old = node.ttl(); ttl = tick & Long.MAX_VALUE; // CAS 失敗說明已是二次訪問 } } /** * 根據 LRU 時間計算 TTL */ public long weightTTL(CacheEntry node, long now) { long ttl = node.ttl(); return -1L - ttl; } }
/** * LFU-AgeDecay 策略 */ public class LeastFrequentlyUsed implements EvictStrategy { private static final int TIMESTAMP_BITS = 40; // 40bits 記錄訪問時間戳(保證 34 年不溢出) private static final int FREQUENCY_BITS = 24; // 24bits 做爲對數計數器(能夠忽略計數溢出的狀況) private final long ERA = System.currentTimeMillis(); // 起始時間(記錄相對於該值的時間戳) private final double LOG_FACTOR = 1; // 對數因子 private final TimeUnit DECAY_UNIT = TimeUnit.MINUTES; // 時間衰減單位 /** * 更新 LFU 計數器與訪問時間 * 與 redis 不一樣,更新時不會對計數進行衰減 */ public void updateTTL(CacheEntry node) { final long now = System.currentTimeMillis(); long old = node.ttl(); long timestamp = old >>> FREQUENCY_BITS; long frequency = old & (~0L >>> TIMESTAMP_BITS); // 計算訪問時間 long elapsed = Math.min(~0L >>> FREQUENCY_BITS, now - ERA); while (timestamp < elapsed) { // 增長訪問計數 double rand = ThreadLocalRandom.current().nextDouble(); if (1./(frequency * LOG_FACTOR + 1) > rand) { frequency++; frequency &= (~0L >>> TIMESTAMP_BITS); } // 更新 TTL long ttl = elapsed << FREQUENCY_BITS | frequency & (~0L >>> TIMESTAMP_BITS); if (node.casTTL(old, ttl)) { break; } old = node.ttl(); timestamp = old >>> FREQUENCY_BITS; frequency = old & (~0L >>> TIMESTAMP_BITS); } } /** * 返回衰減後的 LFU 計數 */ public long weightTTL(CacheEntry node, long now) { long ttl = node.ttl(); long timestamp = ttl >>> FREQUENCY_BITS; long frequency = ttl & (~0L >>> TIMESTAMP_BITS); long decay = DECAY_UNIT.toMinutes(Math.max(now - ERA, timestamp) - timestamp); return frequency - decay; } }
至此,對 redis 的淘汰策略分析完畢,後續將對 redis 的一些其餘細節進行分享,感謝觀看。