淺析Redis

b1uesk9發表於2024-10-22

淺析Redis

什麼是Redis

Redis本質上是一個Key-Value型別的記憶體資料庫,整個資料庫載入在記憶體當中操作,定期透過非同步操作把資料庫中的資料flush到硬碟上進行儲存。

因為是純記憶體操作,Redis的效能非常出色,每秒可以處理超過 10萬次讀寫操作,是已知效能最快的Key-Value 資料庫。

Redis底層

Redis的底層請見 https://www.bozhu12.cc/backend/redis2/#_1-前言 這篇文章 講的非常詳細

Redis的執行緒模型

redis 內部使用檔案事件處理器 file event handler,它是單執行緒的,所以redis才叫做單執行緒模型。它採用IO多路複用機制同時監聽多個 socket,將產生事件的 socket 壓入記憶體佇列中,事件分派器根據 socket 上的事件型別來選擇對應的事件處理器進行處理。

檔案事件處理器的結構:

  • 多個 socket
  • IO 多路複用程式
  • 檔案事件分派器
  • 事件處理器(連線應答處理器、命令請求處理器、命令回覆處理器)

1725246285647

1725248167657

  1. Redis 啟動初始化的時候,Redis 會將連線應答處理器與 AE_READABLE 事件關聯起來。
  2. 如果一個客戶端跟 Redis 發起連線,此時 Redis 會產生一個 AE_READABLE 事件,由於開始之初 AE_READABLE 是與連線應答處理器關聯,所以由連線應答處理器來處理該事件,這時連線應答處理器會與客戶端建立連線,建立客戶端響應的 socket,同時將這個 socket 的 AE_READABLE 事件與命令請求處理器關聯起來。
  3. 如果這個時間客戶端向 Redis 傳送一個命令(set k1 v1),這時 socket 會產生一個 AE_READABLE 事件,IO 多路複用程式會將該事件壓入佇列中,此時事件分派器從佇列中取得該事件,由於該 socket 的 AE_READABLE 事件已經和命令請求處理器關聯了,因此事件分派器會將該事件交給命令請求處理器處理,命令請求處理器讀取事件中的命令並完成。操作完成後,Redis 會將該 socket 的 AE_WRITABLE 事件與命令回覆處理器關聯。
  4. 如果客戶端已經準備好接受資料後,Redis 中的該 socket 會產生一個 AE_WRITABLE 事件,同樣會壓入佇列然後被事件派發器取出交給相對應的命令回覆處理器,由該命令回覆處理器將準備好的響應資料寫入 socket 中,供客戶端讀取。
  5. 命令回覆處理器寫完後,就會刪除該 socket 的 AE_WRITABLE 事件與命令回覆處理器的關聯關係。

單執行緒處理流程

  1. 主執行緒處理網路 I/O 和命令執行:
    • 在單執行緒模式下,Redis 的主執行緒既負責從客戶端讀取請求,也負責執行命令和傳送響應。所有的工作都是按照請求的順序,依次完成。
    • 主執行緒會輪詢所有的客戶端連線,一個一個地處理請求。
  2. 處理客戶端 A 的請求:
    • 主執行緒首先從客戶端 A 讀取 SET key1 value1 請求。
    • 讀取完成後,主執行緒立即解析並執行該命令,將 key1 設定為 value1
    • 然後,主執行緒將 OK 結果傳送回客戶端 A。
  3. 處理客戶端 B 的請求:
    • 接下來,主執行緒從客戶端 B 讀取 GET key1 請求。
    • 讀取完成後,主執行緒解析並執行該命令,查詢 key1 的值,得到 value1
    • 主執行緒將結果 value1 返回給客戶端 B。
  4. 處理客戶端 C 的請求:
    • 最後,主執行緒從客戶端 C 讀取 SET key2 value2 請求。
    • 主執行緒解析並執行該命令,將 key2 設定為 value2
    • 然後將 OK 結果返回給客戶端 C。

具體步驟解釋

  • 步驟 1:網路 I/O 和命令執行的順序處理
    • Redis 依次輪詢客戶端 A、B、C 的連線,並從中讀取請求資料。在主執行緒中,網路 I/O 和命令執行都是同步完成的,意味著 Redis 會處理完一個客戶端的所有操作,才會繼續處理下一個客戶端的請求。
  • 步驟 2:命令解析與執行
    • 當主執行緒讀取了一個完整的命令後,它會立即解析命令並執行。例如,主執行緒從客戶端 A 讀取 SET key1 value1 後,立即將 key1 設定為 value1,並返回 OK
  • 步驟 3:響應回寫
    • 主執行緒執行完命令後,會立刻將響應結果傳送回客戶端。例如,客戶端 B 請求 GET key1,主執行緒查詢後,立即將查詢結果 value1 傳送給客戶端 B。

多執行緒機制

1725248255687

客戶端請求示例

假設有 3 個客戶端同時向 Redis 傳送請求:

  1. 客戶端 A 傳送 SET key1 value1
  2. 客戶端 B 傳送 GET key1
  3. 客戶端 C 傳送 SET key2 value2

多執行緒 I/O 處理流程

  1. 網路 I/O 階段:
    • Redis 的 4 個 I/O 執行緒開始工作,每個執行緒負責從不同客戶端接收資料。例如:
      • I/O 執行緒 1 從客戶端 A 讀取 SET key1 value1 的請求。
      • I/O 執行緒 2 從客戶端 B 讀取 GET key1 的請求。
      • I/O 執行緒 3 從客戶端 C 讀取 SET key2 value2 的請求。
  2. 主執行緒命令解析與執行:
    • 一旦 I/O 執行緒從客戶端接收到完整的請求資料後,它們會將資料傳遞給 Redis 的主執行緒。
    • 主執行緒負責解析命令並執行它們:
      • 首先,主執行緒處理 SET key1 value1,將 key1 設定為 value1
      • 然後,主執行緒處理 GET key1,讀取並返回 key1 的值(value1)。
      • 最後,主執行緒處理 SET key2 value2,將 key2 設定為 value2
  3. 網路響應階段:
    • 命令執行完成後,主執行緒將結果傳遞迴 I/O 執行緒:
      • I/O 執行緒 1 將 OK 響應返回給客戶端 A。
      • I/O 執行緒 2 將 value1 返回給客戶端 B。
      • I/O 執行緒 3 將 OK 返回給客戶端 C。

記憶體淘汰底層原理

1. 淘汰過程

Redis 記憶體淘汰執行流程如下:

1.每次當 Redis 執行命令時,若設定了最大記憶體大小 maxmemory,並設定了淘汰策略式,則會嘗試進行一次 Key 淘汰;

2.Redis 首先會評估已使用記憶體(這裡不包含主從複製使用的兩個緩衝區佔用的記憶體)是否大於 maxmemory,如果沒有則直接返回,否則將計算當前需要釋放多少記憶體,隨後開始根據策略淘汰符合條件的 Key;當開始進行淘汰時,將會依次對每個資料庫進行抽樣,抽樣的資料範圍由策略決定,而樣本數量則由 maxmemory-samples配置決定;

3.完成抽樣後,Redis 會嘗試將樣本放入提前初始化好 EvictionPoolLRU 陣列中,它相當於一個臨時緩衝區,當陣列填滿以後即將裡面全部的 Key 進行刪除。

4.若一次刪除後記憶體仍然不足,則再次重複上一步驟,將樣本中的剩餘 Key 再次填入陣列中進行刪除,直到釋放了足夠的記憶體,或者本次抽樣的所有 Key 都被刪除完畢(如果此時記憶體還是不足,那麼就重新執行一次淘汰流程)。

在抽樣這一步,涉及到從字典中隨機抽樣這個過程,由於雜湊表的 Key 是雜湊分佈的,因此會有很多桶都是空的,純隨機效率可能會很低。因此,Redis 採用了一個特別的做法,那就是先連續遍歷數個桶,如果都是空的,再隨機調到另一個位置,再連續遍歷幾個桶……如此迴圈,直到結束抽樣。

你可以參照原始碼理解這個過程:

unsigned int dictGetSomeKeys(dict *d, dictEntry **des, unsigned int count) {
    unsigned long j; /* internal hash table id, 0 or 1. */
    unsigned long tables; /* 1 or 2 tables? */
    unsigned long stored = 0, maxsizemask;
    unsigned long maxsteps;
​
    if (dictSize(d) < count) count = dictSize(d);
    maxsteps = count*10;
​
    // 如果字典正在遷移,則協助遷移
    for (j = 0; j < count; j++) {
        if (dictIsRehashing(d))
            _dictRehashStep(d);
        else
            break;
    }
​
    tables = dictIsRehashing(d) ? 2 : 1;
    maxsizemask = d->ht[0].sizemask;
    if (tables > 1 && maxsizemask < d->ht[1].sizemask)
        maxsizemask = d->ht[1].sizemask;
​
    unsigned long i = random() & maxsizemask;
    unsigned long emptylen = 0;
​
    // 當已經採集到足夠的樣本,或者重試已達上限則結束取樣
    while(stored < count && maxsteps--) {
        for (j = 0; j < tables; j++) {
            if (tables == 2 && j == 0 && i < (unsigned long) d->rehashidx) {
                if (i >= d->ht[1].size)
                    i = d->rehashidx;
                else
                    continue;
            }
​
            // 如果一個庫的到期字典已經處理完畢,則處理下一個庫
            if (i >= d->ht[j].size) continue;
            dictEntry *he = d->ht[j].table[i];
​
            // 連續遍歷多個桶,如果多個桶都是空的,那麼隨機跳到另一個位置,然後再重複此步驟           
            if (he == NULL) {
                emptylen++;
                if (emptylen >= 5 && emptylen > count) {
                    i = random() & maxsizemask;
                    emptylen = 0;
                }
            } else {
                emptylen = 0;
                while (he) {
                    *des = he;
                    des++;
                    he = he->next;
                    stored++;
                    if (stored == count) return stored;
                }
            }
        }
​
        // 查詢下一個桶
        i = (i+1) & maxsizemask;
    }
    return stored;
}

2. LRU 實現

LRU 的全稱為 Least Recently Used,也就是最近最少使用。一般來說,LRU 會從一批 Key 中淘汰上次訪問時間最早的 key。

它是一種非常常見的快取回收演算法,在諸如 Guava Cache、Caffeine等快取庫中都提供了類似的實現。我們自己也可以基於 JDK 的 LinkedHashMap 實現支援 LRU 演算法的快取功能。
2.1 近似 LRU
傳統的 LRU 演算法實現通常會維護一個連結串列,當訪問過某個節點後就將該節點移至連結串列頭部。如此反覆後,連結串列的節點就會按最近一次訪問時間排序。當快取數量到達上限後,我們直接移除尾節點,即可移除最近最少訪問的快取。

不過,對於 Redis 來說,如果每個 Key 新增的時候都需要額外的維護並操作這樣一條連結串列,要額外付出的代價顯然是不可接受的,因此 Redis 中的 LRU 是近似 LRU(NearlyLRU)。

當每次訪問 Key 時,Redis 會在結構體中記錄本次訪問時間,而當需要淘汰 Key 時,將會從全部資料中進行抽樣,然後再移除樣本中上次訪問時間最早的 key。

它的特點是:

  • 僅當需要時再抽樣,因而不需要維護全量資料組成的連結串列,這避免了額外記憶體消耗。

  • 訪問時僅在結構體上記錄操作時間,而不需要操作連結串列節點,這避免了額外的效能消耗。

當然,有利就有弊,這種實現方式也決定 Redis 的 LRU 是並不是百分百準確的,被淘汰的 Key 未必真的就是所有 Key 中最後一次訪問時間最早的。


2.2 抽樣大小
根據上述的內容,我們不難理解,當抽樣的數量越大,LRU 淘汰 Key 就越準確,相對的開銷也更大。因此,Redis 允許我們透過 maxmemory-samples 配置取樣數量(預設為 5),從而在效能和精度上取得平衡。

3. LFU 實現

LFU 全稱為 Least Frequently Used ,也就是最近最不常用。它的特點如下:

  • 同樣是基於抽樣實現的近似演算法,maxmemory-samples 對其同樣有效。

  • 比較的不是最後一次訪問時間,而是資料的訪問頻率。當淘汰的時候,優先淘汰範圍頻率最低 Key。

它的實現與 LRU 基本一致,但是在計數部分則有所改進。

3.1 機率計數器
在 Redis 用來儲存資料的結構體 redisObj 中,有一個 24 位的 lru數值欄位:

  • 當使用 LRU 演算法時,它用於記錄最後一次訪問時間的時間戳。

  • 當使用 LFU 演算法時,它被分為兩部分,高 16 位關於記錄最近一次訪問時間(Last Decrement Time),而低 8 位作為記錄訪問頻率計數器(Logistic Counter)。

LFU 的核心就在於低 8 位表示的訪問頻率計數器(下面我們簡稱為 counter),是一個介於 0 ~ 255 的特殊數值,它會每次訪問 Key 時,基於時間衰減和機率遞增機制動態改變。

| 這種基於機率,使用極小記憶體對大量事件進行計數的計數器被稱為莫里斯計數器,它是一種機率計數法的實現。

3.2 時間衰減
每當訪問 Key 時,根據當前實際與該 Key 的最後一次訪問時間的時間差對 counter 進行衰減。

衰減值取決於 lfu_decay_time 配置,該配置表示一個衰減週期。我們可以簡單的認為,每當時間間隔滿足一個衰減週期時,就會對 counter 減一。

比如,我們設定 lfu_decay_time為 1 分鐘,那麼如果 Key 最後一次訪問距離現在已有 3 分 30 秒,那麼 counter 就需要減 3。

3.3 機率遞增
在完成衰減後,Redis 將根據 lfu_log_factor 配置對應機率值對 counter 進行遞增。

這裡直接放上原始碼:

/* Logarithmically increment a counter. The greater is the current counter value
 * the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
    // 若已達最大值 255,直接返回
    if (counter == 255) return 255;
    // 獲取一個介於 0 到 1 之間的隨機值
    double r = (double)rand()/RAND_MAX;
    // 根據當前 counter 減去初始值得到 baseval
    double baseval = counter - LFU_INIT_VAL; 
    if (baseval < 0) baseval = 0;
    // 使用 baseval*server.lfu_log_factor+1 得到一個機率值 p
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    // 當 r < p 時,遞增 counter
    if (r < p) counter++;
    return counter;
}

簡而言之,直接從程式碼上理解,我們可以認為 counter和 lfu_log_factor 越大,則遞增的機率越小:

當然,實際上也要考慮到訪問次數對其的影響,Redis 官方給出了相關資料:

3.4 計數器的初始值
為了防止新的 Key 由於 counter 為 0 導致直接被淘汰,Redis 會預設將 counter設定為 5。

3.5 抽樣大小的選擇
值得注意的是,當資料量比較大的時候,如果抽樣大小設定的過小,因為一次抽樣的樣本數量有限,冷熱資料因為時間衰減導致的權重差異將會變得不明顯,此時 LFU 演算法的優勢就難以體現,即使的相對較熱的資料也有可能被頻繁“誤傷”。

所以,如果你選擇了 LFU 演算法作為淘汰策略,並且同時又具備比較大的資料量,那麼不妨將抽樣大小也設定的大一些。