天天看點

redis lua 設定過期_關于Redis 記憶體管理機制和實作的詳解

Redis是一個基于記憶體的鍵值資料庫,其記憶體管理是非常重要的。本文記憶體管理的内容包括:過期鍵的懶性删除和過期删除以及記憶體溢出控制政策。

最大記憶體限制

Redis使用 maxmemory 參數限制最大可用記憶體,預設值為0,表示無限制。限制記憶體的目的主要 有:

  • 用于緩存場景,當超出記憶體上限 maxmemory 時使用 LRU 等删除政策釋放空間。
  • 防止所用記憶體超過伺服器實體記憶體。因為 Redis 預設情況下是會盡可能多使用伺服器的記憶體,可能會出現伺服器記憶體不足,導緻 Redis 程序被殺死。
redis lua 設定過期_關于Redis 記憶體管理機制和實作的詳解

maxmemory 限制的是Redis實際使用的記憶體量,也就是 used_memory統計項對應的記憶體。由于記憶體碎片率的存在,實際消耗的記憶體 可能會比maxmemory設定的更大,實際使用時要小心這部分記憶體溢出。具體Redis 記憶體監控的内容請檢視一文了解 Redis 記憶體監控和記憶體消耗。

Redis預設無限使用伺服器記憶體,為防止極端情況下導緻系統記憶體耗 盡,建議所有的Redis程序都要配置maxmemory。在保證實體記憶體可用的情況下,系統中所有Redis執行個體可以調整 maxmemory參數來達到自由伸縮記憶體的目的。

記憶體回收政策

Redis 回收記憶體大緻有兩個機制:一是删除到達過期時間的鍵值對象;二是當記憶體達到 maxmemory 時觸發記憶體移除控制政策,強制删除選擇出來的鍵值對象。

删除過期鍵對象

Redis 所有的鍵都可以設定過期屬性,内部儲存在過期表中,鍵值表和過期表的結果如下圖所示。當 Redis儲存大量的鍵,對每個鍵都進行精準的過期删除可能會導緻消耗大量的 CPU,會阻塞 Redis 的主線程,拖累 Redis 的性能,是以 Redis 采用惰性删除和定時任務删除機制實作過期鍵的記憶體回收。

redis lua 設定過期_關于Redis 記憶體管理機制和實作的詳解

惰性删除是指當用戶端操作帶有逾時屬性的鍵時,會檢查是否超過鍵的過期時間,然後會同步或者異步執行删除操作并傳回鍵已經過期。這樣可以節省 CPU成本考慮,不需要單獨維護過期時間連結清單來處理過期鍵的删除。

過期鍵的惰性删除政策由 db.c/expireifNeeded 函數實作,所有對資料庫的讀寫指令執行之前都會調用 expireifNeeded 來檢查指令執行的鍵是否過期。如果鍵過期,expireifNeeded 會将過期鍵從鍵值表和過期表中删除,然後同步或者異步釋放對應對象的空間。源碼展示的時 Redis 4.0 版本。

expireIfNeeded 先從過期表中擷取鍵對應的過期時間,如果目前時間已經超過了過期時間(lua腳本執行則有特殊邏輯,詳看代碼注釋),則進入删除鍵流程。删除鍵流程主要進行了三件事:

  • 一是删除操作指令傳播,通知 slave 執行個體并存儲到 AOF 緩沖區中
  • 二是記錄鍵空間事件,
  • 三是根據 lazyfreelazyexpire 是否開啟進行異步删除或者異步删除操作。
  1. int expireIfNeeded(redisDb *db, robj *key) {
  2. // 擷取鍵的過期時間
  3. mstime_t when = getExpire(db,key);
  4. mstime_t now;
  5. // 鍵沒有過期時間
  6. if (when < 0) return 0;
  7. // 執行個體正在從硬碟 laod 資料,比如說 RDB 或者 AOF
  8. if (server.loading) return 0;
  9. // 當執行lua腳本時,隻有鍵在lua一開始執行時
  10. // 就到了過期時間才算過期,否則在lua執行過程中不算失效
  11. now = server.lua_caller ? server.lua_time_start : mstime();
  12. // 當本執行個體是slave時,過期鍵的删除由master發送過來的
  13. // del 指令控制。但是這個函數還是将正确的資訊傳回給調用者。
  14. if (server.masterhost != NULL) return now > when;
  15. // 判斷是否未過期
  16. if (now <= when) return 0;
  17. // 代碼到這裡,說明鍵已經過期,而且需要被删除
  18. server.stat_expiredkeys++;
  19. // 指令傳播,到 slave 和 AOF
  20. propagateExpire(db,key,server.lazyfree_lazy_expire);
  21. // 鍵空間通知使得用戶端可以通過訂閱頻道或模式, 來接收那些以某種方式改動了 Redis 資料集的事件。
  22. notifyKeyspaceEvent(NOTIFY_EXPIRED,
  23. "expired",key,db->id);
  24. // 如果是惰性删除,調用dbAsyncDelete,否則調用 dbSyncDelete
  25. return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
  26. dbSyncDelete(db,key);
  27. }
redis lua 設定過期_關于Redis 記憶體管理機制和實作的詳解

上圖是寫指令傳播的示意圖,删除指令的傳播和它一緻。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 對象則都是直接被釋放。

redis lua 設定過期_關于Redis 記憶體管理機制和實作的詳解
#define LAZYFREE_THRESHOLD 64int 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)                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 則是直接删除過期鍵,并且将鍵、值和 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次(通過配置控制)。定時任務中删除過期鍵邏輯采用了自适應算法,根據鍵的 過期比例、使用快慢兩種速率模式回收鍵,流程如下圖所示。

redis lua 設定過期_關于Redis 記憶體管理機制和實作的詳解
  • 1)定時任務首先根據快慢模式( 慢模型掃描的鍵的數量以及可以執行時間都比快模式要多 )和相關門檻值配置計算計算本周期最大執行時間、要檢查的資料庫數量以及每個資料庫掃描的鍵數量。
  • 2) 從上次定時任務未掃描的資料庫開始,依次周遊各個資料庫。
  • 3)從資料庫中随機選手 ACTIVEEXPIRECYCLELOOKUPSPER_LOOP 個鍵,如果發現是過期鍵,則調用 activeExpireCycleTryExpire 函數删除它。
  • 4)如果執行時間超過了設定的最大執行時間,則退出,并設定下一次使用慢模式執行。
  • 5)未逾時的話,則判斷是否采樣的鍵中是否有25%的鍵是過期的,如果是則繼續掃描目前資料庫,跳到第3步。否則開始掃描下一個資料庫。

定期删除政策由 expire.c/activeExpireCycle 函數實作。在redis事件驅動的循環中的eventLoop->beforesleep和 周期性操作 databasesCron 都會調用 activeExpireCycle 來處理過期鍵。但是二者傳入的 type 值不同,一個是ACTIVEEXPIRECYCLESLOW 另外一個是ACTIVEEXPIRECYCLEFAST。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 伺服器的性能,這一點千萬要引起注意。

繼續閱讀