1. 程式人生 > 其它 >共讀《redis設計與實現》-資料結構篇

共讀《redis設計與實現》-資料結構篇

準備將之前攢下的書先看一遍,主要是有個大概的瞭解,以後用的時候也知道在哪裡找。所以準備開幾篇共讀的帖子,激勵自己多看一些書。

Redis 基於 簡單動態字串(SDS)、雙端連結串列字典壓縮列表整數集合等基礎的資料結構,建立了一個物件系統,這個物件系統包含:字串物件(String)、列表物件(List)、集合物件(Set)、有序集合物件(Zset)、雜湊物件(Hash) 5種資料物件型別。但是這5種物件型別,其內部的基礎的儲存結構 並不是 一對一的一種,而是每一種包含了至少兩種資料結構。

我們這篇主要用來說一下其基礎的儲存結構

前提條件

redis 底層是使用C語言編寫的,所以很多函式直接使用的C庫。

一、SDS(簡單動態字串)

我們知道C語言中字串 是以字元陣列char[]進行儲存的,字串的結束是以 空字元‘/0’ 來進行標識的,也就是字串的實際長度比我們看見的字串都會多1 byte(位元組)
如果我們想要檢視一下字串的長度,那麼就需要遍歷一下字元陣列,時間複雜度為O(n)。

1.1 結構說明:

  1. redis中使用結構體SDS用來儲存字串型別,同樣的使用字元陣列進行儲存 也自帶空字元‘/0’,從而可以使用C語言中字串相關的特性/函式。
  2. len:陣列已用長度記錄,就是說字串的真實長度(不算‘/0’)
  3. free:陣列中剩餘可用長度,也就是陣列中還有多少長度使用的。

1.2 記憶體預分配

我們從SDS結構圖可以知道SDS中字元陣列的長度是和字串長度不一樣的,那麼這個長度是如何分配的?

  1. 首先如果是建立/擴充套件:
    1. 小於1M,分配的 未使用記憶體 是 使用記憶體的2倍
    2. 大於1M,那麼 每次擴充套件未使用記憶體為 1M
  2. 如果是收縮:

並不會立即真正釋放,會留下未使用的記憶體,可以通過Api來進行釋放,從而避免記憶體洩漏

1.3 二進位制

由於C語言中字串以 ‘/0’標識結尾,所以C語言中字串不能儲存 圖片、音視訊的二進位制資料,但是redis 中字串以len來做為結尾的判斷,所以可以使用字串來儲存二進位制的資料。
當然對於 文字型別的 本身結束就是‘/0’結尾的,所以我們可以直接使用C的字串特性。

1.4 特性(總結):

  1. 自帶空格,從而可以使用C語言字串相關特性
  2. 儲存
    使用空間未使用空間這樣長度可以快速得出(時間複雜度O(1)),不用遍歷陣列(時間複雜度O(n))
  3. 由2我們可以杜絕 C語言中快取溢位的問題
  4. 節省了避免快取溢位而帶來 記憶體重分配的系統開銷
  5. 空間預分配
    1. 擴充套件:小於1M 預分配未使用空間為 使用空間的2倍,大於1M,預分配未使用空間為1M;
    2. 收縮:惰性空間釋放
  6. 可以儲存圖片和音視訊二進位制資料。

關於 C語言快取溢位:
我們知道陣列是一塊記憶體挨著的儲存空間,C語言中,如果我們直接對字串增加,會有如下這種情況的發生:

現在給hello 尾部新增 “-wi” 字串

"字串“hello”新增 "-wi" 字串之後記憶體快照"

所以C語言中我們為了防止這種情況,每次擴充套件的時候都會進行 記憶體重分配,使得空餘的字元陣列可以容得下我們新加的字串。但是 記憶體重分配會導致系統呼叫,對於redis這種頻繁增加刪除的資料庫來說,這種肯定要儘可能的減少系統性能的浪費。

二、連結串列

其實就是一個結構體持有雙向連結串列

typedef struct list{
    //表頭節點
    listNode *head;
    //表尾節點
    listNode *tail;
    //連結串列所包含的節點數量
    unsigned long len;
    //節點值複製函式
    void *(*dup)(void *ptr);
    //節點值釋放函式
    void *(*free)(void *ptr);
    //節點值對比函式
    int (*match)(void *ptr,void *key);
}list;

特性

  1. 雙向連表,這樣查詢前(或者後)一個節點,複雜度為O(1)
  2. 有頭尾指標,查詢第一個節點、最後一個節點複雜度為O(1)
  3. 帶連結串列長度計數器,返回長度複雜度為O(1)
  4. 無環(⚠️)
  5. void* 儲存節點的值,可以使用dup\free\match 等特定函式。

三、字典

C語言本身沒有 字典型別,但是對於key-vale 這種對映的關係 在redis是常用的,所以redis 自己構建了一個結構體,本身使用的是 hash 結構

typedef struct dict {
    dictType *type;     //dictType也是一種資料結構,dictType結構中包含了一些函式,這些函式用來計算key的雜湊值,進而用這個雜湊值計算key在dictEntry型table陣列中的下標
    void *privdata;     //私有資料,儲存著dictType結構中函式的引數
    dictht ht[2];       //兩張雜湊表:一張用來正常儲存節點,一張用來在rehash時臨時儲存節點
    long rehashidx;     //rehash的標記:預設-1,當table陣列中已有元素個數增加/減少到一定量時,整個字典結構將進行rehash給每個table元素重新分配位置,rehashidx代表rehash過程的進度,rehashidx==-1代表字典沒有在進行rehash,rehashidx>-1代表該字典結構正在對進行rehash
} dict;

3.1 字典結構體

  1. dictType:也是一種資料結構,dictType結構中包含了一些函式(dup\free等),這些函式用來計算key的雜湊值,進而用這個雜湊值計算key在dictEntry型table陣列中的下標。

說白了,也就是redis 的字典為每種基礎型別都建立了一個dictType,使得可以使用型別特定的函式

  1. privdata:私有資料,儲存dictType構造引數,不同的型別傳不同 的引數
  2. ht[]:雜湊表,真正儲存資料的地方。其中ht[0]是使用的表,ht[1]沒有分配記憶體空間,只有在rehash的時候會分配記憶體,用到。
  3. rehashidx:在rehash的時候才會使用。

3.1.1 redis 雜湊表結構體:

typedef struct dictht { //雜湊表
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask; 
    unsigned long used;
} dictht;
說明:
  1. table 是hash 儲存的陣列地址
  2. size 是桶的大小,也就是陣列的容量
  3. sizemask,進行hash 運算的時候會使用到,一般為 size-1;(用於計算每個key在table中的下標位置=hash(key)&sizemask)
  4. 記錄雜湊表的table中已有的節點數量(節點=dictEntry=鍵值對)。

3.1.2 redis的hash節點結構體

typedef struct dictEntry {
    void *key;//鍵
    union{     //值
        void *val;//值可以是指標
        uint64_tu64;//值可以是無符號整數
        int64_ts64;//值可以是帶符號整數
    } v;
    struct dicEntry *next;//指向下個dictEntry節點:redis的字典結構採用連結串列法解決hash衝突,當table陣列某個位置處已有元素時,該位置採用頭插法形成連結串列解決hash衝突
} dictEntry;

3.2 結構圖

3.3 hash 步驟

  1. 算出key 的hash 值(通過key 自身的函式)
  2. 使用 步驟1得到的 雜湊值 和 sizemask進行運算 index = hash & dict->ht[x].sizemask;得到要儲存的索引位置。

其實和java 的hashmap 運算過程一樣
當然這種肯定會遇到hash 衝突,這時候就是用 鏈地址法解決衝突
也為了插入效率問題(插入的話還需要遍歷在陣列後面的連結串列),採用頭插法

3.4 rehash 步驟

所謂的rehash 就是當前hash 結構(主要是 桶陣列)已經低於某種效率了,需要進行優化,從而 再次進行hash運算

  1. 給ht[1]分配記憶體,具體的分配規則:
    1. 如果是擴充套件(增加值)導致的rehash,分配的ht[1]記憶體為:h[0].user*22^n(2的n次冪)
    2. 如果是收縮導致的rehash,分配的ht[1]記憶體為:h[0].user2^n(2的n次冪)
  2. rehashidx賦值0
  3. ht[0]的 值 重新hash 運算到ht[1]中去,執行一次 rehashidx+1
  4. ht[0]釋放,將ht[1]改為ht[0]新建一個ht[1]

3.5 rehash 觸發條件

  • 沒有在執行BGSAVE命令或者BGREWRITEAOF命令,並且雜湊表負載因子 大於或等於1
  • 目前在執行BGSAVE命令或者BGREWRITEAOF命令,並且雜湊表的負載因子大於或等於5
  • 當雜湊表的負載因子``小於``0.1時,redis會自動開始對雜湊表進行縮容操作。

說一下負載因子節點數/桶大小

3.6 漸進rehash

對於數量小的hash表進行 reash 一次執行就ok ,但是資料量特別大的呢?那種成千上萬幾億的資料,這種如果進行一次性的rehash的話佔用資源是非常大的,此時redis 就要處於不可用的狀態了,這種是絕對不允許的,所以這種是需要分批次來進行rehash,就是漸進rehash

對於這種有個注意點:
如果在rehash 的時候寫入資料,那麼我們直接寫到ht[1]上,
但是如果是更新刪除操作 則是兩個ht[]都要用

3.7 特性

  1. ht[0] 為一般儲存,ht[1]rehash時使用的儲存
  2. rehashidx 開始為 -1 ,開始rehash的時候會變成0
  3. hash 演算法是MurMurHash
  4. 通過鏈地址法解決衝突
  5. 採用頭插法
  6. 使用漸進rehash
  7. 觸發條件

四、跳躍表

對於一個有序陣列,我們想要快速訪問,並且頻繁更新資料,那麼我們會使用什麼樣的儲存操作呢?對於 有序這兩個字 我們快速訪問肯定想到的是二分表樹形結構,尤其是 二叉平衡樹 最為可靠,但是二叉平衡樹 以及它的簡易替代 紅黑樹資料庫這種 更新比較頻繁的應用中,維持他們的平衡是很耗費效能的。所以redis 採用了相似的 跳躍表 這種結構。

不同於前面幾種結構,跳躍表 只是在儲存大量的有序陣列中 或者 redis 內部結構中使用到了。
本意是減少複雜度,替代平衡樹,並且因為跳躍表的實現比平衡樹要來得更為簡單,所以有不少程式都使用跳躍表來代替平衡樹。
結構圖:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;    //header指向跳躍表的表頭節點,tail指向跳躍表的表尾節點
    unsigned long length;   //記錄跳躍表的長度,也即是,跳躍表目前包含節點的數量(表頭節點不計算在內)
    int level;  //記錄目前跳躍表內,層數最大的那個節點的層數(表頭節點的層數不計算在內)
} zskiplist;
typedef struct zskiplistNode {
    robj *obj;  /*成員物件*/
    double score;   /*分值*/
    struct zskiplistNode *backward; /*後退指標*/
    struct zskiplistLevel { /*層*/
        struct zskiplistNode *forward;  /*前進指標*/
        unsigned int span;  /*跨度*/
    } level[];
} zskiplistNode;

說明:

這裡說一下跳躍表的思想:
我們在有序列表中查詢一個數,

對於**陣列**那麼我們就可以使用二分法查詢,以此來提高查詢效率,但是如果我們要頻繁的插入新資料,那就要不斷的去移動這個陣列的資料,這樣來說資料如果特別大效能並沒有得到很大的提升(移動資料資料相比查詢來說是更耗時的)

對於**連結串列**來說,我們的插入和刪除就比較方便了,畢竟只有指標之間引用的修改,這不提高效率了麼?但是連結串列是不可以用二分法的(中間元素需要遍歷才能找到O(n),資料可以直接訪問O(1)),我們有沒有辦法去提高連結串列的查詢效率呢?
我們可以每隔一個節點在上面建立一個節點,也就是新的連結串列之前連結串列一半的數量,查詢的時候以新連結串列起點,遇到比當前節點大(小)比後一節點小(大)的就移動到之前節點去查詢,這樣查詢的效率可以得到很大的提升,當然,我們可以在新連結串列上在建立一層,查詢速度比之前的在提高一些,然後在新建一層……這樣最終就是一個建立索引的過程。

對於 二分規則(每隔一個節點建立上一層索引) 是否要完美``執行
當最後我們建立好之後是不是發現,每隔一個建立這種索引的過程是不是和平衡二叉樹有點像啊?而且有個最重要的是,當我們插入新資料的時候,為了維持每隔一個建立上一層索引的概念,我們不得不更新索引。。這樣當索引數量大的時候不又產生效率問題麼,似乎也沒辦法解決了??
既然有這種問題,我們就沒必要嚴格執行二分不就行了麼。關注一下我們的 目的 只在於讓查詢的效率提升,那麼我們按照這種方法 提升查詢效率,既然不能達到百分百完美,那我們就儘量的靠近實現二分就行。
用數學統計學中 的概率問題去解決,也就是實現平均的二分其實查詢效率就能夠得到提升,所以並不是嚴格執行每隔一個進行一次建立索引。

特性:

  1. 同樣的,跳躍表也是redis 建立了一個結構體持有節點物件,這樣我們使用的時候可以使用 length來獲取長度level 獲取最大層數、以及頭節點尾節點,這些獲取的時候複雜度都是O(1)
  2. 然後 每個 listnode 節點都有 多個``前進指標一個``後退指標
  3. 前進指標 指向 比節點大(或者小)的下個節點;(也就是指向尾部元素 的方向
  4. 後退指標 指向 當前節點的 上一層級(只有一個,並且指向上一層級,不能跨級)
  5. 我們訪問或者查詢元素時 通過 前進指標就可以查詢到。
  6. 隨機層數:對於每一個新插入的節點,都需要呼叫一個隨機演算法給它分配一個合理的層數,Redis 跳躍表預設允許最大的層數是 32

五、整數集合

5.1 結構體

//每個intset結構表示一個整數集合
typedef struct intset{
    //編碼方式
    uint32_t encoding;
    //集合中包含的元素數量
    uint32_t length;
    //儲存元素的陣列
    int8_t contents[];
} intset;

整數集合也是一樣的持有一個整數陣列的 結構體,結構體中儲存 陣列長度、陣列型別

  1. contents[]:是整數集合的底層實現,整數集合的每個元素都是 contents陣列的個數組項(item),各個項在陣列中按值的大小從小到大有序地排列,並且陣列中不包含任何重複項。
  2. length:屬性記錄了陣列的長度。
  3. encodingintset結構體contents屬性宣告為int8_t型別的陣列,但實際上 contents陣列並不儲存任何int8t型別的值, contents陣列的真正型別取決於encoding屬性的值。encoding屬性的值為INTSET_ENC_INT16則陣列就是uint16_t型別,陣列中的每一個元素都是int16_t型別的整數值(-32768——32767),encoding屬性的值為INTSET_ENC_INT32則陣列就是uint32_t型別,陣列中的每一個元素都是int16_t型別的整數值(-2147483648——2147483647)。

5.2 升級

C語言中,記憶體是需要我們自己行進管理的。其實我們可以知道我們儲存的時候並不是一次性儲存的,可能之前儲存的是 int8 型別的,後來資料發生變化,我們儲存int16甚至int32\int64型別的,為了防止這種情況發生,我們一般一開始就進行 int64的定義儲存,這樣我們就不用擔心後面使用的時候發生記憶體溢位問題。但是有個問題是:我們這樣做的話,假如前面的都是int8的,後面int64 最後很晚才入庫 或者直接不入庫了,這樣我們用int64儲存的int8資料,這不是記憶體浪費麼?所以,redis 為了這種情況,對沒有超過當前儲存的情況使用當前結構進行儲存,也就是開始就是 int8,等到進來一個數發現儲存不夠,需要int16\int32 那麼我在升級 整個集合的型別。從而避免了資源的浪費。
升級首先要做的就是空間重分配
只有升級操作,沒有``降級操作。

5.3 優點

靈活性:就是我的儲存可以更加的靈活,不必擔心型別轉換的問題。
節約記憶體:不必一開始就建立大容量的資料。

六、壓縮列表

它是我們常用的 zset ,list 和 hash 結構的底層實現之一


和其他型別一樣,壓縮列表也是由一個結構體來持有儲存的資料資料,然後儲存了陣列中節點的數量,節點的偏移量,節點的儲存大小。
其中,entry[] 儲存的是有序的陣列序列。

entry[]

我們重點看一下entry[]的結構體

為什麼小資料量使用

我們知道,對於記憶體的讀取來說 順序讀取 是比 隨機讀取 效率要高很多的所以對於讀取的操作,我們常常會將其設定為陣列,提高其讀取效率。但是如果是更新來說,大資料量的陣列往往是效率不可靠的。所以,我們也就明白為什麼 對於壓縮列表來說,只有小資料量的才會使用。

encoding

——解決空間浪費問題
對於資料儲存也有一個問題:就是我們在整數集合中說的,如果前面的資料是int8 的後面的是int64的,這樣我們的儲存空間就要設定成64的,前面不就浪費了很多記憶體麼,如何解決這個問題?
我們可以儲存成不同結構型別的 啊,比如entry 結構體,我的content 就是不同資料型別的,這樣儲存的時候小的儲存成int8 大的儲存成int64,但是這樣會有個問題:我們在遍歷它的時候由於不知道每個元素的大小是多少,因此也就無法計算下一個節點的具體位置,如果前面讀取的是in8 後面讀取的int64 我怎麼分開呢?
這個時候我們可以給每個節點增加一個encoding的屬性,我們就可以知道這個**content**中記錄資料的格式,也就是記憶體的大小了。

一位元組、兩位元組或者五位元組長, 值的最高位為 00 、 01 或者 10 的是位元組陣列編碼: 這種編碼表示節點的 content 屬性儲存著位元組陣列, 陣列的長度由編碼除去最高兩位之後的其他位記錄;
一位元組長, 值的最高位以 11 開頭的是整數編碼: 這種編碼表示節點的 content 屬性儲存著整數值, 整數值的型別和長度由編碼除去最高兩位之後的其他位記錄;


如此。我們在遍歷節點的之後就知道每個節點的長度(佔用記憶體的大小),就可以很容易計算出下一個節點再記憶體中的位置。這種結構就像一個簡單的壓縮列表了。

previous_entry_length

我們知道如何順序讀取了,但是如果我想後退讀取資料呢?我們不知道前面資料的型別 大小,怎麼取擷取記憶體讀取呢?
和encoding 一樣,我們記錄一下上一個entry的大小,然後用當前記憶體地址-**previous_entry_length** 如此就能計算出上一個記憶體地址,然後按照相應規則讀取了。

這個屬性記錄了壓縮列表前一個節點的長度,該屬性根據前一個節點的大小不同可以是1個位元組或者5個位元組。
如果前一個節點的長度小於254個位元組,那麼previous_entry_length的大小為1個位元組,即前一個節點的長度可以使用1個位元組表示
如果前一個節點的長度大於等於254個位元組,那麼previous_entry_length的大小為5個位元組,第一個位元組會被設定為0xFE(十進位制的254),之後的四個位元組則用於儲存前一個節點的長度。

連鎖更新

由上述我們知道,下一個節點儲存上一個節點的大小,如果我們新增節點 或者 刪除節點的時候,節點的大小發生了變化:

考慮下這種情況:
比如多個連續節點長度都是小於254 位元組的,都處於 250 和253 位元組之間,現在我們在前面插入一個大於254 位元組長度的節點,那麼後一節點 之前的 1位元組 顯然不能滿足,只能更改為 5 位元組來盡心儲存 大於254 位元組的長度,我們在看後面,麻煩的事情來了:我們將previous_entry_length 改成5位元組的長度,那麼我們當前節點就超過了254節點,顯然下一節點的previous_entry_length也不滿足了,然後我們就又要改,這樣一系列的問題就出現了。這樣的問題稱為 連鎖更新。
儘管連鎖更新的複雜度較高,但是它真正造成效能問題的機率是很低的:

  1. 要很多連續的,長度介於 250和253 之間的節點
  2. 即使出現連鎖更新,但是如果只是小範圍,節點數量不多,就不會造成效能影響。

所以在實際中我們可以放心的使用這個函式。


物件系統

到這裡我們已經將redis 的儲存結構講完了,但是物件系統和 儲存結構之間具體的關係,或者說聯絡是什麼呢?
首先我們明白,在物件系統中,redis 有大物件:STRINGLISTSETZSETHASH
然後每個物件 的底層儲存是 我們上面說的哪幾種類型,
說白了就是說的 java中的基本型別物件之間的關係。


從上面我們知道每種物件都至少 有兩種 基本型別,那麼他們之間的劃分 或者說界限是什麼呢?

1. 界限





2. 各種物件API

STRING API

LIST API

SET API


ZSET API

HASH API

3. 公共Api

4. 型別檢查

我們知道對於redis 來說,每個物件都使用了至少兩種基本型別,但是C 語言中,如果型別不一樣,常常會出現型別錯誤的問題。我們怎麼解決呢?
這裡我們看一下物件的儲存結構:

struct redisObject {

    unsigned type:4; // 型別

    unsigned encoding:4; // 編碼 

    void *ptr; //執行底層實現資料結構的指標

    int refcount; //引用計數,用於記憶體回收

    unsigned lru:22; // 記錄最近一次訪問這個物件的時間

}

通過這個我們可以看到,其實redis 物件儲存了使用的基本結構,這樣我們使用api的時候,都會進行一個型別檢查然後再去進行使用,對於非本型別的 api 返回錯誤資訊。

其實每個物件內部基本型別的轉換也是需要注意一下的,就是邊界。

5. 多型性

我們可以從 公共api 中可以看到 redis 物件的多型性,就是不同的型別執行的 方法結果是一樣的,只不過對於不同的型別都有自己特殊的處理
其實這裡的多型性在我們同一個型別中不同基礎結構的 API 中也是有體現的。

6. 記憶體回收/引用計數器

C語言中,記憶體是交給我們自己來進行管理的,所以當我們不使用這塊記憶體的時候就要就行記憶體釋放。我們怎麼知道記憶體是否還在使用呢?從之前我們物件結構中可以看到,redis 維護了一個 引用計數器,這樣我們每次引用的時候都會 使得 refcount+1。其實引用計數器在很多 語言中都有使用java中也使用過,這裡面有個比較難受的點:如果兩個物件之間相互引用,但是兩個都是沒有用的,這種永遠不會是0,也就就釋放不了拉。在redis 中還維護了一個lru就是說設定一個時間,超過這個時間的,那麼就強制釋放它,這樣就避免了相互引用導致的 記憶體釋放問題。

7. 物件共享

redis 大量用到了sds 這種結構,而且可以在其他基本結構中 巢狀使用。例如連結串列的節點的值可以使用 sds 。我們如果有很多一樣的資料,如果在記憶體中分配一個空間,少量的還行如果數量多了豈不會“浪費”?
所以redis 採用了物件共享,也就是這個型別的資料如果在記憶體中已經有了,那麼我們再次建立的時候不會開闢新的空間,直接使用物件的引用,此時引用計數器+1,那麼資料量大的時候就會節省很多記憶體。
redis 伺服器啟動的時候會建立一萬個字串物件,這些物件包含0-9999字串物件,以後使用的時候不在建立新的而是使用這個物件。

參考資料

《Redis設計與實現》-黃健巨集
部分圖片來與百度搜索