詳解 Redis 記憶體管理機制和實現
Redis是一個基於記憶體的鍵值資料庫,其記憶體管理是非常重要的。本文記憶體管理的內容包括:過期鍵的懶性刪除和過期刪除以及記憶體溢位控制策略。
最大記憶體限制
Redis使用 maxmemory 引數限制最大可用記憶體,預設值為0,表示無限制。限制記憶體的目的主要 有:
- 用於快取場景,當超出記憶體上限 maxmemory 時使用 LRU 等刪除策略釋放空間。
- 防止所用記憶體超過伺服器實體記憶體。因為 Redis 預設情況下是會盡可能多使用伺服器的記憶體,可能會出現伺服器記憶體不足,導致 Redis 程式被殺死。
maxmemory 限制的是Redis實際使用的記憶體量,也就是 used_memory統計項對應的記憶體。由於記憶體碎片率的存在,實際消耗的記憶體 可能會比maxmemory設定的更大,實際使用時要小心這部分記憶體溢位。具體Redis 記憶體監控的內容請檢視
Redis預設無限使用伺服器記憶體,為防止極端情況下導致系統記憶體耗 盡,建議所有的Redis程式都要配置maxmemory。 在保證實體記憶體可用的情況下,系統中所有Redis例項可以調整 maxmemory引數來達到自由伸縮記憶體的目的。
記憶體回收策略
Redis 回收記憶體大致有兩個機制:一是刪除到達過期時間的鍵值物件;二是當記憶體達到 maxmemory 時觸發記憶體移除控制策略,強制刪除選擇出來的鍵值物件。
刪除過期鍵物件
Redis 所有的鍵都可以設定過期屬性,內部儲存在過期表中,鍵值表和過期表的結果如下圖所示。當 Redis儲存大量的鍵,對每個鍵都進行精準的過期刪除可能會導致消耗大量的 CPU,會阻塞 Redis 的主執行緒,拖累 Redis 的效能,因此 Redis 採用惰性刪除和定時任務刪除機制實現過期鍵的記憶體回收。
惰性刪除是指當客戶端操作帶有超時屬性的鍵時,會檢查是否超過鍵的過期時間,然後會同步或者非同步執行刪除操作並返回鍵已經過期。這樣可以節省 CPU成本考慮,不需要單獨維護過期時間連結串列來處理過期鍵的刪除。
過期鍵的惰性刪除策略由 db.c/expireifNeeded 函式實現,所有對資料庫的讀寫命令執行之前都會呼叫 expireifNeeded 來檢查命令執行的鍵是否過期。如果鍵過期,expireifNeeded 會將過期鍵從鍵值表和過期表中刪除,然後同步或者非同步釋放對應物件的空間。原始碼展示的時 Redis 4.0 版本。
expireIfNeeded 先從過期表中獲取鍵對應的過期時間,如果當前時間已經超過了過期時間(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",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,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 d的兄弟函式 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次(通過配置控制)。定時任務中刪除過期鍵邏輯採用了自適應演演算法,根據鍵的 過期比例、使用快慢兩種速率模式回收鍵,流程如下圖所示。
- 1)定時任務首先根據快慢模式( 慢模型掃描的鍵的數量以及可以執行時間都比快模式要多 )和相關閾值配置計算計算本週期最大執行時間、要檢查的資料庫數量以及每個資料庫掃描的鍵數量。
-
- 從上次定時任務未掃描的資料庫開始,依次遍歷各個資料庫。
- 3)從資料庫中隨機選手 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 個鍵,如果發現是過期鍵,則呼叫 activeExpireCycleTryExpire 函式刪除它。
- 4)如果執行時間超過了設定的最大執行時間,則退出,並設定下一次使用慢模式執行。
- 5)未超時的話,則判斷是否取樣的鍵中是否有25%的鍵是過期的,如果是則繼續掃描當前資料庫,跳到第3步。否則開始掃描下一個資料庫。
定期刪除策略由 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,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,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 伺服器的效能,這一點千萬要引起注意。