Redis過期鍵的刪除策略
對於過期鍵一般有三種刪除策略
- 定時刪除:在設置鍵的過期時間的同時,創建一個定時器(timer),讓定時器在鍵的過期時間來臨時,立即執行對鍵的刪除操作;
- 惰性刪除:放任鍵過期不管,但是每次從鍵空間中獲取鍵時,都檢查取得的鍵是否過期,如果過期的話,就刪除該鍵;如果沒有過期,那就返回該鍵;
- 定期刪除:每隔一段時間,程序就對數據庫進行一次檢查,刪除里面的過期鍵。至於刪除多少過期鍵,以及要檢查多少個數據庫,則由算法決定。
下面我們來看看三種策略的優缺比較:
- 定時刪除策略對內存是最友好的:通過使用定時器,定時刪除策略可以保證過期鍵會盡可能快地被刪除,並釋放過期鍵所占用的內存;但另一方面,定時刪除策略的缺點是,他對CPU是最不友好的:在過期鍵比較多的情況下,刪除過期鍵這一行為可能會占用相當一部分CPU時間,在內存不緊張但是CPU時間非常緊張的情況下,將CPU時間用在刪除和當前任務無關的過期鍵上,無疑會對服務器的響應時間和吞吐量造成影響;
- 惰性刪除策略對CPU時間來說是最友好的:程序只會在取出鍵時才對鍵進行過期檢查,這可以保證刪除過期鍵的操作只會在非做不可的情況下進行;惰性刪除策略的缺點是,它對內存是最不友好的:如果一個鍵已經過期,而這個鍵又仍然保留在數據庫中,那么只要這個過期鍵不被刪除,它所占用的內存就不會釋放;
- 定時刪除占用太多CPU時間,影響服務器的響應時間和吞吐量;惰性刪除浪費太多內存,有內存泄漏的危險。定期刪除策略是前兩種策略的一種整合和折中:
- 定期刪除策略每隔一段時間執行一次刪除過期鍵操作,並通過限制刪除操作執行的時長和頻率來減少刪除操作對CPU時間的影響;
- 通過定期刪除過期鍵,定期刪除策略有效地減少了因為過期鍵而帶來的內存浪費;
- 定期刪除策略的難點是確定刪除操作執行的時長和頻率。
Redis的過期鍵刪除策略:Redis服務器實際使用的是惰性刪除和定期刪除兩種策略。
下面我們就結合源碼進行分析:
惰性刪除策略的實現
過期鍵的惰性刪除策略由db.c/expireIfNeeded函數實現,所有讀寫數據庫的Redis命令在執行之前都會調用expireIfNeeded函數對輸入鍵進行檢查:
下面結合代碼,用偽代碼簡要描述:/* * 檢查 key 是否已經過期,如果是的話,將它從數據庫中刪除。 * * 返回 0 表示鍵沒有過期時間,或者鍵未過期。 *
* 返回 1 表示鍵已經因為過期而被刪除了。 */ int expireIfNeeded(redisDb *db, robj *key) { // getExpire(db,key)函數取出鍵key的過期時間,如果key沒有設置過期時間那么返回-1 mstime_t when = getExpire(db,key); mstime_t now; if (when < 0) return 0; /* No expire for this key key沒有設置過期時間*/ /* Don't expire anything while loading. It will be done later. */ // 如果服務器正在進行載入,那么過會兒再執行 if (server.loading) return 0; /* If we are in the context of a Lua script, we claim that time is * blocked to when the Lua script started. This way a key can expire * only the first time it is accessed and not in the middle of the * script execution, making propagation to slaves / AOF consistent. * See issue #1525 on Github for more information. */ //如果我們是正在執行lua腳本,那么必須先將腳本進行阻塞。lua部分知識還沒學,所以這里並不是很懂為什么???? now = server.lua_caller ? server.lua_time_start : mstime(); /* If we are running in the context of a slave, return ASAP: * the slave key expiration is controlled by the master that will * send us synthesized DEL operations for expired keys. * * Still we try to return the right information to the caller, * that is, 0 if we think the key should be still valid, 1 if * we think the key is expired at this time. */ // 附屬節點並不主動刪除 key,它只返回一個邏輯上正確的返回值 // 真正的刪除操作要等待主節點發來刪除命令時才執行,從而保證數據的同步 //這部分知識可以查看redis的主從同步 if (server.masterhost != NULL) return now > when; // 運行到這里,表示鍵帶有過期時間,並且服務器為主節點 /* Return when this key has not expired */ // 如果未過期,返回 0 if (now <= when) return 0; /* 已過期的鍵的數量 */ server.stat_expiredkeys++; // 向 AOF 文件和附屬節點傳播過期信息.當key過期時,DEL 操作也會傳遞給所有的AOF文件和附屬節點 propagateExpire(db,key); // 發送事件通知,關於redis的鍵事件通知和鍵空間通知,可以查詢資料后面學習硬挨也會講到 notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED, "expired",key,db->id); // 調用dbDelete(db,keu)將過期鍵從數據庫中刪除 return dbDelete(db,key); }
流程圖:
定期刪除策略的實現
過期鍵的定期刪除策略由redis.c/activeExpireCycle函數實現,每當Redis的服務器周期性操作redis.c/serverCron函數執行時,activeExpireCycle函數就會被調用,它在規定的時間內,分多次遍歷服務器中的各個數據庫,從數據庫的expires字典中隨機檢查一部分鍵的過期時間,並刪除其中的過期鍵。
源碼分析如下:
/* Try to expire a few timed out keys. The algorithm used is adaptive and * will use few CPU cycles if there are few expiring keys, otherwise * it will get more aggressive to avoid that too much memory is used by * keys that can be removed from the keyspace. * * 函數嘗試刪除數據庫中已經過期的鍵。 * 當帶有過期時間的鍵比較少時,函數運行得比較保守, * 如果帶有過期時間的鍵比較多,那么函數會以更積極的方式來刪除過期鍵, * 從而可能地釋放被過期鍵占用的內存。 * * No more than REDIS_DBCRON_DBS_PER_CALL databases are tested at every * iteration. * * 每次循環中被測試的數據庫數目不會超過 REDIS_DBCRON_DBS_PER_CALL 。 * * This kind of call is used when Redis detects that timelimit_exit is * true, so there is more work to do, and we do it more incrementally from * the beforeSleep() function of the event loop. * * 如果 timelimit_exit 為真,那么說明還有更多刪除工作要做,(在我看來timelimit_exit如果為真的話那表示上一次刪除過期鍵時是因為刪除時間過長超時了才退出的,所以這次將刪除方法更加積極) * 那么在 beforeSleep() 函數調用時,程序會再次執行這個函數。 * * Expire cycle type: * * 過期循環的類型: * * If type is ACTIVE_EXPIRE_CYCLE_FAST the function will try to run a * "fast" expire cycle that takes no longer than EXPIRE_FAST_CYCLE_DURATION * microseconds, and is not repeated again before the same amount of time. * * 如果循環的類型為 ACTIVE_EXPIRE_CYCLE_FAST , * 那么函數會以“快速過期”模式執行, * 執行的時間不會長過 EXPIRE_FAST_CYCLE_DURATION 毫秒, * 並且在 EXPIRE_FAST_CYCLE_DURATION 毫秒之內不會再重新執行。 * * If type is ACTIVE_EXPIRE_CYCLE_SLOW, that normal expire cycle is * executed, where the time limit is a percentage of the REDIS_HZ period * as specified by the REDIS_EXPIRELOOKUPS_TIME_PERC define. * * 如果循環的類型為 ACTIVE_EXPIRE_CYCLE_SLOW , * 那么函數會以“正常過期”模式執行, * 函數的執行時限為 REDIS_HS 常量的一個百分比, * 這個百分比由 REDIS_EXPIRELOOKUPS_TIME_PERC 定義。 */ void activeExpireCycle(int type) { /* This function has some global state in order to continue the work * incrementally across calls. */ // 共享變量,用來累積函數連續執行時的數據 static unsigned int current_db = 0; /* Last DB tested. 正在測試的數據庫*/ static int timelimit_exit = 0; /* Time limit hit in previous call 上一次執行是否時間超時的提示 */ static long long last_fast_cycle = 0; /* When last fast cycle ran. 上次快速模式執行的時間*/ unsigned int j, iteration = 0; // 默認每次處理的數據庫數量 unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL; //默認REDIS_DBCRON_DBS_PER_CALL=16 // 函數開始的時間 long long start = ustime(), timelimit; // 快速模式 if (type == ACTIVE_EXPIRE_CYCLE_FAST) { /* Don't start a fast cycle if the previous cycle did not exited * for time limt. Also don't repeat a fast cycle for the same period * as the fast cycle total duration itself. */ // 如果上次函數沒有觸發 timelimit_exit ,那么不執行處理 if (!timelimit_exit) return; // 如果距離上次執行未夠一定時間,那么不執行處理 if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return; // 運行到這里,說明執行快速處理,記錄當前時間 last_fast_cycle = start; } /* We usually should test REDIS_DBCRON_DBS_PER_CALL per iteration, with * two exceptions: * * 一般情況下,每次迭代(也就是每次調用這個函數)函數只處理 REDIS_DBCRON_DBS_PER_CALL 個數據庫, * 除非: * * 1) Don't test more DBs than we have. * 當前數據庫的數量小於 REDIS_DBCRON_DBS_PER_CALL * 2) If last time we hit the time limit, we want to scan all DBs * in this iteration, as there is work to do in some DB and we don't want * expired keys to use memory for too much time. * 如果上次處理遇到了時間上限,那么這次需要對所有數據庫進行掃描, * 這可以避免過多的過期鍵占用空間 */ if (dbs_per_call > server.dbnum || timelimit_exit)//以服務器的數據庫數量為准 dbs_per_call = server.dbnum; /* 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. */ // 函數處理的微秒時間上限 // ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默認為 25 ,也即是 25 % 的 CPU 時間 timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100; timelimit_exit = 0; if (timelimit <= 0) timelimit = 1; // 如果是運行在快速模式之下 // 那么最多只能運行 FAST_DURATION 微秒 // 默認值為 1000 (微秒) if (type == ACTIVE_EXPIRE_CYCLE_FAST) timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */ // 遍歷數據庫 for (j = 0; j < dbs_per_call; j++) { int expired; // 指向要處理的數據庫 redisDb *db = server.db+(current_db % server.dbnum); /* Increment the DB now so we are sure if we run out of time * in the current DB we'll restart from the next. This allows to * distribute the time evenly across DBs. */ // 為 currrnt_DB 計數器加一,如果進入 do 循環之后因為超時而跳出 // 那么下次會直接從下個 currrnt_DB 開始處理。這樣使得分配在每個數據庫上處理時間比較平均 current_db++; /* Continue to expire if at the end of the cycle more than 25% * of the keys were expired. */ //如果每次循環清理的過期鍵是過期鍵的25%以上,那么就繼續清理 do { unsigned long num, slots; long long now, ttl_sum; int ttl_samples; /* If there is nothing to expire try next DB ASAP. */ // 獲取數據庫中帶過期時間的鍵的數量 // 如果該數量為 0 ,直接跳過這個數據庫 if ((num = dictSize(db->expires)) == 0) { db->avg_ttl = 0; break; } // 獲取數據庫中鍵值對的數量 slots = dictSlots(db->expires); // 當前時間 now = mstime(); /* When there are less than 1% filled slots getting random * keys is expensive, so stop here waiting for better times... * The dictionary will be resized asap. */ // 這個數據庫的使用率低於 1% ,掃描起來太費力了(大部分都會 MISS) // 跳過,等待字典收縮程序運行 if (num && slots > DICT_HT_INITIAL_SIZE && (num*100/slots < 1)) break; /* The main collection cycle. Sample random keys among keys * with an expire set, checking for expired ones. * * 樣本計數器 */ // 已處理過期鍵計數器 expired = 0; // 鍵的總 TTL 計數器 ttl_sum = 0; // 總共處理的鍵計數器 ttl_samples = 0; // 每次最多只能檢查 LOOKUPS_PER_LOOP 個鍵,默認是20 if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP) num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP; // 開始遍歷數據庫 while (num--) { dictEntry *de; long long ttl; // 從 expires 中隨機取出一個帶過期時間的鍵 if ((de = dictGetRandomKey(db->expires)) == NULL) break; // 計算 TTL ttl = dictGetSignedIntegerVal(de)-now; // 如果鍵已經過期,那么刪除它,並將 expired 計數器增一 if (activeExpireCycleTryExpire(db,de,now)) expired++; if (ttl < 0) ttl = 0; // 累積鍵的 TTL ttl_sum += ttl; // 累積處理鍵的個數 ttl_samples++; } /* Update the average TTL stats for this database. */ // 為這個數據庫更新平均 TTL 統計數據 if (ttl_samples) { // 計算當前平均值 long long avg_ttl = ttl_sum/ttl_samples; // 如果這是第一次設置數據庫平均 TTL ,那么進行初始化 if (db->avg_ttl == 0) db->avg_ttl = avg_ttl; /* Smooth the value averaging with the previous one. */ // 否則取數據庫的上次平均 TTL 和今次平均 TTL 的平均值 db->avg_ttl = (db->avg_ttl+avg_ttl)/2; } /* We can't block forever here even if there are many keys to * expire. So after a given amount of milliseconds return to the * caller waiting for the other active expire cycle. */ // 如果過期鍵太多的話,我們不能用太長時間處理,所以這個函數執行一定時間之后就要返回,等待下一次循環 // 更新遍歷次數 iteration++; // 每遍歷 16 次執行一次 if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */ (ustime()-start) > timelimit) { // 如果遍歷次數正好是 16 的倍數 // 並且遍歷的時間超過了 timelimit,超時了 // 那么將timelimit_exit賦值為1,下一個if返回吧 timelimit_exit = 1; } // 已經超時了,返回 if (timelimit_exit) return; /* We don't repeat the cycle if there are less than 25% of keys * found expired in the current DB. */ // 如果刪除的過期鍵少於當前數據庫中過期鍵數量的 25 %,那么不再遍歷。當然如果超過了25%,那說明過期鍵還很多,繼續清理 } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4); } }
上面代碼注釋比較詳細,所以這里就不給流程圖了。