詳解 Redis 內(nèi)存管理機(jī)制和實(shí)現(xiàn)
本文轉(zhuǎn)載自微信公眾號(hào)「程序員歷小冰」,作者歷小冰 。轉(zhuǎn)載本文請(qǐng)聯(lián)系程序員歷小冰公眾號(hào)。
Redis是一個(gè)基于內(nèi)存的鍵值數(shù)據(jù)庫(kù),其內(nèi)存管理是非常重要的。本文內(nèi)存管理的內(nèi)容包括:過(guò)期鍵的懶性刪除和過(guò)期刪除以及內(nèi)存溢出控制策略。
最大內(nèi)存限制
Redis使用 maxmemory 參數(shù)限制最大可用內(nèi)存,默認(rèn)值為0,表示無(wú)限制。限制內(nèi)存的目的主要 有:
- 用于緩存場(chǎng)景,當(dāng)超出內(nèi)存上限 maxmemory 時(shí)使用 LRU 等刪除策略釋放空間。
- 防止所用內(nèi)存超過(guò)服務(wù)器物理內(nèi)存。因?yàn)?Redis 默認(rèn)情況下是會(huì)盡可能多使用服務(wù)器的內(nèi)存,可能會(huì)出現(xiàn)服務(wù)器內(nèi)存不足,導(dǎo)致 Redis 進(jìn)程被殺死。
maxmemory 限制的是Redis實(shí)際使用的內(nèi)存量,也就是 used_memory統(tǒng)計(jì)項(xiàng)對(duì)應(yīng)的內(nèi)存。由于內(nèi)存碎片率的存在,實(shí)際消耗的內(nèi)存 可能會(huì)比maxmemory設(shè)置的更大,實(shí)際使用時(shí)要小心這部分內(nèi)存溢出。具體Redis 內(nèi)存監(jiān)控的內(nèi)容請(qǐng)查看一文了解 Redis 內(nèi)存監(jiān)控和內(nèi)存消耗。
Redis默認(rèn)無(wú)限使用服務(wù)器內(nèi)存,為防止極端情況下導(dǎo)致系統(tǒng)內(nèi)存耗 盡,建議所有的Redis進(jìn)程都要配置maxmemory。在保證物理內(nèi)存可用的情況下,系統(tǒng)中所有Redis實(shí)例可以調(diào)整 maxmemory參數(shù)來(lái)達(dá)到自由伸縮內(nèi)存的目的。
內(nèi)存回收策略
Redis 回收內(nèi)存大致有兩個(gè)機(jī)制:一是刪除到達(dá)過(guò)期時(shí)間的鍵值對(duì)象;二是當(dāng)內(nèi)存達(dá)到 maxmemory 時(shí)觸發(fā)內(nèi)存移除控制策略,強(qiáng)制刪除選擇出來(lái)的鍵值對(duì)象。
刪除過(guò)期鍵對(duì)象
Redis 所有的鍵都可以設(shè)置過(guò)期屬性,內(nèi)部保存在過(guò)期表中,鍵值表和過(guò)期表的結(jié)果如下圖所示。當(dāng) Redis保存大量的鍵,對(duì)每個(gè)鍵都進(jìn)行精準(zhǔn)的過(guò)期刪除可能會(huì)導(dǎo)致消耗大量的 CPU,會(huì)阻塞 Redis 的主線程,拖累 Redis 的性能,因此 Redis 采用惰性刪除和定時(shí)任務(wù)刪除機(jī)制實(shí)現(xiàn)過(guò)期鍵的內(nèi)存回收。
惰性刪除是指當(dāng)客戶端操作帶有超時(shí)屬性的鍵時(shí),會(huì)檢查是否超過(guò)鍵的過(guò)期時(shí)間,然后會(huì)同步或者異步執(zhí)行刪除操作并返回鍵已經(jīng)過(guò)期。這樣可以節(jié)省 CPU成本考慮,不需要單獨(dú)維護(hù)過(guò)期時(shí)間鏈表來(lái)處理過(guò)期鍵的刪除。
過(guò)期鍵的惰性刪除策略由 db.c/expireifNeeded 函數(shù)實(shí)現(xiàn),所有對(duì)數(shù)據(jù)庫(kù)的讀寫(xiě)命令執(zhí)行之前都會(huì)調(diào)用 expireifNeeded 來(lái)檢查命令執(zhí)行的鍵是否過(guò)期。如果鍵過(guò)期,expireifNeeded 會(huì)將過(guò)期鍵從鍵值表和過(guò)期表中刪除,然后同步或者異步釋放對(duì)應(yīng)對(duì)象的空間。源碼展示的時(shí) Redis 4.0 版本。
expireIfNeeded 先從過(guò)期表中獲取鍵對(duì)應(yīng)的過(guò)期時(shí)間,如果當(dāng)前時(shí)間已經(jīng)超過(guò)了過(guò)期時(shí)間(lua腳本執(zhí)行則有特殊邏輯,詳看代碼注釋),則進(jìn)入刪除鍵流程。刪除鍵流程主要進(jìn)行了三件事:
- 一是刪除操作命令傳播,通知 slave 實(shí)例并存儲(chǔ)到 AOF 緩沖區(qū)中
- 二是記錄鍵空間事件,
- 三是根據(jù) lazyfreelazyexpire 是否開(kāi)啟進(jìn)行異步刪除或者異步刪除操作。
- int expireIfNeeded(redisDb *db, robj *key) {
- // 獲取鍵的過(guò)期時(shí)間
- mstime_t when = getExpire(db,key);
- mstime_t now;
- // 鍵沒(méi)有過(guò)期時(shí)間
- if (when < 0) return 0;
- // 實(shí)例正在從硬盤(pán) laod 數(shù)據(jù),比如說(shuō) RDB 或者 AOF
- if (server.loading) return 0;
- // 當(dāng)執(zhí)行l(wèi)ua腳本時(shí),只有鍵在lua一開(kāi)始執(zhí)行時(shí)
- // 就到了過(guò)期時(shí)間才算過(guò)期,否則在lua執(zhí)行過(guò)程中不算失效
- now = server.lua_caller ? server.lua_time_start : mstime();
- // 當(dāng)本實(shí)例是slave時(shí),過(guò)期鍵的刪除由master發(fā)送過(guò)來(lái)的
- // del 指令控制。但是這個(gè)函數(shù)還是將正確的信息返回給調(diào)用者。
- if (server.masterhost != NULL) return now > when;
- // 判斷是否未過(guò)期
- if (now <= when) return 0;
- // 代碼到這里,說(shuō)明鍵已經(jīng)過(guò)期,而且需要被刪除
- server.stat_expiredkeys++;
- // 命令傳播,到 slave 和 AOF
- propagateExpire(db,key,server.lazyfree_lazy_expire);
- // 鍵空間通知使得客戶端可以通過(guò)訂閱頻道或模式, 來(lái)接收那些以某種方式改動(dòng)了 Redis 數(shù)據(jù)集的事件。
- notifyKeyspaceEvent(NOTIFY_EXPIRED,
- "expired",key,db->id);
- // 如果是惰性刪除,調(diào)用dbAsyncDelete,否則調(diào)用 dbSyncDelete
- return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
- dbSyncDelete(db,key);
- }
上圖是寫(xiě)命令傳播的示意圖,刪除命令的傳播和它一致。propagateExpire 函數(shù)先調(diào)用 feedAppendOnlyFile 函數(shù)將命令同步到 AOF 的緩沖區(qū)中,然后調(diào)用 replicationFeedSlaves函數(shù)將命令同步到所有的 slave 中。Redis 復(fù)制的機(jī)制可以查看Redis 復(fù)制過(guò)程詳解。
- // 將命令傳遞到slave和AOF緩沖區(qū)。maser刪除一個(gè)過(guò)期鍵時(shí)會(huì)發(fā)送Del命令到所有的slave和AOF緩沖區(qū)
- void propagateExpire(redisDb *db, robj *key, int lazy) {
- robj *argv[2];
- // 生成同步的數(shù)據(jù)
- argv[0] = lazy ? shared.unlink : shared.del;
- argv[1] = key;
- incrRefCount(argv[0]);
- incrRefCount(argv[1]);
- // 如果開(kāi)啟了 AOF 則追加到 AOF 緩沖區(qū)中
- 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 函數(shù)會(huì)先調(diào)用 dictDelete 來(lái)刪除過(guò)期表中的鍵,然后處理鍵值表中的鍵值對(duì)象。它會(huì)根據(jù)值的占用的空間來(lái)選擇是直接釋放值對(duì)象,還是交給 bio 異步釋放值對(duì)象。判斷依據(jù)就是值的估計(jì)大小是否大于 LAZYFREE_THRESHOLD 閾值。鍵對(duì)象和 dictEntry 對(duì)象則都是直接被釋放。
- #define LAZYFREE_THRESHOLD 64
- int dbAsyncDelete(redisDb *db, robj *key) {
- // 刪除該鍵在過(guò)期表中對(duì)應(yīng)的entry
- if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
- // unlink 該鍵在鍵值表對(duì)應(yīng)的entry
- dictEntry *de = dictUnlink(db->dict,key->ptr);
- // 如果該鍵值占用空間非常小,懶刪除反而效率低。所以只有在一定條件下,才會(huì)異步刪除
- if (de) {
- robj *val = dictGetVal(de);
- size_t free_effort = lazyfreeGetFreeEffort(val);
- // 如果釋放這個(gè)對(duì)象消耗很多,并且值未被共享(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);
- }
- }
- // 釋放鍵值對(duì),或者只釋放key,而將val設(shè)置為NULL來(lái)后續(xù)懶刪除
- if (de) {
- dictFreeUnlinkedEntry(db->dict,de);
- // slot 和 key 的映射關(guān)系是用于快速定位某個(gè)key在哪個(gè) slot中。
- if (server.cluster_enabled) slotToKeyDel(key);
- return 1;
- } else {
- return 0;
- }
- }
dictUnlink 會(huì)將鍵值從鍵值表中刪除,但是卻不釋放 key、val和對(duì)應(yīng)的表entry對(duì)象,而是將其直接返回,然后再調(diào)用dictFreeUnlinkedEntry進(jìn)行釋放。dictDelete 是它的兄弟函數(shù),但是會(huì)直接釋放相應(yīng)的對(duì)象。二者底層都通過(guò)調(diào)用 dictGenericDelete來(lái)實(shí)現(xiàn)。dbAsyncDelete d的兄弟函數(shù) dbSyncDelete 就是直接調(diào)用dictDelete來(lái)刪除過(guò)期鍵。
- void dictFreeUnlinkedEntry(dict *d, dictEntry *he) {
- if (he == NULL) return;
- // 釋放key對(duì)象
- dictFreeKey(d, he);
- // 釋放值對(duì)象,如果它不為null
- dictFreeVal(d, he);
- // 釋放 dictEntry 對(duì)象
- zfree(he);
- }
Redis 有自己的 bio 機(jī)制,主要是處理 AOF 落盤(pán)、懶刪除邏輯和關(guān)閉大文件fd。bioCreateBackgroundJob 函數(shù)將釋放值對(duì)象的 job 加入到隊(duì)列中,bioProcessBackgroundJobs會(huì)從隊(duì)列中取出任務(wù),根據(jù)類型進(jìn)行對(duì)應(yīng)的操作。
- 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) {
- // 根據(jù)參數(shù)來(lái)決定要做什么。有參數(shù)1則要釋放它,
- // 有參數(shù)2和3是釋放兩個(gè)鍵值表
- // 過(guò)期表,也就是釋放db 只有參數(shù)三是釋放跳表
- if (job->arg1)
- lazyfreeFreeObject
- FromBioThread(job->arg1);
- else if (job->arg2 && job->arg3)
- lazyfreeFreeDatabase
- FromBioThread(job->arg2,job->arg3);
- else if (job->arg3)
- lazyfreeFreeSlotsMap
- FromBioThread(job->arg3);
- }
- zfree(job);
- ......
- }
- }
dbSyncDelete 則是直接刪除過(guò)期鍵,并且將鍵、值和 DictEntry 對(duì)象都釋放。
- int dbSyncDelete(redisDb *db, robj *key) {
- // 刪除過(guò)期表中的entry
- if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
- // 刪除鍵值表中的entry
- if (dictDelete(db->dict,key->ptr) == DICT_OK) {
- // 如果開(kāi)啟了集群,則刪除slot 和 key 映射表中key記錄。
- if (server.cluster_enabled) slotToKeyDel(key);
- return 1;
- } else {
- return 0;
- }
- }
但是單獨(dú)用這種方式存在內(nèi)存泄露的問(wèn)題,當(dāng)過(guò)期鍵一直沒(méi)有訪問(wèn)將無(wú)法得到及時(shí)刪除,從而導(dǎo)致內(nèi)存不能及時(shí)釋放。正因?yàn)槿绱耍琑edis還提供另一種定時(shí)任 務(wù)刪除機(jī)制作為惰性刪除的補(bǔ)充。
Redis 內(nèi)部維護(hù)一個(gè)定時(shí)任務(wù),默認(rèn)每秒運(yùn)行10次(通過(guò)配置控制)。定時(shí)任務(wù)中刪除過(guò)期鍵邏輯采用了自適應(yīng)算法,根據(jù)鍵的 過(guò)期比例、使用快慢兩種速率模式回收鍵,流程如下圖所示。
- 1)定時(shí)任務(wù)首先根據(jù)快慢模式( 慢模型掃描的鍵的數(shù)量以及可以執(zhí)行時(shí)間都比快模式要多 )和相關(guān)閾值配置計(jì)算計(jì)算本周期最大執(zhí)行時(shí)間、要檢查的數(shù)據(jù)庫(kù)數(shù)量以及每個(gè)數(shù)據(jù)庫(kù)掃描的鍵數(shù)量。
- 2) 從上次定時(shí)任務(wù)未掃描的數(shù)據(jù)庫(kù)開(kāi)始,依次遍歷各個(gè)數(shù)據(jù)庫(kù)。
- 3)從數(shù)據(jù)庫(kù)中隨機(jī)選手 ACTIVEEXPIRECYCLELOOKUPSPER_LOOP 個(gè)鍵,如果發(fā)現(xiàn)是過(guò)期鍵,則調(diào)用 activeExpireCycleTryExpire 函數(shù)刪除它。
- 4)如果執(zhí)行時(shí)間超過(guò)了設(shè)定的最大執(zhí)行時(shí)間,則退出,并設(shè)置下一次使用慢模式執(zhí)行。
- 5)未超時(shí)的話,則判斷是否采樣的鍵中是否有25%的鍵是過(guò)期的,如果是則繼續(xù)掃描當(dāng)前數(shù)據(jù)庫(kù),跳到第3步。否則開(kāi)始掃描下一個(gè)數(shù)據(jù)庫(kù)。
定期刪除策略由 expire.c/activeExpireCycle 函數(shù)實(shí)現(xiàn)。在redis事件驅(qū)動(dòng)的循環(huán)中的eventLoop->beforesleep和 周期性操作 databasesCron 都會(huì)調(diào)用 activeExpireCycle 來(lái)處理過(guò)期鍵。但是二者傳入的 type 值不同,一個(gè)是ACTIVEEXPIRECYCLESLOW 另外一個(gè)是ACTIVEEXPIRECYCLEFAST。activeExpireCycle 在規(guī)定的時(shí)間,分多次遍歷各個(gè)數(shù)據(jù)庫(kù),從 expires 字典中隨機(jī)檢查一部分過(guò)期鍵的過(guò)期時(shí)間,刪除其中的過(guò)期鍵,相關(guān)源碼如下所示。
- void activeExpireCycle(int type) {
- // 上次檢查的db
- static unsigned int current_db = 0;
- // 上次檢查的最大執(zhí)行時(shí)間
- static int timelimit_exit = 0;
- // 上一次快速模式運(yùn)行時(shí)間
- static long long last_fast_cycle = 0; /* When last fast cycle ran. */
- int j, iteration = 0;
- // 每次檢查周期要遍歷的DB數(shù)
- int dbs_per_call = CRON_DBS_PER_CALL;
- long long start = ustime(), timelimit, elapsed;
- ..... // 一些狀態(tài)時(shí)不進(jìn)行檢查,直接返回
- // 如果上次周期因?yàn)閳?zhí)行達(dá)到了最大執(zhí)行時(shí)間而退出,則本次遍歷所有db,否則遍歷db數(shù)等于 CRON_DBS_PER_CALL
- if (dbs_per_call > server.dbnum || timelimit_exit)
- dbs_per_call = server.dbnum;
- // 根據(jù)ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC計(jì)算本次最大執(zhí)行時(shí)間
- timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
- timelimit_exit = 0;
- if (timelimit <= 0) timelimit = 1;
- // 如果是快速模式,則最大執(zhí)行時(shí)間為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 個(gè) db
- for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
- int expired;
- redisDb *db = server.db+(current_db % server.dbnum);
- // 將db數(shù)增加,一遍下一次繼續(xù)從這個(gè)db開(kāi)始遍歷
- current_db++;
- do {
- ..... // 申明變量和一些情況下 break
- if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
- num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
- // 主要循環(huán),在過(guò)期表中進(jìn)行隨機(jī)采樣,判斷是否比率大于25%
- while (num--) {
- dictEntry *de;
- long long ttl;
- if ((de = dictGetRandomKey(db->expires)) == NULL) break;
- ttl = dictGetSignedIntegerVal(de)-now;
- // 刪除過(guò)期鍵
- 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++;
- }
- // 記錄過(guò)期總數(shù)
- total_expired += expired;
- // 即使有很多鍵要過(guò)期,也不阻塞很久,如果執(zhí)行超過(guò)了最大執(zhí)行時(shí)間,則返回
- 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;
- }
- }
- // 當(dāng)比率小于25%時(shí)返回
- } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
- }
- .....// 更新一些server的記錄數(shù)據(jù)
- }
activeExpireCycleTryExpire 函數(shù)的實(shí)現(xiàn)就和 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;
- }
- }
定期刪除策略的關(guān)鍵點(diǎn)就是刪除操作執(zhí)行的時(shí)長(zhǎng)和頻率:
- 如果刪除操作太過(guò)頻繁或者執(zhí)行時(shí)間太長(zhǎng),就對(duì) CPU 時(shí)間不是很友好,CPU 時(shí)間過(guò)多的消耗在刪除過(guò)期鍵上。
- 如果刪除操作執(zhí)行太少或者執(zhí)行時(shí)間太短,就不能及時(shí)刪除過(guò)期鍵,導(dǎo)致內(nèi)存浪費(fèi)。
內(nèi)存溢出控制策略
當(dāng)Redis所用內(nèi)存達(dá)到maxmemory上限時(shí)會(huì)觸發(fā)相應(yīng)的溢出控制策略。具體策略受maxmemory-policy參數(shù)控制,Redis支持6種策略,如下所示:
- 1)noeviction:默認(rèn)策略,不會(huì)刪除任何數(shù)據(jù),拒絕所有寫(xiě)入操作并返 回客戶端錯(cuò)誤信息(error)OOM command not allowed when used memory,此 時(shí)Redis只響應(yīng)讀操作。
- 2)volatile-lru:根據(jù)LRU算法刪除設(shè)置了超時(shí)屬性(expire)的鍵,直 到騰出足夠空間為止。如果沒(méi)有可刪除的鍵對(duì)象,回退到noeviction策略。
- 3)allkeys-lru:根據(jù)LRU算法刪除鍵,不管數(shù)據(jù)有沒(méi)有設(shè)置超時(shí)屬性, 直到騰出足夠空間為止。
- 4)allkeys-random:隨機(jī)刪除所有鍵,直到騰出足夠空間為止。
- 5)volatile-random:隨機(jī)刪除過(guò)期鍵,直到騰出足夠空間為止。
- 6)volatile-ttl:根據(jù)鍵值對(duì)象的ttl屬性,刪除最近將要過(guò)期數(shù)據(jù)。如果沒(méi)有,回退到noeviction策略。
內(nèi)存溢出控制策略可以使用 config set maxmemory-policy {policy} 語(yǔ)句進(jìn)行動(dòng)態(tài)配置。Redis 提供了豐富的空間溢出控制策略,我們可以根據(jù)自身業(yè)務(wù)需要進(jìn)行選擇。
當(dāng)設(shè)置 volatile-lru 策略時(shí),保證具有過(guò)期屬性的鍵可以根據(jù) LRU 剔除,而未設(shè)置超時(shí)的鍵可以永久保留。還可以采用allkeys-lru 策略把 Redis 變?yōu)榧兙彺娣?wù)器使用。
當(dāng)Redis因?yàn)閮?nèi)存溢出刪除鍵時(shí),可以通過(guò)執(zhí)行 info stats 命令查看 evicted_keys 指標(biāo)找出當(dāng)前 Redis 服務(wù)器已剔除的鍵數(shù)量。
每次Redis執(zhí)行命令時(shí)如果設(shè)置了maxmemory參數(shù),都會(huì)嘗試執(zhí)行回收 內(nèi)存操作。當(dāng)Redis一直工作在內(nèi)存溢出(used_memory>maxmemory)的狀態(tài)下且設(shè)置非 noeviction 策略時(shí),會(huì)頻繁地觸發(fā)回收內(nèi)存的操作,影響Redis 服務(wù)器的性能,這一點(diǎn)千萬(wàn)要引起注意。