Redis 過期淘汰策略
redis的過期淘汰策略是非常值得去深入了解以及考究的一個問題。很多使用者往往不能深得其意,往往停留在人云亦云的程度,若生產不出事故便劃水就劃過去了,但是當生產數據莫名其妙的消失,或者reids服務崩潰的時候,卻又束手無策。本文嘗試著從淺入深的將redis的過期策略剖析開來,期望幫助作者以及讀者站在一個更加系統(tǒng)化的角度去看待過期策略。
redis作為緩存數據庫,其底層數據結構主要由dict和expires兩個字典構成,其中dict字典負責保存鍵值對,而expires字典則負責保存鍵的過期時間。訪問磁盤空間的成本是訪問緩存的成本高出非常多,所以內存的成本比磁盤空間要大。在實際使用中,緩存的空間往往極為有限,所以為了在為數不多的容量中做到真正的物盡其用,必須要對緩存的容量進行管控。
內存策略
redis通過配置
maxmemory
來配置最大容量(閾值),當數據占有空間超過所設定值就會觸發(fā)內部的內存淘汰策略(內存釋放)。那么究竟要淘汰哪些數據,才是最符合業(yè)務需求?或者在業(yè)務容忍的范圍內呢?為了解決這個問題,redis提供了可配置的淘汰策略,讓使用者可以配置適合自己業(yè)務場景的淘汰策略,不配置的情況下默認是使用volatile-lru
。
noeviction:當內存不足以容納新寫入數據時,新寫入操作會報錯。
allkeys-lru:當內存不足以容納新寫入數據時,在鍵空間中,移除最近最少使用的key。
allkeys-random:當內存不足以容納新寫入數據時,在鍵空間中,隨機移除某個key。
volatile-lru:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,移除最近最少使用的key。
volatile-random:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,隨機移除某個key。
volatile-ttl:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,有更早過期時間的key優(yōu)先移除。
如果redis保存的key-value對數量不多(比如數十對),那么當內存超過閾值后,對整個內存空間的所有key進行檢查,也無傷大雅。然而,在實際使用中redis保存的key-value數量遠遠不止于此,如果使用內存超過閾值就逐個去檢查是否符合過期策略嗎?隨意遍歷十萬個key?顯然不是,否則,redis又何以高性能著稱?但是,檢查多少key這個問題確實存在。為了解決該問題,redis的設計者們引入一個配置項maxmemory-samples
,稱之為過期檢測樣本,默認值是3,通過它來曲線救國。
過期檢測樣本是如何配合redis來進行數據清理呢?
當mem_used
內存已經超過maxmemory
的設定,對于所有的讀寫請求,都會觸發(fā)redis.c/freeMemoryIfNeeded
函數以清理超出的內存。注意這個清理過程是阻塞的
,直到清理出足夠的內存空間。所以如果在達到maxmemory
并且調用方還在不斷寫入的情況下,可能會反復觸發(fā)主動清理策略,導致請求會有一定的延遲。
清理時會根據用戶配置的maxmemory
政策來做適當的清理(一般是LRU或TTL),這里的LRU或TTL策略并不是針對redis的的所有鍵,而是以配置文件中的maxmemory
樣本個鍵作為樣本池進行抽樣清理。
redis設計者將該值默認為3,如果增加該值,會提高LRU或TTL的精準度,redis的作者測試的結果是當這個配置為10時已經非常接近全量LRU的精準度了,而且增加
maxmemory
采樣會導致在主動清理時消耗更多的CPU時間,所以在設置該值必須慎重把控,在業(yè)務的需求以及性能之間做權衡。建議如下:
盡量不要觸發(fā)
maxmemory
,最好在mem_used
內存占用達到maxmemory
的一定比例后,需要考慮調大赫茲以加快淘汰,或者進行集群擴容。如果能夠控制住內存,則可以不用修改
maxmemory-samples
配置。如果redis本身就作為LRU緩存服務(這種服務一般長時間處于maxmemory
狀態(tài),由redis自動做LRU淘汰),可以適當調大maxmemory
樣本。
freeMemoryIfNeeded源碼解讀
int freeMemoryIfNeeded(void) {
size_t mem_used, mem_tofree, mem_freed;
int slaves = listLength(server.slaves);
// 計算占用內存大小時,并不計算slave output buffer和aof buffer,
// 因此maxmemory應該比實際內存小,為這兩個buffer留足空間。
mem_used = zmalloc_used_memory();
if (slaves) {
listIter li;
listNode *ln;
listRewind(server.slaves,&li);
while((ln = listNext(&li))) {
redisClient *slave = listNodeValue(ln);
unsigned long obuf_bytes = getClientOutputBufferMemoryUsage(slave);
if (obuf_bytes > mem_used)
mem_used = 0;
else
mem_used -= obuf_bytes;
}
}
if (server.appendonly) {
mem_used -= sdslen(server.aofbuf);
mem_used -= sdslen(server.bgrewritebuf);
}
// 判斷已經使用內存是否超過最大使用內存,如果沒有超過就返回REDIS_OK,
if (mem_used <= server.maxmemory) return REDIS_OK;
// 當超過了最大使用內存時,就要判斷此時redis到底采用何種內存釋放策略,根據不同的策略,采取不同的清除算法。
// 首先判斷是否是為no-enviction策略,如果是,則返回REDIS_ERR,然后redis就不再接受任何寫命令了。
if (server.maxmemory_policy == REDIS_MAXMEMORY_NO_EVICTION)
return REDIS_ERR;
// 計算需要清理內存大小
mem_tofree = mem_used - server.maxmemory;
mem_freed = 0;
while (mem_freed < mem_tofree) {
int j, k, keys_freed = 0;
for (j = 0; j < server.dbnum; j++) {
long bestval = 0;
sds bestkey = NULL;
struct dictEntry *de;
redisDb *db = server.db+j;
dict *dict;
// 1、從哪個字典中剔除數據
// 判斷淘汰策略是基于所有的鍵還是只是基于設置了過期時間的鍵,
// 如果是針對所有的鍵,就從server.db[j].dict中取數據,
// 如果是針對設置了過期時間的鍵,就從server.db[j].expires(記錄過期時間)中取數據。
if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM)
{
dict = server.db[j].dict;
} else {
dict = server.db[j].expires;
}
if (dictSize(dict) == 0) continue;
// 2、從是否為隨機策略
// 是不是random策略,包括volatile-random 和allkeys-random,這兩種策略是最簡單的,就是在上面的數據集中隨便去一個鍵,然后刪掉。
if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_RANDOM)
{
de = dictGetRandomKey(dict);// 從方法名猜出是隨機獲取一個dictEntry
bestkey = dictGetEntryKey(de);// 得到刪除的key
}
// 3、判斷是否為lru算法
// 是lru策略還是ttl策略,如果是lru策略就采用lru近似算法
// 為了減少運算量,redis的lru算法和expire淘汰算法一樣,都是非最優(yōu)解,
// lru算法是在相應的dict中,選擇maxmemory_samples(默認設置是3)份key,挑選其中l(wèi)ru的,進行淘汰
else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
{
for (k = 0; k < server.maxmemory_samples; k++) {
sds thiskey;
long thisval;
robj *o;
de = dictGetRandomKey(dict);
thiskey = dictGetEntryKey(de);
/* When policy is volatile-lru we need an additonal lookup
* to locate the real key, as dict is set to db->expires. */
if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
de = dictFind(db->dict, thiskey); //因為dict->expires維護的數據結構里并沒有記錄該key的最后訪問時間
o = dictGetEntryVal(de);
thisval = estimateObjectIdleTime(o);
/* Higher idle time is better candidate for deletion */
// 找到那個最合適刪除的key
// 類似排序,循環(huán)后找到最近最少使用,將其刪除
if (bestkey == NULL || thisval > bestval) {
bestkey = thiskey;
bestval = thisval;
}
}
}
// 如果是ttl策略。
// 取maxmemory_samples個鍵,比較過期時間,
// 從這些鍵中找到最快過期的那個鍵,并將其刪除
else if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_TTL) {
for (k = 0; k < server.maxmemory_samples; k++) {
sds thiskey;
long thisval;
de = dictGetRandomKey(dict);
thiskey = dictGetEntryKey(de);
thisval = (long) dictGetEntryVal(de);
/* Expire sooner (minor expire unix timestamp) is better candidate for deletion */
if (bestkey == NULL || thisval < bestval) {
bestkey = thiskey;
bestval = thisval;
}
}
}
// 根據不同策略挑選了即將刪除的key之后,進行刪除
if (bestkey) {
long long delta;
robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
// 發(fā)布數據更新消息,主要是AOF 持久化和從機
propagateExpire(db,keyobj); //將del命令擴散給slaves
// 注意, propagateExpire() 可能會導致內存的分配,
// propagateExpire() 提前執(zhí)行就是因為redis 只計算
// dbDelete() 釋放的內存大小。倘若同時計算dbDelete()
// 釋放的內存和propagateExpire() 分配空間的大小,與此
// 同時假設分配空間大于釋放空間,就有可能永遠退不出這個循環(huán)。
// 下面的代碼會同時計算dbDelete() 釋放的內存和propagateExpire() 分配空間的大小
/* We compute the amount of memory freed by dbDelete() alone.
* It is possible that actually the memory needed to propagate
* the DEL in AOF and replication link is greater than the one
* we are freeing removing the key, but we can't account for
* that otherwise we would never exit the loop.
*
* AOF and Output buffer memory will be freed eventually so
* we only care about memory used by the key space. */
// 只計算dbDelete() 釋放內存的大小
delta = (long long) zmalloc_used_memory();
dbDelete(db,keyobj);
delta -= (long long) zmalloc_used_memory();
mem_freed += delta;
server.stat_evictedkeys++;
decrRefCount(keyobj);
keys_freed++;
/* When the memory to free starts to be big enough, we may
* start spending so much time here that is impossible to
* deliver data to the slaves fast enough, so we force the
* transmission here inside the loop. */
// 將從機回復空間中的數據及時發(fā)送給從機
if (slaves) flushSlavesOutputBuffers();
}
}//在所有的db中遍歷一遍,然后判斷刪除的key釋放的空間是否足夠,未能釋放空間,且此時redis 使用的內存大小依舊超額,失敗返回
if (!keys_freed) return REDIS_ERR; /* nothing to free... */
}
return REDIS_OK;
}
從源碼分析中可以看到redis在使用內存中超過設定的閾值時是如何將清理key-value進行內管管理,其中涉及到redis的存儲結構。開篇就說到redis底層數據結構是由dict以及expires兩個字典構成,通過一張圖可以非常清晰了解到redis中帶過期時間的key-value的存儲結構,可以更加深刻認識到redis的內存管理機制。
從redis的內存管理機制中我們可以看到,當使用的內存超過設定的閾值,就會觸發(fā)內存清理。那么一定要等到內存超過閾值才進行內存清理嗎?非要亡羊補牢?redis的設計者顯然是考慮到了這個問題,當redis在使用過程中,自行去刪除一些過期key,盡量保證不要觸發(fā)超過內存閾值而發(fā)生的清理事件。
有效時間
expire/pexpire key time(以秒/毫秒為單位)--這是最常用的方式(Time To Live 稱為TTL)
setex(String key, int seconds, String value)--字符串獨有的方式
在使用過期時間時,必要注意如下三點:
除了字符串自己獨有設置過期時間的方法外,其他方法都需要依靠expire方法來設置時間
如果沒有設置時間,那緩存就是永不過期
如果設置了過期時間,之后又想讓緩存永不過期,使用persist key
過期鍵自動刪除策略
1、定時刪除(主動刪除策略):通過使用定時器(時間事件,采用無序鏈表實現,),定時刪除數據。定時刪除策略可以保證過期的鍵會盡可能快的被刪除了,并釋放過期鍵鎖占用的內存。
好處:對內存是最友好的。
壞處:它對CPU時間不友好,在過期鍵比較多的情況下,刪除過期鍵這一行為會占用相當一部分CPU時間,在內存不緊張但是CPU非常緊張的情況下,將CPU應用于刪除和當前任務無關的過期鍵上,無疑會對服務器的響應時間和吞吐量造成影響。
2、惰性刪除(被動刪除策略):程序在每次使用到鍵的時候去檢查是否過期,如果過期則刪除并返回空。
好處:對CPU時間友好,永遠只在操作與當前任務有關的鍵。
壞處:可能會在內存中遺留大量的過期鍵而不刪除,造成內存泄漏。
3、定期刪除(主動刪除):定期每隔一段時間執(zhí)行一段刪除過期鍵操作,通過限制刪除操作的執(zhí)行時長與頻率來減少刪除操作對CPU時間的影響。除此之外,定期執(zhí)行也可以減少過期鍵長期駐留內存的影響,減少內存泄漏的可能。
好處:可以控制過期刪除的執(zhí)行頻率
壞處:服務器必須合理設置過期鍵刪除的操作時間以及執(zhí)行的頻率。
redis的過期鍵刪除策略
redis服務器實際上使用的惰性刪除和定期刪除兩種策略:通過配合使用兩種刪除策略,服務器可以很好地合理使用CPU時間和避免浪費內存空間之間取得平衡。
惰性刪除策略的實現
redis提供一個
expireIfNeeded
函數,所以讀寫數據庫的命令在執(zhí)行之前都必須調用expireIfNeeded函數。(鍵是否存在)
如果過期 --> 刪除
如果非過期 --> 執(zhí)行命令(expireIfNeeded函數不做動作)
定期刪除策略的實現
定期刪除有函數
activeExpireCycle
函數實現,每當redis服務器調用serverCorn
函數時執(zhí)行定期刪除函數。它會在規(guī)定時間
內,分多次遍歷服務器中的各個數據庫,并在數據庫的expire字典中隨機檢查
一部分鍵的過期時間,并刪除過期鍵。
遍歷數據庫(就是redis.conf中配置的"database"數量,默認為16)
檢查當前庫中的指定個數個key(默認是每個庫檢查20個key,注意相當于該循環(huán)執(zhí)行20次)
如果當前庫中沒有一個key設置了過期時間,直接執(zhí)行下一個庫的遍歷
隨機獲取一個設置了過期時間的key,檢查該key是否過期,如果過期,刪除key
判斷定期刪除操作是否已經達到指定時長,若已經達到,直接退出定期刪除。
參考資料:
《Redis設計與實現》
深入理解Redis數據淘汰策略:https://blog.csdn.net/wtyvhreal/article/details/46390065