一、背景概要
事情是這樣的,昨天一位朋友A在面試時,被問了一個Redis主線程和背景定期删除線程的并發問題,聊天對話大概如下
面試官 :Redis的過期删除政策有哪些?
朋友A :一共有惰性删除和定期删除兩種,定期删除是指在背景線程中定期掃描 ...
面試官 :那麼定期删除和主線程的并發問題,redis是怎麼處理的呢 ?
朋友A :emm ...
是以問題來了,在Redis的定期删除政策中,是如何進行删除的呢?以及Redis是如何解決和主線程并發的問題呢?
二、源碼分析
為了解決這個問題,決定先從源碼層面去查下。
1、定期删除的實作
databasesCron()
Key的過期定時删除操作,是在負責Redis背景任務中的databasesCron()函數執行的,如下所示。在這個代碼中可以發現,當開啟主動過期删除選項,且目前是在主節點上執行時(因為從節點會接收主節點傳遞的DEL/UNLINK指令),會調用activeExpireCycle()函數執行定期删除政策。
activeExpireCycle()
注意這個函數支援 ACTIVE_EXPIRE_CYCLE_FAST 和 ACTIVE_EXPIRE_CYCLE_SLOW 兩種模式 ACTIVE_EXPIRE_CYCLE_FAST / 在每次進入事件循環之前調用 ACTIVE_EXPIRE_CYCLE_SLOW / 在databasesCron()中調用
在這個函數中,會主動掃描已設定過期時間的鍵,并檢查它們是否已經過期,然後根據需要進行删除操作,删除操作主要在expireScanCallback()函數中執行。
c複制代碼void activeExpireCycle(int type) {
// ......
// 掃描過期鍵
do {
// 如果沒有要過期的鍵,則處理下一個資料庫
if ((num = dictSize(db->expires)) == 0) {
db->avg_ttl = 0;
break;
}
// 通過回調函數對過期鍵進行相應的處理操作
// 直到達到掃描的過期鍵數量或達到最大掃描桶數量的限制。
while (data.sampled < num && checked_buckets < max_buckets) {
db->expires_cursor = dictScan(db->expires, db->expires_cursor,
expireScanCallback, &data);
checked_buckets++;
}
} while (data.sampled == 0 ||
(data.expired * 100 / data.sampled) > config_cycle_acceptable_stale);
}
expireScanCallback()
掃到過期的鍵時,内部會通過expireScanCallback() -> activeExpireCycleTryExpire() -> deleteExpiredKeyAndPropagate()這個調用鍊完成對應的删除操作。
c複制代碼void expireScanCallback(void *privdata, const dictEntry *const_de) {
// ... ...
// privdata就是在activeExpireCycle中掃到的需要删除的Key
expireScanData *data = privdata;
// 嘗試将鍵設定為過期狀态
if (activeExpireCycleTryExpire(data->db, de, data->now)) {
data->expired++;
// 傳播DEL指令
postExecutionUnitOperations();
}
// ... ...
}
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));
// 删除過期的Key并傳播DEL指令
deleteExpiredKeyAndPropagate(db,keyobj);
return 1;
} else {
return 0;
}
}
deleteExpiredKeyAndPropagate()
在deleteExpiredKeyAndPropagate()函數内調用dbGenericDelete()函數内部執行删除操作的時候,如果開啟了lazy_free機制,會調用freeObjAsync()函數,通過bioCreateLazyFreeJob()方法将這個key的記憶體回收操作包裝為一個任務,塞進異步任務隊列,背景的lazy_free線程就會從這個異步隊列裡面取任務并執行。否則,就會在目前的線程内執行記憶體釋放操作。
void deleteExpiredKeyAndPropagate(redisDb *db, robj *keyobj) {
mstime_t expire_latency;
latencyStartMonitor(expire_latency);
// 執行Key删除操作
dbGenericDelete(db,keyobj,server.lazyfree_lazy_expire,DB_FLAG_KEY_EXPIRED);
// 在propagateDeletion函數中會傳播DEL指令(未開啟lazyfree)或UNLINK指令(開啟lazyfree)
// argv[0] = lazy ? shared.unlink : shared.del;
// argv[1] = key;
propagateDeletion(db,keyobj,server.lazyfree_lazy_expire);
}
到這裡,定期删除的删除部分已經基本結束了,可以總結出如下圖所示的内容。
定期删除的觸發
下面需要找下Redis調用定期删除的時機,也就是調用databasesCron()函數的時機。順着調用鍊會發現在serverCron()函數中會調用databasesCron()函數,可是卻沒有找到調用serverCron()函數的邏輯。
難道是沒有調用serverCron()函數的邏輯嗎 ?當然不是,我們繼續往下看。
時間事件的回調
在main入口中,會調用initServer()函數完成server初始化相關的工作,其中在initServer()函數内部會完成時間事件和檔案事件的回調注冊。
到這裡會發現,原來serverCron()函數不是直接在某個邏輯中調用的,而是被注冊為時間事件的回調函數了!
什麼是時間事件
在 Redis 中,時間事件是一種特殊類型的事件,用于執行定時任務或周期性任務,比如定期删除過期的鍵。時間事件由 Redis 事件循環(event loop)管理,它會根據設定的時間間隔周期性地觸發回調函數的執行。
簡單點說,就是Redis内部會有一種叫做事件循環的機制,它會按照一定規則定期觸發時間事件的回調,達到觸發過期Key删除的效果,下面會主要說下這部分的實作。
三、事件循環
事件循環是Redis的核心邏輯,它需要排程兩種不同的事件類型,分别是檔案事件和時間事件。
1、檔案事件
Redis通過IO多路複用技術,實作了高效的指令請求處理 :當多個用戶端通過套接字連接配接Redis時,隻有在套接字可以無阻塞地進行讀或者寫時,伺服器才會和這些用戶端進行互動。
Redis将這類因為對套接字進行多路複用而産生的事件稱為檔案事件(file event),檔案事件可以分為讀事件和寫事件兩類。
讀/寫事件的差別:
讀事件實作了指令請求的接收,寫事件實作了指令結果的傳回。
2、時間事件
時間事件記錄着那些要在指定時間點運作的事件, 多個時間事件以無序連結清單的形式儲存在伺服器狀态中。其中每個時間事件主要由三個屬性組成:
- when :以毫秒格式的 UNIX 時間戳為機關,記錄了應該在什麼時間點執行事件處理函數
- timeProc :事件處理函數
- next :指向下一個時間事件,形成連結清單。
根據 timeProc 函數的傳回值,可以将時間事件劃分為兩類:單次執行和循環執行
單次執行
如果事件處理函數 timeProc 傳回 ae.h/AE_NOMORE ,那麼這個事件為單次執行事件
該事件會在指定的時間被處理一次,之後該事件就會被删除,不再執行。
循環執行
如果事件處理函數 timeProc 傳回非 ae.h/AE_NOMORE 的整數值,那麼這個事件為循環執行事件
該事件會在指定的時間被處理,之後它會按照事件處理函數的傳回值,更新事件的 when 屬性,讓這個事件在之後的某個時間點再次運作,并以這種方式一直更新并運作下去。
3、事件排程
因為在 Redis 中既有檔案事件,又有時間事件,是以Redis的事件循環如何排程這兩個事件呢 ?
排程邏輯
在Redis的事件循環中,這兩種事件的排程是如下的合作關系
- 一種事件會等待另一種事件執行完畢之後,才開始執行,事件之間不會出現搶占
- 事件處理器先處理檔案事件(先處理指令請求),再執行時間事件(再調用 serverCron)
- 檔案事件的等待時間,由距離到達時間最短的時間事件決定。
排程延遲
基于上述排程邏輯可知,際處理時間事件的時間,通常會比時間事件所預定的時間要晚,至于延遲的時間有多長,取決于時間事件執行之前,執行檔案事件所消耗的時間。
基于已知了解,Redis内部的整個事件循環邏輯大概如下圖所示。
- 事件循環 :先讀取檔案事件,後讀取時間事件
- 單線程處理 :Redis内部整個指令處理子產品是單線程的
- 多線程處理 :除了和Redis核心指令邏輯相關的部分,其他的像網絡IO(6.0版本以上)、lazyFree等都是多線程的處理
四、回到問題
現在讓我們再次回到開始的問題 “在Redis的定期删除政策中,是如何進行删除的呢?以及Redis是如何解決和主線程并發的問題呢?”
1、如何進行删除
答 :redis的activeExpireCycle()函數掃描到過期的Key時,首先,無論是開啟了lazyFree機制,都會在資料庫字典中把資料鍵進行解綁,這樣用戶端就通路不到Key了。
然而,對于對象記憶體的真正釋放,和目前的lazyFree機制有關。
- 未開啟LazyFree :直接在目前線程中釋放對象的記憶體
- 開啟LazyFree :把釋放對象的操作放在背景任務中,由背景的LazyFree線程進行真正的記憶體釋放
也就是說,如果沒有開啟LazyFree機制,即使是定期删除政策掃描到了需要删除的Key,也會在目前主線程中執行,也會有主線程阻塞的風險。
2、定時過期政策與主線程的并發問題
答 :不存在并發問題。因為Redis是基于Reactor的事件循環模型,主線程的所有操作都來自檔案事件或時間事件。Redis的事件循環的排程政策決定了,每次都會按順序先執行檔案事件,然後再執行時間事件,這兩個操作不會競争執行,是以不存在并發問題。
五、總結
本文主要通過定期删除政策開始切入,由點到面的介紹了Redis的事件循環、事件模型、主線程它們之間的關系,解答了定期删除政策和主線程并發問題的疑問。
作者:Wgrape
連結:https://juejin.cn/post/7243725455736406076
來源:稀土掘金