深刻淺出Redis-redis底層數據結構(上)

一、概述


 

    相信使用過Redis 的各位同窗都很清楚,Redis 是一個基於鍵值對(key-value)的分佈式存儲系統,與Memcached相似,卻優於Memcached的一個高性能的key-value數據庫。redis

    

    在《Redis設計與實現》這樣描述:算法

    Redis 數據庫裏面的每一個鍵值對(key-value) 都是由對象(object)組成的:數據庫

      數據庫鍵老是一個字符串對象(string object);數組

      數據庫的值則能夠是字符串對象、列表對象(list)、哈希對象(hash)、集合對象(set)、有序集合(sort set)對象這五種對象中的其中一種。安全

 

    咱們爲何會說Redis 優於Memcached 呢,由於Redis 的出現,豐富了memcached 中key-value的存儲不足,在部分場合能夠對關係數據庫起到很好的補充做用,並且這些數據類型都支持push/pop、add/remove及取交集並集和差集及更豐富的操做,並且這些操做都是原子性的。數據結構

    

    咱們今天探討的並非Redis 中value 的數據類型,而是他們的具體實現——底層數據類型分佈式

    Redis 底層數據結構有一下數據類型:memcached

    1.  簡單動態字符串
    2.    鏈表
    3.    字典
    4.    跳躍表
    5.    整數集合
    6.    壓縮列表
    7.    對象

          

    咱們接下來會一步一步的探討這些數據結構有什麼特色,已經他們是如何構成咱們所使用的value 數據類型。函數

 

二、簡單動態字符串(simple dynamic string)SDS


2.1 概述

   Redis 是一個開源的使用ANSI C語言編寫的key-value 數據庫,咱們可能會較爲主觀的認爲 Redis 中的字符串就是採用了C語言中的傳統字符串表示,但其實否則,Redis 沒有直接使用C語言傳統的字符串表示,而是本身構建了一種名爲簡單動態字符串(simple dynamic string SDS)的抽象類型,並將SDS用做Redis 的默認字符串表示:性能

redis>SET msg "hello world"
OK

   設置一個key= msg,value = hello world 的新鍵值對,他們底層是數據結構將會是:

     鍵(key)是一個字符串對象,對象的底層實現是一個保存着字符串「msg」 的SDS;

     值(value)也是一個字符串對象,對象的底層實現是一個保存着字符串「hello world」 的SDS

 

   從上述例子,咱們能夠很直觀的看到咱們在日常使用redis 的時候,建立的字符串究竟是一個什麼樣子的數據類型。除了用來保存字符串之外,SDS還被用做緩衝區(buffer)AOF模塊中的AOF緩衝區。

 

2.2  SDS 的定義

  Redis 中定義動態字符串的結構:

/*  
 * 保存字符串對象的結構  
 */  
struct sdshdr {  
      
    // buf 中已佔用空間的長度  
    int len;  
  
    // buf 中剩餘可用空間的長度  
    int free;  
  
    // 數據空間  
    char buf[];  
};  

   

 

   一、len 變量,用於記錄buf 中已經使用的空間長度(這裏指出Redis 的長度爲5)

   二、free 變量,用於記錄buf 中還空餘的空間(初次分配空間,通常沒有空餘,在對字符串修改的時候,會有剩餘空間出現)

     三、buf 字符數組,用於記錄咱們的字符串(記錄Redis)

 

 

2.3  SDS 與 C 字符串的區別

    傳統的C 字符串 使用長度爲N+1 的字符串數組來表示長度爲N 的字符串,這樣作在獲取字符串長度,字符串擴展等操做的時候效率低下。C 語言使用這種簡單的字符串表示方式,並不能知足Redis 對字符串在安全性、效率以及功能方面的要求

2.3.1 獲取字符串長度(SDS O(1)/C 字符串 O(n))

     傳統的C 字符串 使用長度爲N+1 的字符串數組來表示長度爲N 的字符串,因此爲了獲取一個長度爲C字符串的長度,必須遍歷整個字符串。

     和C 字符串不一樣,SDS 的數據結構中,有專門用於保存字符串長度的變量,咱們能夠經過獲取len 屬性的值,直接知道字符串長度。

    

 

2.3.2 杜絕緩衝區溢出

    C 字符串 不記錄字符串長度,除了獲取的時候複雜度高之外,還容易致使緩衝區溢出。

     假設程序中有兩個在內存中緊鄰着的 字符串 s1 和 s2,其中s1 保存了字符串「redis」,二s2 則保存了字符串「MongoDb」:

      

     若是咱們如今將s1 的內容修改成redis cluster,可是又忘了從新爲s1 分配足夠的空間,這時候就會出現如下問題:

      

      咱們能夠看到,本來s2 中的內容已經被S1的內容給佔領了,s2 如今爲 cluster,而不是「Mongodb」。

 

     Redis 中SDS 的空間分配策略徹底杜絕了發生緩衝區溢出的可能性:

     當咱們須要對一個SDS 進行修改的時候,redis 會在執行拼接操做以前,預先檢查給定SDS 空間是否足夠,若是不夠,會先拓展SDS 的空間,而後再執行拼接操做

 

 

2.3.3 減小修改字符串時帶來的內存重分配次數   

  C語言字符串在進行字符串的擴充和收縮的時候,都會面臨着內存空間的從新分配問題。

   1. 字符串拼接會產生字符串的內存空間的擴充,在拼接的過程當中,原來的字符串的大小極可能小於拼接後的字符串的大小,那麼這樣的話,就會致使一旦忘記申請分配空間,就會致使內存的溢出。

   2. 字符串在進行收縮的時候,內存空間會相應的收縮,而若是在進行字符串的切割的時候,沒有對內存的空間進行一個從新分配,那麼這部分多出來的空間就成爲了內存泄露。

  舉個例子:咱們須要對下面的SDS進行拓展,則須要進行空間的拓展,這時候redis 會將SDS的長度修改成13字節,而且將未使用空間一樣修改成1字節 

  

   由於在上一次修改字符串的時候已經拓展了空間,再次進行修改字符串的時候會發現空間足夠使用,所以無須進行空間拓展

  

 

  經過這種預分配策略,SDS將連續增加N次字符串所需的內存重分配次數從一定N次下降爲最多N次

 

2.3.4 惰性空間釋放

    咱們在觀察SDS 的結構的時候能夠看到裏面的free 屬性,是用於記錄空餘空間的。咱們除了在拓展字符串的時候會使用到free 來進行記錄空餘空間之外,在對字符串進行收縮的時候,咱們也可使用free 屬性來進行記錄剩餘空間,這樣作的好處就是避免下次對字符串進行再次修改的時候,須要對字符串的空間進行拓展。

    然而,咱們並非說不能釋放SDS 中空餘的空間,SDS 提供了相應的API,讓咱們能夠在有須要的時候,自行釋放SDS 的空餘空間。

    經過惰性空間釋放,SDS 避免了縮短字符串時所需的內存重分配操做,並未未來可能有的增加操做提供了優化

 

 

2.3.5 二進制安全

    C 字符串中的字符必須符合某種編碼,而且除了字符串的末尾以外,字符串裏面不能包含空字符,不然最早被程序讀入的空字符將被誤認爲是字符串結尾,這些限制使得C字符串只能保存文本數據,而不能保存想圖片,音頻,視頻,壓縮文件這樣的二進制數據。

    可是在Redis中,不是靠空字符來判斷字符串的結束的,而是經過len這個屬性。那麼,即使是中間出現了空字符對於SDS來講,讀取該字符仍然是能夠的。

    例如:

   

 

 

2.3.6 兼容部分C字符串函數

     雖然SDS 的API 都是二進制安全的,但他們同樣遵循C字符串以空字符串結尾的慣例。

 

2.3.7 總結

 

C 字符串 SDS
獲取字符串長度的複雜度爲O(N) 獲取字符串長度的複雜度爲O(1)
API 是不安全的,可能會形成緩衝區溢出 API 是安全的,不會形成緩衝區溢出
修改字符串長度N次必然須要執行N次內存重分配 修改字符串長度N次最多執行N次內存重分配
只能保存文本數據 能夠保存二進制數據和文本文數據
可使用全部<String.h>庫中的函數 可使用一部分<string.h>庫中的函數

 

 

三、鏈表


 

 

3.1 概述

  鏈表提供了高效的節點重排能力,以及順序性的節點訪問方式,而且能夠經過增刪節點來靈活地調整鏈表的長度。

  鏈表在Redis 中的應用很是普遍,好比列表鍵的底層實現之一就是鏈表。當一個列表鍵包含了數量較多的元素,又或者列表中包含的元素都是比較長的字符串時,Redis 就會使用鏈表做爲列表鍵的底層實現。

  

3.2 鏈表的數據結構

   每一個鏈表節點使用一個 listNode結構表示(adlist.h/listNode):

typedef struct listNode{
      struct listNode *prev;
      struct listNode * next;
      void * value;  
}

 

   多個鏈表節點組成的雙端鏈表:

  

    

     咱們能夠經過直接操做list 來操做鏈表會更加方便:

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 組成的結構圖:

 

 

3.3 鏈表的特性

  • 雙端:鏈表節點帶有prevnext 指針,獲取某個節點的前置節點和後置節點的時間複雜度都是O(N)
  • 無環:表頭節點的 prev 指針和表尾節點的next 都指向NULL,對立案表的訪問時以NULL爲截止
  • 表頭和表尾:由於鏈表帶有head指針和tail 指針,程序獲取鏈表頭結點和尾節點的時間複雜度爲O(1)
  • 長度計數器:鏈表中存有記錄鏈表長度的屬性 len
  • 多態:鏈表節點使用 void* 指針來保存節點值,而且能夠經過list 結構的dup 、 free、 match三個屬性爲節點值設置類型特定函數。

 

 

四、字典

 


  

 

4.1 概述

    字典,又稱爲符號表(symbol table)、關聯數組(associative array)或映射(map),是一種用於保存鍵值對的抽象數據結構。 

    在字典中,一個鍵(key)能夠和一個值(value)進行關聯,字典中的每一個鍵都是獨一無二的。在C語言中,並無這種數據結構,可是Redis 中構建了本身的字典實現

    舉個簡單的例子:

redis > SET msg "hello world"
OK

    建立這樣的鍵值對(「msg」,「hello world」)在數據庫中就是以字典的形式存儲

 

 

4.2 字典的定義

   4.2.1 哈希表

   Redis 字典所使用的哈希表由 dict.h/dictht 結構定義:

typedef struct dictht {
   //哈希表數組
   dictEntry **table;
   //哈希表大小
   unsigned long size;

   //哈希表大小掩碼,用於計算索引值
   unsigned long sizemask;
   //該哈希表已有節點的數量
   unsigned long used;
}

 

   一個空的字典的結構圖以下:

   咱們能夠看到,在結構中存有指向dictEntry 數組的指針,而咱們用來存儲數據的空間既是dictEntry

         4.2.2 哈希表節點( dictEntry )

   dictEntry 結構定義:

typeof struct dictEntry{
   //
   void *key;
   //
   union{
      void *val;
      uint64_tu64;
      int64_ts64;
   }
struct dictEntry *next; }

 

   在數據結構中,咱們清楚key 是惟一的,可是咱們存入裏面的key 並非直接的字符串,而是一個hash 值,經過hash 算法,將字符串轉換成對應的hash 值,而後在dictEntry 中找到對應的位置。

        這時候咱們會發現一個問題,若是出現hash 值相同的狀況怎麼辦?Redis 採用了鏈地址法:

   

   當k1 和k0 的hash 值相同時,將k1中的next 指向k0 想成一個鏈表。

 

   4.2.3 字典

typedef struct dict {
// 類型特定函數 dictType
*type;
// 私有數據
void *privedata;
// 哈希表 dictht ht[
2]; // rehash 索引 in trehashidx; }

 

    type 屬性 和privdata 屬性是針對不一樣類型的鍵值對,爲建立多態字典而設置的。

    ht 屬性是一個包含兩個項(兩個哈希表)的數組

    普通狀態下的字典:

  

 

 

4.3 解決哈希衝突

   在上述分析哈希節點的時候咱們有講到:在插入一條新的數據時,會進行哈希值的計算,若是出現了hash值相同的狀況,Redis 中採用了連地址法(separate chaining)來解決鍵衝突。每一個哈希表節點都有一個next 指針,多個哈希表節點可使用next 構成一個單向鏈表,被分配到同一個索引上的多個節點可使用這個單向鏈表鏈接起來解決hash值衝突的問題。

  舉個例子:

  如今哈希表中有如下的數據:k0 和k1

    咱們如今要插入k2,經過hash 算法計算到k2 的hash 值爲2,即咱們須要將k2 插入到dictEntry[2]中:

    

     在插入後咱們能夠看到,dictEntry指向了k2,k2的next 指向了k1,從而完成了一次插入操做(這裏選擇表頭插入是由於哈希表節點中沒有記錄鏈表尾節點位置)

 

 

4.4 Rehash

  隨着對哈希表的不斷操做,哈希表保存的鍵值對會逐漸的發生改變,爲了讓哈希表的負載因子維持在一個合理的範圍以內,咱們須要對哈希表的大小進行相應的擴展或者壓縮,這時候,咱們能夠經過 rehash(從新散列)操做來完成。

 

  4.4.1 目前的哈希表狀態:

    咱們能夠看到,哈希表中的每一個節點都已經使用到了,這時候咱們須要對哈希表進行拓展。

  

  4.4.2 爲哈希表分配空間

    哈希表空間分配規則:

      若是執行的是拓展操做,那麼ht[1] 的大小爲第一個大於等於ht[0] 的2的n次冪

      若是執行的是收縮操做,那麼ht[1] 的大小爲第一個大於等於ht[0] 的2的n次冪

    所以這裏咱們爲ht[1] 分配 空間爲8,

  

 

  4.4.3 數據轉移

    將ht[0]中的數據轉移到ht[1]中,在轉移的過程當中,須要對哈希表節點的數據從新進行哈希值計算

    數據轉移後的結果:

  

 

   4.4.4 釋放ht[0]

    將ht[0]釋放,而後將ht[1]設置成ht[0],最後爲ht[1]分配一個空白哈希表:

  

 

  4.4.5 漸進式 rehash

    上面咱們說到,在進行拓展或者壓縮的時候,能夠直接將全部的鍵值對rehash 到ht[1]中,這是由於數據量比較小。在實際開發過程當中,這個rehash 操做並非一次性、集中式完成的,而是分屢次、漸進式地完成的。

    漸進式rehash 的詳細步驟:

      一、爲ht[1] 分配空間,讓字典同時持有ht[0]和ht[1]兩個哈希表

      二、在幾點鐘維持一個索引計數器變量rehashidx,並將它的值設置爲0,表示rehash 開始

      三、在rehash 進行期間,每次對字典執行CRUD操做時,程序除了執行指定的操做之外,還會將ht[0]中的數據rehash 到ht[1]表中,而且將rehashidx加一

      四、當ht[0]中全部數據轉移到ht[1]中時,將rehashidx 設置成-1,表示rehash 結束

    

    採用漸進式rehash 的好處在於它採起分而治之的方式,避免了集中式rehash 帶來的龐大計算量。

相關文章
相關標籤/搜索