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

前言

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就是哈希表。markdown

  • 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 深度歷險:核心原理和應用實踐》


完。

聯繫我

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


以上皆爲我的所思所得,若有錯誤歡迎評論區指正。

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

聯繫郵箱:huyanshi2580@gmail.com

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

相關文章
相關標籤/搜索