Redis系列(六)底層數據結構之字典

前言

Redis 已經是大家耳熟能詳的東西了,日常工作也都在使用,面試中也是高頻的會涉及到,那麼我們對它究竟瞭解有多深刻呢?

我讀了幾本 Redis 相關的書籍,嘗試去了解它的具體實現,將一些底層的數據結構及實現原理記錄下來。

本文將介紹 Redis 中底層的 dict(字典) 的實現方法。 它是 Redis 中哈希鍵和有序集合鍵的底層實現之一。

2020-01-06-17-50-12

可以看到圖中,當我給一個 哈希結構中放了兩個短的值,此時 哈希的編碼方式是 ziplist, 而當我插入一個比較長的值,哈希的編碼方式成爲了 hashtable.

注:本文默認讀者對於 hashtable 這一數據結構有基本的瞭解,因此不會詳細講解這塊內容

定義

字典

字典作爲一種常用的數據結構,也被內置在很多編程語言中,比如 Java 的 HashMap 和 Python 的 dict. 然而 C 語言又沒有(知道爲什麼大家更喜歡寫 Java,Python 等高級語言了吧).

所以 Redis 自己實現了一個字典:

typedef struct dict{
  // 類型特定函數
  dictType *type;
  // 私有數據
  void *private;
  // 哈希表
  dictht ht[2];
  // rehash 索引,噹噹前的字典不在 rehash 時,值爲-1
  int trehashidx;
}
  • type 和 private

這兩個屬性是爲了實現字典多態而設置的,當字典中存放着不同類型的值,對應的一些複製,比較函數也不一樣,這兩個屬性配合起來可以實現多態的方法調用。

  • ht[2]

這是一個長度爲 2 的 dictht結構的數組,dictht就是哈希表。

  • trehashidx

這是一個輔助變量,用於記錄 rehash 過程的進度,以及是否正在進行 rehash 等信息。

看完字段介紹,我們發現,字典這個數據結構,本質上是對 hashtable的一個簡單封裝,因此字典的實現細節主要就來到了 哈希表上。

哈希表

哈希表的定義如下:

typedef struct dictht{
  // 哈希表的數組
  dictEntry **table;
  // 哈希表的大小
  unsigned long size;
  // 哈希表的大小的掩碼,用於計算索引值,總是等於 size-1
  unsigned long sizemasky;
  // 哈希表中已有的節點數量
  unsigned long used;
}

其中哈希表中的節點的定義如下:

typedef struct dictEntry{
  // 鍵
  void *key;
  // 值
  union {
    void *val;
    uint64_tu64;
    int64_ts64;
  }v;

  // 指向下一個節點的指針
  struct dictEntry *next;
} dictEntry;

如果你看過 Java 中 HashMap 的源碼,你會發現這一切是如此的熟悉。因此我不對其中的每個屬性進行詳細的解釋了。

2020-01-06-18-22-43

上圖是一個沒有處在 rehash 狀態下的字典。可以看到,字典持有兩張哈希表,其中一個的值爲 null, 另外一個哈希表的 size=4, 其中兩個位置上已經存放了具體的鍵值對,而且沒有發生 hash 衝突。

哈希算法

哈希表添加一個元素首先需要計算當前鍵值的 hash 值,之後根據 hash 值來定位即將它即將被放入的槽。由於 hash 值可能衝突,因此 hash 算法的選擇尤其重要,要將 key 值打散的足夠均勻。

Redis 選用了業內的一些算法來實現 hash 過程。

在 Redis 5.0 以及 4.0 版本,都使用了 siphash 哈希算法。siphash 可以在輸入的 key 值很小的情況下,產生隨機性比較好的輸出。

在 Redis 3.2, 3.0 以及 2.8 版本,使用 Murmurhash2 哈希算法,Murmurhash 可以在輸入值是有規律時,也能給出比較好的隨機分佈。

當然以上兩個算法,都有一個共同點,就是計算性能很好,這才符合 Redis 的產品特性。

hash 結束之後,會根據當前哈希表的長度,來確定當前鍵值所在的 index, 而由於長度有限,那麼遲早會產生兩個鍵值要放到同一個位置的問題,也就是常說的 hash 衝突問題。

哈希衝突

既然是哈希表,那麼就也有 hash 衝突問題。

Redis 的哈希表處理 Hash 衝突的方式和 Java 中的 HashMap 一樣,選擇了分桶的方式,也就是常說的鏈地址法。Hash 表有兩維,第一維度是個數組,第二維度是個鏈表,當發生了 Hash 衝突的時候,將衝突的節點使用鏈表連接起來,放在同一個桶內。

由於第二維度是鏈表,我們都知道鏈表的查找效率相比於數組的查找效率是比較差的。那麼如果 hash 衝突比較嚴重,導致單個鏈表過長,那麼此時 hash 表的查詢效率就會急速下降。

擴容與縮容

當哈希表過於擁擠,查找效率就會下降,當 hash 表過於稀疏,對內存就有點太浪費了,此時就需要進行相應的擴容與縮容操作。

想要進行擴容縮容,那麼就需要描述當前 hasd 表的一個填充程度,總不能靠感覺。這就有了 負載因子 這個概念。

負載因子是用來描述哈希表當前被填充的程度。計算公式是:負載因子=哈希表以保存節點數量 / 哈希表的大小.

在 Redis 的實現裏,擴容縮容有三條規則:

  1. 當 Redis 沒有進行 BGSAVE 相關操作,且 負載因子>1的時候進行擴容。
  2. 負載因子>5的時候,強行進行擴容。
  3. 負載因子<0.1的時候,進行縮容。

根據程序當前是否在進行 BGSAVE 相關操作,擴容需要的負載因子條件不相同。

這是因爲在進行 BGSAVE 操作時,存在子進程,操作系統會使用 寫時複製 (Copy On Write) 來優化子進程的效率。Redis 儘量避免在存在子進程的時候進行擴容,儘量的節省內存。

熟悉 hash 表的讀者們應該知道,擴容期間涉及到到 rehash 的問題。

因爲需要將當前的所有節點挪到一個大小不一致的哈希表中,且需要儘量保持均勻,因此需要將當前哈希表中的所有節點,重新進行一次 hash. 也就是 rehash.

漸進式 hash

原理

在 Java 的 HashMap 中,實現方式是 新建一個哈希表,一次性的將當前所有節點 rehash 完成,之後釋放掉原有的 hash 表,而持有新的表。

而 Redis 不是,Redis 使用了一種名爲漸進式 hash 的方式來滿足自己的性能需求。

這是一個我親歷的面試原題:Redis 的字典結構,在 rehash 時和 Java 的 HashMap 的 Rehash 有什麼不同?

rehash 需要重新定位所有的元素,這是一個 O(N) 效率的問題,當對數據量很大的字典進行這一操作的時候,比較耗時。

對於單線程的 Redis 來說,表示很難接受這樣的延時,因此 Redis 選擇使用 一點一點搬的策略。

Redis 實現了漸進式 hash. 過程如下:

  1. 假如當前數據在 ht[0] 中,那麼首先爲 ht[1] 分配足夠的空間。
  2. 在字典中維護一個變量,rehashindex = 0. 用來指示當前 rehash 的進度。
  3. 在 rehash 期間,每次對 字典進行 增刪改查操作,在完成實際操作之後,都會進行 一次 rehash 操作,將 ht[0] 在rehashindex 位置上的值 rehash 到 ht[1] 上。將 rehashindex 遞增一位。
  4. 隨着不斷的執行,原來的 ht[0] 上的數值總會全部 rehash 完成,此時結束 rehash 過程。 將 rehashindex 置爲-1.

在上面的過程中有兩個問題沒有提到:

  1. 假如這個服務器很空餘呢?中間幾小時都沒有請求進來,那麼同時保持兩個 table, 豈不是很浪費內存?

解決辦法是:在 redis 的定時函數裏,也加入幫助 rehash 的操作,這樣子如果服務器空閒,就會比較快的完成 rehash.

  1. 在保持兩個 table 期間,該哈希表怎麼對外提供服務呢?

解決辦法:對於添加操作,直接添加到 ht[1] 上,因此這樣才能保證 ht[0] 的數量只會減少不會增加,才能保證 rehash 過程可以完結。而刪除,修改,查詢等操作會在 ht[0] 上進行,如果得不到結果,會去 ht[1] 再執行一遍。

漸進式 hash 帶來的好處是顯而易見的,他採用了分而治之的思想,將 rehash 操作分散到每一個對該哈希表的操作上以及定時函數上,避免了集中式 rehash 帶來的性能壓力。

與此同時,漸進式 hash 也帶來了一個問題,那就是 在 rehash 的時間內,需要保存兩個 hash 表,對內存的佔用稍大,而且如果在 redis 服務器本來內存滿了的時候,突然進行 rehash 會造成大量的 key 被拋棄。

小應用

我們學習漸進式 hash 是爲了面試嗎?如果不是爲了面試,那麼我們又不用去設計一個 Redis, 爲啥要知道這個?

我個人覺得,我們是爲了理解它的思想。在我學習完漸進式 hash 之後的某一天,在某論壇回答了一位網友的問題。

他的問題是這樣一個場景:

有兩張表,一張工作量表,一張積分表,積分=工作量*係數。
係數是有可能改變的,當係數發生變化之後,需要重新計算所有過往工作量的對應新系數的積分情況。
而工作量表的數據量比較大,如果在係數發生變化的一瞬間開始重新計算,可以會導致系統卡死,或者系統負載上升,影響到在線服務。
怎麼解決這個問題?

我個人的理解是,這個可以用 redis 漸進式 rehash 的思路來解決。

原數據(原有的工作量表), 負載因子達到某個值(係數改變), 進行 rehash(重新計算所有值)

所有的元素都齊活了。

我們只需要額外記錄一個標誌着正在進行重新計算過程中的變量即可。之後的思路就完全和 Redis 一致了。

  1. 首先我們可以在某個用戶請求自己的積分的時候,再幫他計算新的積分。來分散系統壓力。
  2. 如果系統壓力並不大,可以在系統定時任務裏重算一小部分(一個 batch), 具體多少可以由數據量決定。

這樣完美的解決了性能壓力,代碼層面只是加一個記錄參數以及給一個接口加個"觸發器"而已,也算不上麻煩~.

思考問題:爲什麼縮容不用考慮 bgsave?

這是我看的《Redis 深度歷險:核心原理和應用實踐》中的一個思考問題,我在這裏寫下個人的一點理解。

擴容時考慮 BGSAVE 是因爲,擴容需要申請額外的很多內存,且會重新鏈接鏈表(如果會衝突的話), 這樣會造成很多內存碎片,也會佔用更多的內存,造成系統的壓力。

而縮容過程中,由於申請的內存比較小,同時會釋放掉一些已經使用的內存,不會增大系統的壓力。因此不用考慮是否在進行 BGSAVE 操作。

總結

Redis 的字典數據結構,和下一篇文章要將的跳躍表數據結構一樣,是面試中的高頻問題。

Redis 字典中,用 table[2] 的數組保存着兩張 hash 表,正常情況下只使用其中一張,在 rehash 的時候使用另外一張表。

Redis 爲了提高自己的性能,rehash 過程不是一次性完成的,而是使用了漸進式 hash 的策略,逐步的將原有元素 rehash 到新的哈希表中,直到完成。

至於其他方面,和其他語言中的哈希表區別不是特別大,比如 hash 算法以及如何解決哈希衝突。

參考文章

《Redis 的設計與實現(第二版)》

《Redis 深度歷險:核心原理和應用實踐》


完。

聯繫我

最後,歡迎關注我的個人公衆號【 呼延十 】,會不定期更新很多後端工程師的學習筆記。
也歡迎直接公衆號私信或者郵箱聯繫我,一定知無不言,言無不盡。


以上皆爲個人所思所得,如有錯誤歡迎評論區指正。

歡迎轉載,煩請署名並保留原文鏈接。

聯繫郵箱:[email protected]

更多學習筆記見個人博客或關注微信公衆號 < 呼延十 >------>呼延十

發佈了94 篇原創文章 · 獲贊 12 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章