1. 程式人生 > >Redis專題(2):Redis資料結構底層探祕

Redis專題(2):Redis資料結構底層探祕

前言

上篇文章 Redis閒談(1):構建知識圖譜介紹了redis的基本概念、優缺點以及它的記憶體淘汰機制,相信大家對redis有了初步的認識。網際網路的很多應用場景都有著Redis的身影,它能做的事情遠遠超出了我們的想像。Redis的底層資料結構到底是什麼樣的呢,為什麼它能做這麼多的事情?本文將探祕Redis的底層資料結構以及常用的命令。

本文知識腦圖如下:

一、Redis的資料模型

用 鍵值對 name:"小明"來展示Redis的資料模型如下:

  • dictEntry: 在一些程式語言中,鍵值對的資料結構被稱為字典,而在Redis中,會給每一個key-value鍵值對分配一個字典實體,就是“dicEntry”。dicEntry包含三部分: key的指標、val的指標、next指標,next指標指向下一個dicteEntry形成連結串列,這個next指標可以將多個雜湊值相同的鍵值對連結在一起,通過鏈地址法來解決雜湊衝突的問題
  • sds :Simple Dynamic String,簡單動態字串,儲存字串資料。
  • redisObject:Redis的5種常用型別都是以RedisObject來儲存的,redisObject中的type欄位指明瞭值的資料型別(也就是5種基本型別)。ptr欄位指向物件所在的地址。

RedisObject物件很重要,Redis物件的型別、內部編碼、記憶體回收、共享物件等功能,都是基於RedisObject物件來實現的。

這樣設計的好處是:可以針對不同的使用場景,對5種常用型別設定多種不同的資料結構實現,從而優化物件在不同場景下的使用效率。

Redis將jemalloc作為預設記憶體分配器,減小記憶體碎片。jemalloc在64位系統中,將記憶體空間劃分為小、大、巨大三個範圍;每個範圍內又劃分了許多小的記憶體塊單位;當Redis儲存資料時,會選擇大小最合適的記憶體塊進行儲存。

二、Redis支援的資料結構

Redis支援的資料結構有哪些?

如果回答是String、List、Hash、Set、Zset就不對了,這5種是redis的常用基本資料型別,每一種資料型別內部還包含著多種資料結構。

用encoding指令來看一個值的資料結構。比如:

127.0.0.1:6379> set name tom
OK
127.0.0.1:6379> object encoding name
"embstr"

 

此處設定了name值是tom,它的資料結構是embstr,下文介紹字串時會詳解說明。

127.0.0.1:6379> set age 18
OK
127.0.0.1:6379> object encoding age
"int"

 

如下表格總結Redis中所有的資料結構型別:

底層資料結構編碼常量object encoding指令輸出
整數型別 REDIS_ENCODING_INT "int"
embstr字串型別 REDIS_ENCODING_EMBSTR "embstr"
簡單動態字串 REDIS_ENCODING_RAW "raw"
字典型別 REDIS_ENCODING_HT "hashtable"
雙端連結串列 REDIS_ENCODING_LINKEDLIST "linkedlist"
壓縮列表 REDIS_ENCODING_ZIPLIST "ziplist"
整數集合 REDIS_ENCODING_INTSET "intset"
跳錶和字典 REDIS_ENCODING_SKIPLIST "skiplist"

補充說明

假如面試官問:redis的資料型別有哪些?

回答:String、list、hash、set、zet

一般情況下這樣回答是正確的,前文也提到redis的資料型別確實是包含這5種,但細心的同學肯定發現了之前說的是“常用”的5種資料型別。其實,隨著Redis的不斷更新和完善,Redis的資料型別早已不止5種了。

登入redis的官方網站開啟官方的資料型別介紹:

https://redis.io/topics/data-types-intro 

發現Redis支援的資料結構不止5種,而是8種,後三種類型分別是:

  • 位陣列(或簡稱點陣圖):使用特殊命令可以處理字串值,如位陣列:您可以設定和清除各個位,將所有位設定為1,查詢第一個位或未設定位,等等。
  • HyperLogLogs:這是一個概率資料結構,用於估計集合的基數。不要害怕,它比看起來更簡單。
  • Streams:僅附加的類似於地圖的條目集合,提供抽象日誌資料型別。

本文主要介紹5種常用的資料型別,上述三種以後再共同探索。

2.1 string字串

字串型別是redis最常用的資料型別,在Redis中,字串是可以修改的,在底層它是以位元組陣列的形式存在的。

Redis中的字串被稱為簡單動態字串「SDS」,這種結構很像Java中的ArrayList,其長度是動態可變的.

struct SDS<T> {
  T capacity; // 陣列容量
  T len; // 陣列長度
  byte[] content; // 陣列內容
}

 

content[] 儲存的是字串的內容,capacity表示陣列分配的長度,len表示字串的實際長度。

字串的編碼型別有int、embstr和raw三種,如上表所示,那麼這三種編碼型別有什麼不同呢?

  • int 編碼:儲存的是可以用 long 型別表示的整數值。

  • raw 編碼:儲存長度大於44位元組的字串(redis3.2版本之前是39位元組,之後是44位元組)。

  • embstr 編碼:儲存長度小於44位元組的字串(redis3.2版本之前是39位元組,之後是44位元組)。

設定一個值測試一下:

127.0.0.1:6379> set num 300
127.0.0.1:6379> object encoding num
"int"
127.0.0.1:6379> set key1 wealwaysbyhappyhahaha
OK
127.0.0.1:6379> object encoding key1
"embstr"
127.0.0.1:6379> set key2 hahahahahahahaahahahahahahahahahahahaha
OK
127.0.0.1:6379> strlen key2
(integer) 39
127.0.0.1:6379> object encoding key2
"embstr"
127.0.0.1:6379> set key2 hahahahahahahaahahahahahahahahahahahahahahaha
OK
127.0.0.1:6379> object encoding key2
"raw"
127.0.0.1:6379> strlen key2
(integer) 45

 

raw型別和embstr型別對比

embstr編碼的結構:

raw編碼的結構:

embstr和raw都是由redisObject和sds組成的。不同的是:embstr的redisObject和sds是連續的,只需要使用malloc分配一次記憶體;而raw需要為redisObject和sds分別分配記憶體,即需要分配兩次記憶體。

所有相比較而言,embstr少分配一次記憶體,更方便。但embstr也有明顯的缺點:如要增加長度,redisObject和sds都需要重新分配記憶體。

上文介紹了embstr和raw結構上的不同。重點來了~ 為什麼會選擇44作為兩種編碼的分界點?在3.2版本之前為什麼是39?這兩個值是怎麼得出來的呢?

1) 計算RedisObject佔用的位元組大小

struct RedisObject {
    int4 type; // 4bits
    int4 encoding; // 4bits
    int24 lru; // 24bits
    int32 refcount; // 4bytes = 32bits
    void *ptr; // 8bytes,64-bit system
}

 

  • type: 不同的redis物件會有不同的資料型別(string、list、hash等),type記錄型別,會用到4bits。
  • encoding:儲存編碼形式,用4bits。
  • lru:用24bits記錄物件的LRU資訊。
  • refcount:引用計數器,用到32bits。
  • *ptr:指標指向物件的具體內容,需要64bits。

計算: 4 + 4 + 24 + 32 + 64 = 128bits = 16bytes

第一步就完成了,RedisObject物件頭資訊會佔用16位元組的大小,這個大小通常是固定不變的.

2) sds佔用位元組大小計算

舊版本:

struct SDS {
    unsigned int capacity; // 4byte
    unsigned int len; // 4byte
    byte[] content; // 內聯陣列,長度為 capacity
}

 

這裡的unsigned int 一個4位元組,加起來是8位元組.

記憶體分配器jemalloc分配的記憶體如果超出了64個位元組就認為是一個大字串,就會用到embstr編碼。

前面提到 SDS 結構體中的 content 的字串是以位元組\0結尾的字串,之所以多出這樣一個位元組,是為了便於直接使用 glibc 的字串處理函式,以及為了便於字串的除錯列印輸出。所以我們還要減去1位元組 64byte - 16byte - 8byte - 1byte = 39byte

新版本:

struct SDS {
    int8 capacity; // 1byte
    int8 len; // 1byte
    int8 flags; // 1byte
    byte[] content; // 內聯陣列,長度為 capacity
}

 

這裡unsigned int 變成了uint8_t、uint16_t.的形式,還加了一個char flags標識,總共只用了3個位元組的大小。相當於優化了sds的記憶體使用,相應的用於儲存字串的記憶體就會變大。

然後進行計算:

64byte - 16byte -3byte -1byte = 44byte。

總結:

所以,redis 3.2版本之後embstr最大能容納的字串長度是44,之前是39。長度變化的原因是SDS中記憶體的優化。

2.2 List

Redis中List物件的底層是由quicklist(快速列表)實現的,快速列表支援從連結串列頭和尾新增元素,並且可以獲取指定位置的元素內容。

那麼,快速列表的底層是如何實現的呢?為什麼能夠達到如此快的效能?

羅馬不是一日建成的,quicklist也不是一日實現的,起初redis的list的底層是ziplist(壓縮列表)或者是 linkedlist(雙端列表)。先分別介紹這兩種資料結構。

ziplist 壓縮列表

當一個列表中只包含少量列表項,且是小整數值或長度比較短的字串時,redis就使用ziplist(壓縮列表)來做列表鍵的底層實現。

測試:

127.0.0.1:6379> rpush dotahero sf qop doom
(integer) 3
127.0.0.1:6379> object encoding dotahero
"ziplist"

 

此處使用老版本redis進行測試,向dota英雄列表中加入了qop痛苦女王、sf影魔、doom末日使者三個英雄,資料結構編碼使用的是ziplist。

壓縮列表顧名思義是進行了壓縮,每一個節點之間沒有指標的指向,而是多個元素相鄰,沒有縫隙。所以 ziplist是Redis為了節約記憶體而開發的,是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構。具體結構相對比較複雜,大家有興趣地話可以深入瞭解。

struct ziplist<T> {
    int32 zlbytes; // 整個壓縮列表佔用位元組數
    int32 zltail_offset; // 最後一個元素距離壓縮列表起始位置的偏移量,用於快速定位到最後一個節點
    int16 zllength; // 元素個數
    T[] entries; // 元素內容列表,挨個挨個緊湊儲存
    int8 zlend; // 標誌壓縮列表的結束,值恆為 0xFF
}

 

雙端列表(linkedlist)

雙端列表大家都很熟悉,這裡的雙端列表和java中的linkedlist很類似。

從圖中可以看出Redis的linkedlist雙端連結串列有以下特性:節點帶有prev、next指標、head指標和tail指標,獲取前置節點、後置節點、表頭節點和表尾節點、獲取長度的複雜度都是O(1)。

壓縮列表佔用記憶體少,但是是順序型的資料結構,插入刪除元素的操作比較複雜,所以壓縮列表適合資料比較小的情況,當資料比較多的時候,雙端列表的高效插入刪除還是更好的選擇

在Redis開發者的眼中,資料結構的選擇,時間上、空間上都要達到極致,所以,他們將壓縮列表和雙端列表合二為一,建立了快速列表(quicklist)。和java中的hashmap一樣,結合了陣列和連結串列的優點。

快速列表(quicklist)

  • rpush: listAddNodeHead ---O(1)
  • lpush: listAddNodeTail ---O(1)
  • push:listInsertNode ---O(1)
  • index : listIndex ---O(N)
  • pop:ListFirst/listLast ---O(1)
  • llen:listLength ---O(N)

struct ziplist {
    ...
}
struct ziplist_compressed {
    int32 size;
    byte[] compressed_data;
}
struct quicklistNode {
    quicklistNode* prev;
    quicklistNode* next;
    ziplist* zl; // 指向壓縮列表
    int32 size; // ziplist 的位元組總數
    int16 count; // ziplist 中的元素數量
    int2 encoding; // 儲存形式 2bit,原生位元組陣列還是 LZF 壓縮儲存
    ...
}
struct quicklist {
    quicklistNode* head;
    quicklistNode* tail;
    long count; // 元素總數
    int nodes; // ziplist 節點的個數
    int compressDepth; // LZF 演算法壓縮深度
    ...
}

 

quicklist 預設的壓縮深度是 0,也就是不壓縮。壓縮的實際深度由配置引數list-compress-depth決定。為了支援快速的 push/pop 操作,quicklist 的首尾兩個 ziplist 不壓縮,此時深度就是 1。如果深度為 2,表示 quicklist 的首尾第一個 ziplist 以及首尾第二個 ziplist 都不壓縮。

2.3 Hash

Hash資料型別的底層實現是ziplist(壓縮列表)或字典(也稱為hashtable或散列表)。這裡壓縮列表或者字典的選擇,也是根據元素的數量大小決定的。

如圖hset了三個鍵值對,每個值的位元組數不超過64的時候,預設使用的資料結構是ziplist。

當我們加入了位元組數超過64的值的資料時,預設的資料結構已經成為了hashtable。

Hash物件只有同時滿足下面兩個條件時,才會使用ziplist(壓縮列表):

  • 雜湊中元素數量小於512個;
  • 雜湊中所有鍵值對的鍵和值字串長度都小於64位元組。

壓縮列表剛才已經瞭解了,hashtables類似於jdk1.7以前的hashmap。hashmap採用了鏈地址法的方法解決了雜湊衝突的問題。

Redis中的字典

redis中的dict 結構內部包含兩個 hashtable,通常情況下只有一個 hashtable 是有值的。但是在 dict 擴容縮容時,需要分配新的 hashtable,然後進行漸進式搬遷,這時兩個 hashtable 儲存的分別是舊的 hashtable 和新的 hashtable。待搬遷結束後,舊的 hashtable 被刪除,新的 hashtable 取而代之。

2.4 Set

Set資料型別的底層可以是intset(整數集)或者是hashtable(散列表也叫雜湊表)。

當資料都是整數並且數量不多時,使用intset作為底層資料結構;當有除整數以外的資料或者資料量增多時,使用hashtable作為底層資料結構。

127.0.0.1:6379> sadd myset 111 222 333
(integer) 3
127.0.0.1:6379> object encoding myset
"intset"
127.0.0.1:6379> sadd myset hahaha
(integer) 1
127.0.0.1:6379> object encoding myset
"hashtable"

 

inset的資料結構為:

typedef struct intset {
    // 編碼方式
    uint32_t encoding;
    // 集合包含的元素數量
    uint32_t length;
    // 儲存元素的陣列
    int8_t contents[];
} intset;

 

intset底層實現為有序、無重複數的陣列。 intset的整數型別可以是16位的、32位的、64位的。如果數組裡所有的整數都是16位長度的,新加入一個32位的整數,那麼整個16的陣列將升級成一個32位的陣列。升級可以提升intset的靈活性,又可以節約記憶體,但不可逆。

2.5 Zset

Redis中的Zset,也叫做有序集合。它的底層是ziplist(壓縮列表)或 skiplist(跳躍表)。

壓縮列表前文已經介紹過了,同理是在元素數量比較少的時候使用。此處主要介紹跳躍列表。

跳錶

跳躍列表,顧名思義是可以跳的,跳著查詢自己想要查到的元素。大家可能對這種資料結構比較陌生,雖然平時接觸的少,但它確實是一個各方面效能都很好的資料結構,可以支援快速的查詢、插入、刪除操作,開發難度也比紅黑樹要容易的多。

為什麼跳錶有如此高的效能呢?它究竟是如何“跳”的呢?跳錶利用了二分的思想,在陣列中可以用二分法來快速進行查詢,在連結串列中也是可以的。

舉個例子,連結串列如下:

假設要找到10這個節點,需要一個一個去遍歷,判斷是不是要找的節點。那如何提高效率呢?mysql索引相信大家都很熟悉,可以提高效率,這裡也可以使用索引。抽出一個索引層來:

這樣只需要找到9然後再找10就可以了,大大節省了查詢的時間。

還可以再抽出來一層索引,可以更好地節約時間:

這樣基於連結串列的“二分查詢”支援快速的插入、刪除,時間複雜度都是O(logn)。

由於跳錶的快速查詢效率,以及實現的簡單、易讀。所以Redis放棄了紅黑樹而選擇了更為簡單的跳錶。

Redis中的跳躍表:

typedef struct zskiplist {
     // 表頭節點和表尾節點
    struct zskiplistNode *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;

 

zadd---zslinsert---平均O(logN), 最壞O(N)

zrem---zsldelete---平均O(logN), 最壞O(N)

zrank--zslGetRank---平均O(logN), 最壞O(N)

總結

本文大概介紹了Redis的5種常用資料型別的底層實現,希望大家結合原始碼和資料更深入地瞭解。

資料結構之美在Redis中體現得淋漓盡致,從String到壓縮列表、快速列表、散列表、跳錶,這些資料結構都適用在了不同的地方,各司其職。

不僅如此,Redis將這些資料結構加以升級、結合,將記憶體儲存的效率效能達到了極致,正因為如此,Redis才能成為眾多網際網路公司不可缺少的高效能、秒級的key-value記憶體資料庫。

作者:楊亨

拓展閱讀:Redis閒談(1):構建知識圖譜

來源:宜信技術學院