天天看點

Redis的定期删除與主線程讀寫有并發問題嗎?

作者:網際網路進階架構師

一、背景概要

事情是這樣的,昨天一位朋友A在面試時,被問了一個Redis主線程和背景定期删除線程的并發問題,聊天對話大概如下

面試官 :Redis的過期删除政策有哪些?

朋友A :一共有惰性删除和定期删除兩種,定期删除是指在背景線程中定期掃描 ...

面試官 :那麼定期删除和主線程的并發問題,redis是怎麼處理的呢 ?

朋友A :emm ...

是以問題來了,在Redis的定期删除政策中,是如何進行删除的呢?以及Redis是如何解決和主線程并發的問題呢?

二、源碼分析

為了解決這個問題,決定先從源碼層面去查下。

1、定期删除的實作

databasesCron()

Key的過期定時删除操作,是在負責Redis背景任務中的databasesCron()函數執行的,如下所示。在這個代碼中可以發現,當開啟主動過期删除選項,且目前是在主節點上執行時(因為從節點會接收主節點傳遞的DEL/UNLINK指令),會調用activeExpireCycle()函數執行定期删除政策。

Redis的定期删除與主線程讀寫有并發問題嗎?

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的定期删除與主線程讀寫有并發問題嗎?

定期删除的觸發

下面需要找下Redis調用定期删除的時機,也就是調用databasesCron()函數的時機。順着調用鍊會發現在serverCron()函數中會調用databasesCron()函數,可是卻沒有找到調用serverCron()函數的邏輯。

難道是沒有調用serverCron()函數的邏輯嗎 ?當然不是,我們繼續往下看。

時間事件的回調

在main入口中,會調用initServer()函數完成server初始化相關的工作,其中在initServer()函數内部會完成時間事件和檔案事件的回調注冊。

到這裡會發現,原來serverCron()函數不是直接在某個邏輯中調用的,而是被注冊為時間事件的回調函數了!

Redis的定期删除與主線程讀寫有并發問題嗎?

什麼是時間事件

在 Redis 中,時間事件是一種特殊類型的事件,用于執行定時任務或周期性任務,比如定期删除過期的鍵。時間事件由 Redis 事件循環(event loop)管理,它會根據設定的時間間隔周期性地觸發回調函數的執行。

簡單點說,就是Redis内部會有一種叫做事件循環的機制,它會按照一定規則定期觸發時間事件的回調,達到觸發過期Key删除的效果,下面會主要說下這部分的實作。

Redis的定期删除與主線程讀寫有并發問題嗎?

三、事件循環

事件循環是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的事件循環中,這兩種事件的排程是如下的合作關系

  1. 一種事件會等待另一種事件執行完畢之後,才開始執行,事件之間不會出現搶占
  2. 事件處理器先處理檔案事件(先處理指令請求),再執行時間事件(再調用 serverCron)
  3. 檔案事件的等待時間,由距離到達時間最短的時間事件決定。

排程延遲

基于上述排程邏輯可知,際處理時間事件的時間,通常會比時間事件所預定的時間要晚,至于延遲的時間有多長,取決于時間事件執行之前,執行檔案事件所消耗的時間。

基于已知了解,Redis内部的整個事件循環邏輯大概如下圖所示。

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

來源:稀土掘金

繼續閱讀