摘要
本文來自:https://mp.weixin.qq.com/s/lHMprr7-OYkYTzCkzjec4w
Redis是一個基於內存的鍵值數據庫,其內存管理是非常重要的。本文內存管理的內容包括:過期鍵的懶性刪除和過期刪除以及內存溢出控制策略。
最大內存限制
Redis使用 maxmemory 參數限制最大可用內存,默認值為0,表示無限制。限制內存的目的主要有:
- 用於緩存場景,當超出內存上限 maxmemory 時使用 LRU 等刪除策略釋放空間。
- 防止所用內存超過服務器物理內存。因為 Redis 默認情況下是會盡可能多使用服務器的內存,可能會出現服務器內存不足,導致 Redis 進程被殺死。

maxmemory 限制的是Redis實際使用的內存量,也就是 used_memory 統計項對應的內存。由於內存碎片率的存在,實際消耗的內存可能會比maxmemory設置的更大,實際使用時要小心這部分內存溢出。具體Redis 內存監控的內容請查看一文了解 Redis 內存監控和內存消耗。
Redis默認不限制使用服務器內存,為防止極端情況下導致系統內存耗盡,建議所有的Redis進程都要配置maxmemory。在保證物理內存可用的情況下,系統中所有Redis實例可以調整maxmemory參數來達到自由伸縮內存的目的。
內存回收策略
Redis 回收內存大致有兩個機制:一是刪除到達過期時間的鍵值對象;二是當內存達到 maxmemory 時觸發內存移除控制策略,強制刪除選擇出來的鍵值對象。
刪除過期鍵對象
Redis 所有的鍵都可以設置過期屬性,內部保存在過期表(expires)中,鍵值表和過期表的結果如下圖所示。當 Redis保存大量的鍵,對每個鍵都進行精准的過期刪除可能會導致消耗大量的 CPU,會阻塞 Redis 的主線程,拖累 Redis 的性能,因此 Redis 采用惰性刪除和定時任務刪除機制實現過期鍵的內存回收。

惰性刪除是指當客戶端操作帶有ttl屬性的鍵時,會檢查是否超過鍵的過期時間,然后會同步或者異步執行刪除操作並返回鍵已經過期。這樣可以節省 CPU成本考慮,不需要單獨維護過期時間鏈表來處理過期鍵的刪除。
過期鍵的惰性刪除策略由 db.c/expireifNeeded 函數實現,所有對數據庫的讀寫命令執行之前都會調用 expireifNeeded 來檢查命令執行的鍵是否過期。如果鍵過期,會將過期鍵從鍵值表和過期表中刪除,然后同步或者異步釋放對應對象的空間。源碼展示的時 Redis 4.0 版本:
先從過期表中獲取鍵對應的過期時間,如果當前時間已經超過了過期時間(lua腳本執行則有特殊邏輯,詳看代碼注釋),則進入刪除鍵流程。刪除鍵流程主要進行了三件事:
- 一是刪除操作命令傳播,通知 slave 實例並存儲到 AOF 緩沖區中
- 二是記錄鍵空間事件,
- 三是根據 lazyfree_lazy_expire 是否開啟進行異步刪除操作。
int expireIfNeeded(redisDb *db, robj *key) { // 獲取鍵的過期時間 mstime_t when = getExpire(db,key); mstime_t now; // 鍵沒有過期時間 if (when < 0) return 0; // 實例正在從硬盤 laod 數據,比如說 RDB 或者 AOF if (server.loading) return 0; // 當執行lua腳本時,只有鍵在lua一開始執行時就到了過期時間才算過期,否則在lua執行過程中不算失效 now = server.lua_caller ? server.lua_time_start : mstime(); // 當本實例是slave時,過期鍵的刪除由master發送過來的 del 指令控制。但是這個函數還是將正確的信息返回給調用者。 if (server.masterhost != NULL) return now > when; // 判斷是否未過期 if (now <= when) return 0; // 代碼到這里,說明鍵已經過期,而且需要被刪除 server.stat_expiredkeys++; // 命令傳播,到 slave 和 AOF propagateExpire(db,key,server.lazyfree_lazy_expire); // 鍵空間通知使得客戶端可以通過訂閱頻道或模式, 來接收那些以某種方式改動了 Redis 數據集的事件。 notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired",key,db->id); // 如果是惰性刪除,調用dbAsyncDelete,否則調用 dbSyncDelete return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : dbSyncDelete(db,key); }

上圖是寫命令傳播的示意圖,刪除命令的傳播和它一致。propagateExpire 函數先調用 feedAppendOnlyFile 函數將命令同步到 AOF 的緩沖區中,然后調用 replicationFeedSlaves函數將命令同步到所有的 slave 中。Redis 復制的機制可以查看Redis 復制過程詳解。
// 將命令傳遞到slave和AOF緩沖區。maser刪除一個過期鍵時會發送Del命令到所有的slave和AOF緩沖區 void propagateExpire(redisDb *db, robj *key, int lazy) { robj *argv[2]; // 生成同步的數據 argv[0] = lazy ? shared.unlink : shared.del; argv[1] = key; incrRefCount(argv[0]); incrRefCount(argv[1]); // 如果開啟了 AOF 則追加到 AOF 緩沖區中 if (server.aof_state != AOF_OFF) feedAppendOnlyFile(server.delCommand,db->id,argv,2); // 同步到所有 slave replicationFeedSlaves(server.slaves,db->id,argv,2); decrRefCount(argv[0]); decrRefCount(argv[1]); }
dbAsyncDelete 函數會先調用 dictDelete 來刪除過期表中的鍵,然后處理鍵值表中的鍵值對象。它會根據值的占用的空間來選擇是直接釋放值對象,還是交給 bio 異步釋放值對象。判斷依據就是值的估計大小是否大於 LAZYFREE_THRESHOLD 閾值。鍵對象和 dictEntry 對象則都是直接被釋放。

#define LAZYFREE_THRESHOLD 64 int dbAsyncDelete(redisDb *db, robj *key) { // 刪除該鍵在過期表中對應的entry if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr); // unlink 該鍵在鍵值表對應的entry dictEntry *de = dictUnlink(db->dict,key->ptr); // 如果該鍵值占用空間非常小,懶刪除反而效率低。所以只有在一定條件下,才會異步刪除 if (de) { robj *val = dictGetVal(de); size_t free_effort = lazyfreeGetFreeEffort(val); // 如果釋放這個對象消耗很多,並且值未被共享(refcount == 1)則將其加入到懶刪除列表 if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) { atomicIncr(lazyfree_objects,1); bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL); dictSetVal(db->dict,de,NULL); } } // 釋放鍵值對,或者只釋放key,而將val設置為NULL來后續懶刪除 if (de) { dictFreeUnlinkedEntry(db->dict,de); // slot 和 key 的映射關系是用於快速定位某個key在哪個 slot中。 if (server.cluster_enabled) slotToKeyDel(key); return 1; } else { return 0; } }
dictUnlink 會將鍵值從鍵值表中刪除,但是卻不釋放 key、val和對應的表entry對象,而是將其直接返回,然后再調用dictFreeUnlinkedEntry進行釋放。dictDelete 是它的兄弟函數,但是會直接釋放相應的對象。二者底層都通過調用 dictGenericDelete來實現。dbAsyncDelete的兄弟函數 dbSyncDelete 就是直接調用dictDelete來刪除過期鍵。
void dictFreeUnlinkedEntry(dict *d, dictEntry *he) { if (he == NULL) return; // 釋放key對象 dictFreeKey(d, he); // 釋放值對象,如果它不為null dictFreeVal(d, he); // 釋放 dictEntry 對象 zfree(he); }
Redis 有自己的 bio 機制,主要是處理 AOF 落盤、懶刪除邏輯和關閉大文件fd。bioCreateBackgroundJob 函數將釋放值對象的 job 加入到隊列中,bioProcessBackgroundJobs會從隊列中取出任務,根據類型進行對應的操作。
void *bioProcessBackgroundJobs(void *arg) { ..... while(1) { listNode *ln; ln = listFirst(bio_jobs[type]); job = ln->value; if (type == BIO_CLOSE_FILE) { close((long)job->arg1); } else if (type == BIO_AOF_FSYNC) { aof_fsync((long)job->arg1); } else if (type == BIO_LAZY_FREE) { // 根據參數來決定要做什么。有參數1則要釋放它,有參數2和3是釋放兩個鍵值表 // 過期表,也就是釋放db 只有參數三是釋放跳表 if (job->arg1) lazyfreeFreeObjectFromBioThread(job->arg1); else if (job->arg2 && job->arg3) lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3); else if (job->arg3) lazyfreeFreeSlotsMapFromBioThread(job->arg3); } zfree(job); ...... } }
dbSyncDelete 則是直接刪除過期鍵,並且將鍵、值和 DictEntry 對象都釋放。
int dbSyncDelete(redisDb *db, robj *key) { // 刪除過期表中的entry if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr); // 刪除鍵值表中的entry if (dictDelete(db->dict,key->ptr) == DICT_OK) { // 如果開啟了集群,則刪除slot 和 key 映射表中key記錄。 if (server.cluster_enabled) slotToKeyDel(key); return 1; } else { return 0; } }
但是單獨用這種方式存在內存泄露的問題,當過期鍵一直沒有訪問將無法得到及時刪除,從而導致內存不能及時釋放。正因為如此,Redis還提供另一種定時任務刪除機制作為惰性刪除的補充。
Redis 內部維護一個定時任務,默認每秒運行10次(通過配置控制:hz)。定時任務中刪除過期鍵邏輯采用了自適應算法,根據鍵的過期比例、使用快慢兩種速率模式回收鍵,流程如下圖所示。

- 1) 定時任務首先根據快慢模式(慢模型掃描的鍵的數量以及可以執行時間都比快模式要多 )和相關閾值配置計算本周期最大執行時間、要檢查的數據庫數量以及每個數據庫掃描的鍵數量。
- 2) 從上次定時任務未掃描的數據庫開始,依次遍歷各個數據庫。
- 3)從數據庫中隨機選出 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 個鍵,如果發現是過期鍵,則調用 activeExpireCycleTryExpire 函數刪除它。
- 4)如果執行時間超過了設定的最大執行時間,則退出,並設置下一次使用慢模式執行。
- 5)未超時的話,則判斷是否采樣的鍵中是否有25%的鍵是過期的,如果是則繼續掃描當前數據庫,跳到第3步,否則開始掃描下一個數據庫。
Redis服務器實際使用的是惰性刪除和定期刪除兩種策略:通過配合使用這兩種刪除策略,服務器可以很好的在合理使用CPU時間和避免浪費內存空間之間取得平衡。
定期刪除策略由expire.c/activeExpireCycle 函數實現。在redis事件驅動的循環中的eventLoop->beforesleep和周期性操作 databasesCron 都會調用 activeExpireCycle 來處理過期鍵。但是二者傳入的 type 值不同,一個是ACTIVE_EXPIRE_CYCLE_SLOW 另外一個是ACTIVE_EXPIRE_CYCLE_FAST。activeExpireCycle 在規定的時間,分多次遍歷各個數據庫,從過期(expires)字典中隨機檢查一部分過期鍵的過期時間,刪除其中的過期鍵,相關源碼如下所示。
void activeExpireCycle(int type) { // 上次檢查的db static unsigned int current_db = 0; // 上次檢查的最大執行時間 static int timelimit_exit = 0; // 上一次快速模式運行時間 static long long last_fast_cycle = 0; /* When last fast cycle ran. */ int j, iteration = 0; // 每次檢查周期要遍歷的DB數 int dbs_per_call = CRON_DBS_PER_CALL; long long start = ustime(), timelimit, elapsed; ..... // 一些狀態時不進行檢查,直接返回 // 如果上次周期因為執行達到了最大執行時間而退出,則本次遍歷所有db,否則遍歷db數等於 CRON_DBS_PER_CALL if (dbs_per_call > server.dbnum || timelimit_exit) dbs_per_call = server.dbnum; // 根據ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC計算本次最大執行時間 timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100; timelimit_exit = 0; if (timelimit <= 0) timelimit = 1; // 如果是快速模式,則最大執行時間為ACTIVE_EXPIRE_CYCLE_FAST_DURATION if (type == ACTIVE_EXPIRE_CYCLE_FAST) timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */ // 采樣記錄 long total_sampled = 0; long total_expired = 0; // 依次遍歷 dbs_per_call 個 db for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) { int expired; redisDb *db = server.db+(current_db % server.dbnum); // 將db數增加,一遍下一次繼續從這個db開始遍歷 current_db++; do { ..... // 申明變量和一些情況下 break if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP) num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP; // 主要循環,在過期表中進行隨機采樣,判斷是否比率大於25% while (num--) { dictEntry *de; long long ttl; if ((de = dictGetRandomKey(db->expires)) == NULL) break; ttl = dictGetSignedIntegerVal(de)-now; // 刪除過期鍵 if (activeExpireCycleTryExpire(db,de,now)) expired++; if (ttl > 0) { /* We want the average TTL of keys yet not expired. */ ttl_sum += ttl; ttl_samples++; } total_sampled++; } // 記錄過期總數 total_expired += expired; // 即使有很多鍵要過期,也不阻塞很久,如果執行超過了最大執行時間,則返回 if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */ elapsed = ustime()-start; if (elapsed > timelimit) { timelimit_exit = 1; server.stat_expired_time_cap_reached_count++; break; } } // 當比率小於25%時返回 } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4); } .....// 更新一些server的記錄數據 }
activeExpireCycleTryExpire 函數的實現就和 expireIfNeeded 類似,這里就不贅述了。
int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) { long long t = dictGetSignedIntegerVal(de); if (now > t) { sds key = dictGetKey(de); robj *keyobj = createStringObject(key,sdslen(key)); propagateExpire(db,keyobj,server.lazyfree_lazy_expire); if (server.lazyfree_lazy_expire) dbAsyncDelete(db,keyobj); else dbSyncDelete(db,keyobj); notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired",keyobj,db->id); decrRefCount(keyobj); server.stat_expiredkeys++; return 1; } else { return 0; } }
定期刪除策略的關鍵點就是刪除操作執行的時長和頻率:
- 如果刪除操作太過頻繁或者執行時間太長,就對 CPU 時間不是很友好,CPU 時間過多的消耗在刪除過期鍵上。
- 如果刪除操作執行太少或者執行時間太短,就不能及時刪除過期鍵,導致內存浪費。
內存溢出控制策略
當Redis所用內存達到maxmemory上限時會觸發相應的溢出控制策略。 具體策略受maxmemory-policy參數控制,Redis支持6種策略,如下所示:
- 1)noeviction:默認策略,不會刪除任何數據,拒絕所有寫入操作並返 回客戶端錯誤信息(error)OOM command not allowed when used memory,此時Redis只響應讀操作。
- 2)volatile-lru:根據LRU算法刪除設置了超時屬性(expire)的鍵,直到騰出足夠空間為止。如果沒有可刪除的鍵對象,回退到noeviction策略。
- 3)allkeys-lru:根據LRU算法刪除鍵,不管數據有沒有設置超時屬性, 直到騰出足夠空間為止。
- 4)allkeys-random:隨機刪除所有鍵,直到騰出足夠空間為止。
- 5)volatile-random:隨機刪除過期鍵,直到騰出足夠空間為止。
- 6)volatile-ttl:根據鍵值對象的ttl屬性,刪除最近將要過期數據。如果沒有,回退到noeviction策略。
內存溢出控制策略可以使用 config set maxmemory-policy {policy} 語句進行動態配置。Redis 提供了豐富的空間溢出控制策略,我們可以根據自身業務需要進行選擇。
當設置 volatile-lru 策略時,保證具有過期屬性的鍵可以根據 LRU 剔除,而未設置超時的鍵可以永久保留。還可以采用allkeys-lru 策略把 Redis 變為純緩存服務器使用。
當Redis因為內存溢出刪除鍵時,可以通過執行 info stats 命令查看 evicted_keys 指標找出當前 Redis 服務器已剔除的鍵數量。
每次Redis執行命令時如果設置了maxmemory參數,都會嘗試執行回收 內存操作。當Redis一直工作在內存溢出(used_memory>maxmemory)的狀態下且設置非 noeviction 策略時,會頻繁地觸發回收內存的操作,影響Redis 服務器的性能,這一點千萬要引起注意。
-----------------
-----------------
個人理解說明
Redis的過期實現方式:
一,被動過期(惰性刪除):key被訪問時,如果發現它已經過期就刪除。調用expireIfNeeded函數刪除,刪除時會有3個動作:
①:通知 slave 實例並存儲到 AOF 緩沖區中。
②:記錄事件,可以通過訂閱頻道接收過期事件。
③:根據 lazyfree_lazy_expire 是否開啟異步刪除操作(Redis4.0)
優點:對CPU友好,僅在客戶端需要操作對應的鍵時,才會占用CPU。
缺點:不操作原本應該過期的鍵,則這些鍵依然占用Redis內存,得不到釋放,出現內存泄漏的問題。
二,主動過期(定期刪除):周期性地從過期字典中選擇一部分失效的主鍵刪除。
主動過期是根據動態計算來進行的操作,涉及到的變量有:
#define ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 20 隨機采樣的數量:20 #define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 過期周期類型為fast的最大執行時間:1毫秒 #define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 過期周期類型為slow最大CPU使用的百分比:25% #define CRON_DBS_PER_CALL 16 數據庫數量
周期性:文件src/server.c中的 serverCron函數調起,serverCron 自身作為時間事件,其執行頻率由配置文件中的 hz 選項指定,hz默認情況下值為10,表明 serverCron 執行頻率為每秒10次。其在 Redis 服務器運行期間一直定期運行, 所以它是一個循環時間事件:serverCron
會一直定期執行,直到服務器關閉為止。
過期循環的類型(src/expire.c):
ACTIVE_EXPIRE_CYCLE_FAST:以“快速過期”模式執行,執行的時間不會長過 EXPIRE_FAST_CYCLE_DURATION 微秒,並且在 EXPIRE_FAST_CYCLE_DURATION 微秒之內不會再重新執行。
ACTIVE_EXPIRE_CYCLE_SLOW:以“正常過期”模式執行,函數的執行時限為 REDIS_HZ 常量的一個百分比,這個百分比由 REDIS_EXPIRELOOKUPS_TIME_PERC 定義。
超時時間:如果過期鍵數太多,導致主動刪除策略執行時長增加,延遲了客戶端請求處理,需要一個超時時間來作為平衡點。主動刪除策略時長公式:
/* We can use at max ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC percentage of CPU time * per iteration. Since this function gets called with a frequency of * server.hz times per second, the following is the max amount of * microseconds we can spend in this function. */ timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默認為 25 ,也即是 25% 的 CPU 時間。
timelimit = 1000000 * 25/10/100 = 25000 微妙 = 25毫秒,即超時為25毫秒。在每個周期(1秒10次,即100毫秒)里的25毫秒里(25%的CPU時間)可以一直循環釋放過期key,要是過期的key較多,CPU會有些壓力。
過期流程:
Redis在執行周期性任務(serverCron,默認每100ms(hz=10)執行一次)時,從Redis過期字典里隨機采樣獲取ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP(20)個key,判斷是否過期,過期則刪除。如果采樣中過期key的比例大於25%,則繼續在當前庫下循環執行,直到超過了限制的時間(25毫秒)終止過期刪除;如果隨機采樣的key的過期比例小於25%,則遍歷下一個庫,循環上面過期操作,直到所有庫遍歷完成。如果遍歷期間時間超過了超時時間,則退出,等到下一個周期再從該庫執行過期操作。
從中可以看到如果隨機采集的key中過期比例的一直大於25%,則循環過期期間CPU會有點壓力,過期釋放key的數量和內存都比較快。如果隨機采集比例的過期key都低於25%,則過期釋放key的數量和內存都較慢。
參考文章:
https://segmentfault.com/a/1190000015335204
https://www.cnblogs.com/chenpingzhao/p/5211456.html
https://zhuanlan.zhihu.com/p/136965310
https://zhuanlan.zhihu.com/p/88694872
https://zhuanlan.zhihu.com/p/54758076
https://zhuanlan.zhihu.com/p/44099024