作為緩存系統都要定期清理無效數據,就需要一個主鍵失效和淘汰策略。
1.EXPIRE主鍵失效機制
在Redis當中,有生存期的key被稱為volatile,
在創建緩存時,要為給定的key設置生存期,當key過期的時候(生存期為0),它可能會被刪除。
(1)影響生存時間的一些操作
生存時間可以通過使用 DEL 命令來刪除整個 key 來移除,或者被 SET 和 GETSET 命令覆蓋原來的數據,
也就是說,修改key對應的value和使用另外相同的key和value來覆蓋以后,當前數據的生存時間不同。
比如說,對一個 key 執行INCR命令,對一個列表進行LPUSH命令,或者對一個哈希表執行HSET命令,這類操作都不會修改 key 本身的生存時間。
另一方面,如果使用RENAME對一個 key 進行改名,那么改名后的 key 的生存時間和改名前一樣。
RENAME命令的另一種可能是,嘗試將一個帶生存時間的 key 改名成另一個帶生存時間的 another_key ,這時舊的 another_key (以及它的生存時間)會被刪除,然后舊的 key 會改名為 another_key ,因此,新的 another_key 的生存時間也和原本的 key 一樣。
使用PERSIST命令可以在不刪除 key 的情況下,移除 key 的生存時間,讓 key 重新成為一個persistent key 。
(2)如何更新生存時間
可以對一個已經帶有生存時間的 key 執行EXPIRE命令,新指定的生存時間會取代舊的生存時間。
過期時間的精度已經被控制在1ms之內,主鍵失效的時間復雜度是O(1),
EXPIRE和TTL命令搭配使用,TTL可以查看key的當前生存時間
設置成功返回 1;當 key 不存在或者不能為 key 設置生存時間時,返回 0 。
2.最大緩存配置
在 redis 中,允許用戶設置最大使用內存大小
server.maxmemory
默認為0,沒有指定最大緩存,如果有新的數據添加,超過最大內存,則會使redis崩潰,所以一定要設置。
redis 內存數據集大小上升到一定大小的時候,就會實行數據淘汰策略。
redis 提供 6種數據淘汰策略:
volatile-lru:從已設置過期時間的數據集(server.db[i].expires)中挑選最近最少使用的數據淘汰
volatile-ttl:從已設置過期時間的數據集(server.db[i].expires)中挑選將要過期的數據淘汰
volatile-random:從已設置過期時間的數據集(server.db[i].expires)中任意選擇數據淘汰
allkeys-lru:從數據集(server.db[i].dict)中挑選最近最少使用的數據淘汰
allkeys-random:從數據集(server.db[i].dict)中任意選擇數據淘汰
no-enviction(驅逐):禁止驅逐數據
注意這里的6種機制,volatile和allkeys規定了是對已設置過期時間的數據集淘汰數據還是從全部數據集淘汰數據,
后面的lru、ttl以及random是三種不同的淘汰策略,再加上一種no-enviction永不回收的策略。
使用策略規則:
(1)如果數據呈現冪律分布,也就是一部分數據訪問頻率高,一部分數據訪問頻率低,則使用allkeys-lru。
(2)如果數據呈現平等分布,也就是所有的數據訪問頻率都相同,則使用allkeys-random。
三種數據淘汰策略:
ttl和random比較容易理解,實現也會比較簡單。主要是Lru最近最少使用淘汰策略,設計上會對key 按失效時間排序,然后取最先失效的key進行淘汰。
3.失效的內部實現
Redis 刪除失效主鍵的方法主要有兩種:
消極方法(passive way),在主鍵被訪問時如果發現它已經失效,那么就刪除它
積極方法(active way),周期性地從設置了失效時間的主鍵中選擇一部分失效的主鍵刪除
主鍵具體的失效時間全部都維護在expires這個字典表中。
typedef struct redisDb { dict *dict; //key-value dict *expires; //維護過期key dict *blocking_keys; dict *ready_keys; dict *watched_keys; int id; } redisDb;
(1)passive way 消極方法
在passive way 中, redis在實現GET、MGET、HGET、LRANGE等所有涉及到讀取數據的命令時都會調用 expireIfNeeded,它存在的意義就是在讀取數據之前先檢查一下它有沒有失效,如果失效了就刪除它。
expireIfNeeded函數中調用的另外一個函數propagateExpire,這個函數用來在正式刪除失效主鍵之前廣播這個主鍵已經失效的信息,這個信息會傳播到兩個目的地:
一個是發送到AOF文件,將刪除失效主鍵的這一操作以DEL Key的標准命令格式記錄下來;
另一個就是發送到當前Redis服務器的所有Slave,同樣將刪除失效主鍵的這一操作以DEL Key的標准命令格式告知這些Slave刪除各自的失效主鍵。從中我們可以知道,所有作為Slave來運行的Redis服務器並不需要通過消極方法來刪除失效主鍵,它們只需要執行Master的刪除指令即可。
int expireIfNeeded(redisDb *db, robj *key) { // 獲取主鍵的失效時間 long long when = getExpire(db,key); //假如失效時間為負數,說明該主鍵未設置失效時間(失效時間默認為-1),直接返回0 if (when < 0) return 0; // 假如Redis服務器正在從RDB文件中加載數據,暫時不進行失效主鍵的刪除,直接返回0 if (server.loading) return 0; // 假如當前的Redis服務器是作為Slave運行的,那么不進行失效主鍵的刪除,因為Slave // 上失效主鍵的刪除是由Master來控制的,但是這里會將主鍵的失效時間與當前時間進行 // 一下對比,以告知調用者指定的主鍵是否已經失效了 if (server.masterhost != NULL) { return mstime() > when; } //如果以上條件都不滿足,就將主鍵的失效時間與當前時間進行對比,如果發現指定的主鍵 // 還未失效就直接返回0 if (mstime() <= when) return 0; // 如果發現主鍵確實已經失效了,那么首先更新關於失效主鍵的統計個數,然后將該主鍵失 // 效的信息進行廣播,最后將該主鍵從數據庫中刪除 server.stat_expiredkeys++; propagateExpire(db,key); return dbDelete(db,key); } void propagateExpire(redisDb *db, robj *key) { robj *argv[2]; // shared.del是在Redis服務器啟動之初就已經初始化好的一個常用Redis對象,即DEL命令 argv[0] = shared.del; argv[1] = key; incrRefCount(argv[0]); incrRefCount(argv[1]); // 檢查Redis服務器是否開啟了AOF,如果開啟了就為失效主鍵記錄一條DEL日志 if (server.aof_state != REDIS_AOF_OFF) feedAppendOnlyFile(server.delCommand,db->id,argv,2); //檢查Redis服務器是否擁有Slave,如果是就向所有Slave發送DEL失效主鍵的命令,這就是 // 上面expireIfNeeded函數中發現自己是Slave時無需主動刪除失效主鍵的原因了,因為它 // 只需聽從Master發送過來的命令就OK了 if (listLength(server.slaves)) replicationFeedSlaves(server.slaves,db->id,argv,2); decrRefCount(argv[0]); decrRefCount(argv[1]); }
(2)Active Way 積極方法
消極方法的缺點是,如果key 遲遲不被訪問,就會占用很多內存空間,所以就出現了積極的方式(Active Way),
此方法利用了redis的時間事件,即每隔一段時間就中斷一下完成一些指定操作,其中就包括檢查並刪除失效主鍵。
A.時間事件
創建時間事件, 回調函數就是serverCron,它在Redis服務器啟動時創建,每秒的執行次數由宏定義REDIS_DEFAULT_HZ來指定,默認每秒鍾執行10次。
//該代碼在redis.c文件的initServer函數中。實際上,serverCron這個回調函數不僅要進行失效主鍵的檢查與刪除,還要進行統計信息的更新、客戶端連接超時的控制、BGSAVE和AOF的觸發等等,這里我們僅關注刪除失效主鍵的實現,也就是函數activeExpireCycle。 if(aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) { redisPanic("create time event failed"); exit(1); }
B.使用activeExpireCycle 清除失效key
其實現原理是從Redis中每個數據庫的expires字典表中,隨機抽樣REDIS_EXPIRELOOKUPS_PER_CRON(默認值為10)個設置了失效時間的主鍵,檢查它們是否已經失效並刪除掉失效的主鍵,如果失效主鍵個數占本次抽樣個數的比例超過25%,它會繼續進行下一輪的隨機抽樣和刪除,直到剛才的比例低於25%才停止對當前數據庫的處理,轉向下一個數據庫。
注意,activeExpireCycle函數不會試圖一次性處理Redis中的所有數據庫,而是最多只處理REDIS_DBCRON_DBS_PER_CALL(默認值為16),此外activeExpireCycle函數還有處理時間上的限制,不是想執行多久就執行多久,凡此種種都只有一個目的,那就是避免失效主鍵刪除占用過多的CPU資源。
void activeExpireCycle(void) { /*因為每次調用activeExpireCycle函數不會一次性檢查所有Redis數據庫,所以需要記錄下 每次函數調用處理的最后一個Redis數據庫的編號,這樣下次調用activeExpireCycle函數 還可以從這個數據庫開始繼續處理,這就是current_db被聲明為static的原因,而另外一 個變量timelimit_exit是為了記錄上一次調用activeExpireCycle函數的執行時間是否達 到時間限制了,所以也需要聲明為static */ static unsigned int current_db = 0; static int timelimit_exit = 0; unsigned int j, iteration = 0; /** 每次調用activeExpireCycle函數處理的Redis數據庫個數為REDIS_DBCRON_DBS_PER_CALL unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL; long long start = ustime(), timelimit; 如果當前Redis服務器中的數據庫個數小於REDIS_DBCRON_DBS_PER_CALL,則處理全部數據庫, 如果上一次調用activeExpireCycle函數的執行時間達到了時間限制,說明失效主鍵較多,也 會選擇處理全部數據庫 */ if (dbs_per_call > server.dbnum || timelimit_exit) dbs_per_call = server.dbnum; /* 執行activeExpireCycle函數的最長時間(以微秒計),其中REDIS_EXPIRELOOKUPS_TIME_PERC 是單位時間內能夠分配給activeExpireCycle函數執行的CPU時間比例,默認值為25,server.hz 即為一秒內activeExpireCycle的調用次數,所以這個計算公式更明白的寫法應該是這樣的,即 (1000000 * (REDIS_EXPIRELOOKUPS_TIME_PERC / 100)) / server.hz */ timelimit = 1000000*REDIS_EXPIRELOOKUPS_TIME_PERC/server.hz/100; timelimit_exit = 0; if (timelimit <= 0) timelimit = 1; //遍歷處理每個Redis數據庫中的失效數據 for (j = 0; j < dbs_per_call; j++) { int expired; redisDb *db = server.db+(current_db % server.dbnum); // 此處立刻就將current_db加一,這樣可以保證即使這次無法在時間限制內刪除完所有當前 // 數據庫中的失效主鍵,下一次調用activeExpireCycle一樣會從下一個數據庫開始處理, //從而保證每個數據庫都有被處理的機會 current_db++; // 開始處理當前數據庫中的失效主鍵 do { unsigned long num, slots; long long now; // 如果expires字典表大小為0,說明該數據庫中沒有設置失效時間的主鍵,直接檢查下 // 一數據庫 if ((num = dictSize(db->expires)) == 0) break; slots = dictSlots(db->expires); now = mstime(); // 如果expires字典表不為空,但是其填充率不足1%,那么隨機選擇主鍵進行檢查的代價 //會很高,所以這里直接檢查下一數據庫 if (num && slots > DICT_HT_INITIAL_SIZE && (num*100/slots < 1)) break; expired = 0; //如果expires字典表中的entry個數不足以達到抽樣個數,則選擇全部key作為抽樣樣本 if (num > REDIS_EXPIRELOOKUPS_PER_CRON) num = REDIS_EXPIRELOOKUPS_PER_CRON; while (num--) { dictEntry *de; long long t; // 隨機獲取一個設置了失效時間的主鍵,檢查其是否已經失效 if ((de = dictGetRandomKey(db->expires)) == NULL) break; t = dictGetSignedIntegerVal(de); if (now > t) { // 發現該主鍵確實已經失效,刪除該主鍵 sds key = dictGetKey(de); robj *keyobj = createStringObject(key,sdslen(key)); //同樣要在刪除前廣播該主鍵的失效信息 propagateExpire(db,keyobj); dbDelete(db,keyobj); decrRefCount(keyobj); expired++; server.stat_expiredkeys++; } } // 每進行一次抽樣刪除后對iteration加一,每16次抽樣刪除后檢查本次執行時間是否 // 已經達到時間限制,如果已達到時間限制,則記錄本次執行達到時間限制並退出 iteration++; if ((iteration & 0xf) == 0 && (ustime()-start) > timelimit) { timelimit_exit = 1; return; } //如果失效的主鍵數占抽樣數的百分比大於25%,則繼續抽樣刪除過程 } while (expired > REDIS_EXPIRELOOKUPS_PER_CRON/4); } }
4.Redis 的主鍵失效機制對系統性能的影響
Redis 會定期地檢查設置了失效時間的主鍵並刪除已經失效的主鍵,但是通過對每次處理數據庫個數的限制、activeExpireCycle 函數在一秒鍾內執行次數的限制、分配給 activeExpireCycle 函數CPU時間的限制、繼續刪除主鍵的失效主鍵數百分比的限制,Redis 已經大大降低了主鍵失效機制對系統整體性能的影響,但是如果在實際應用中出現大量主鍵在短時間內同時失效的情況還是會產生很多問題,
也就是緩存穿透的情況。
5.如何避免大量主鍵在同一時間同時失效造成數據庫壓力過大
合理的配置緩存可以增加系統的健壯性,避免緩存失效造成的事故。
1.在緩存失效后,通過加鎖或者隊列來控制讀數據庫寫緩存的線程數量。比如對某個key只允許一個線程查詢數據和寫緩存,其他線程等待。
2.可以通過緩存reload機制,預先去更新緩存.
2.不同的key,設置不同的過期時間,讓緩存失效的時間點盡量均勻。
3.做二級緩存,或者雙緩存策略。A1為原始緩存,A2為拷貝緩存,A1失效時,可以訪問A2,A1緩存失效時間設置為短期,A2設置為長期。
6.Memcached刪除失效主鍵的方法與Redis有何異同?
Memcached 在刪除失效主鍵時采用的消極方法,即 Memcached 內部不會監視主鍵是否失效,而是在通過 Get 訪問主鍵時才會檢查其是否已經失效。
其次,Memcached 與 Redis 在主鍵失效機制上的最大不同是,Memcached 不會像 Redis 那樣真正地去刪除失效的主鍵,而只是簡單地將失效主鍵占用的空間回收。
這樣當有新的數據寫入到系統中時,Memcached 會優先使用那些失效主鍵的空間。
如果失效主鍵的空間用光了,Memcached 還可以通過 LRU 機制來回收那些長期得不到訪問的空間,因此 Memcached 並不需要像 Redis 中那樣的周期性刪除操作,這也是由 Memcached 使用的內存管理機制決定的。
同時, Redis 在出現 OOM時同樣可以通過配置 maxmemory-policy 這個參數來決定是否采用 LRU 機制來回收內存空間。
參考: