一文看懂redis各種資料型別的底層實現
redis資料結構
redis已經成為了現今構建網際網路應用最常用的中介軟體之一,它對使用者暴露的資料型別有string、list、hash、set、sorted set等,我們在使用這些資料型別的同時,肯定也會對其內部的設計和實現感興趣。這篇文章將探究這些資料型別底層的資料結構實現,比如sds、ziplist、quicklist、dict、skiplsit等
本文引用的redis原始碼版本為redis 6.2
一、SDS
redis string物件底層使用SDS來實現。
redis雖然使用C語言開發,卻沒有直接使用C語言的預設字串,而是自己構建了一種名叫SDS(simple dynamic string)的資料結構。
熟悉go語言的同學會發現,這個資料結構跟go語言中的slice切片的底層實現非常相似,下面來詳細解析
sds結構定義在sds.h中
typedef char *sds;
sds定義成char *型別,這是為了和傳統C語言的字串保持相容,但是sds並不等同char *,實際上sds還包含了一個header結構
struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; };
一個完證的sds字串,由記憶體地址上前後相鄰的兩部分組成:一個header,和一個char [] 位元組陣列。下面來看header的構成:
-
len
:sds字串的長度 -
alloc
:sds字串底層位元組陣列的最大容量(不包括header),即buf陣列的長度-1(不包括結束符 ‘\0’) -
buf
:用於儲存字串的位元組陣列,沒有具體長度標識,是一個柔性陣列,柔性陣列並不佔用結構體的空間 -
flags
:用來標記不同型別的sds,只使用了低3位來標記,高5位暫未使用(除了sdshdr5)
根據alloc
和len
的型別不同,sds分為幾種型別
- sdshdr5:使用高5位(5 msb)來表示len, alloc固定是5 msb的最大值 2^5,因而沒有辦法動態擴容
- sdshdr8:長度為小於2^8的字串
- sdshdr16:長度為小於2^16的字串
- sdshdr32:長度為小於2^32的字串
- sdshdr64:其他所有長度都使用此類
__attribute__ ((__packed__))
關鍵字用來告訴編譯器這個結構體不在遵循記憶體對齊規則,而是欄位成員緊緻排列,方便通過偏移量來訪問結構體中的欄位,比如buf
向前偏移一個位元組,就可以訪問到flags
欄位,以此來判斷這個sds的型別
之所以要定義5種header,是為了能讓不同的sds字串可以使用不同的header,短的sds字串能夠使用小的header,儘可能的節省記憶體。
上圖展示了兩個有不同header sds字串的記憶體佈局,以及如何根據sds字串指標獲取對應header起始指標。先從sds字元指標向地址偏移一個位元組獲取到flag,從flag的低3bit得到header的型別,知道了header的型別,也就很容易的能夠計算出header的起始指標位置。
sds字串的header,其實隱藏在真正的字串資料的前面(低地址方向)。這樣定義有如下幾個好處:
- header和資料相鄰,這有利於減少記憶體碎片,提高儲存效率
- 雖然header有多個型別,但sds可以用統一的char *來表達。且它與傳統的C語言字串保持型別相容。我們可以直接把它傳給C函式,比如使用printf進行列印。
SDS特點(區別於C字串)
- 記憶體預分配
- redis作為資料庫,字串資料經常被頻繁修改。C語言的字串增長或者縮短,就必須對整個底層陣列進行一次重新分配,SDS可以在字串增長時,給SDS分配額外未使用空間(buf),以減少記憶體分配次數
- 字串小於1M時,翻倍擴容,大於1M時,每次增加1M
- 惰性釋放
- SDS縮短字串時,只需要通過len欄位記錄新的字串長度,而不必立即重新分配記憶體
- SDS也提供了相應的API,讓我們在需要的時候釋放SDS未使用的空間
- 二進位制安全
- SDS使用len欄位而不依懶於空字元
\0
來判斷字串的結尾,所以可以使用buf來儲存任何二進位制格式的資料 - 比如我們可以使用redis來儲存ProtoBuf壓縮過的二進位制資料
- SDS使用len欄位而不依懶於空字元
- 相容C字串
- SDS被定義為char *,並且會在字串的末尾加上
\0
,所以能夠相容C字串,以複用C字串相關函式
- SDS被定義為char *,並且會在字串的末尾加上
二、連結串列List
redis3.2之前,list物件的底層實現之一是連結串列,但在3.2版本之後,list型別就改成用quicklist+ziplist來實現了。不過連結串列依然在釋出訂閱、監視器、儲存客戶端狀態資訊儲存、客戶端輸出緩衝區等場景被使用,所以做個簡單介紹
雙鏈表list結構定義在adlist.h中
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
unsigned long len;
} list;
listNode
用兩個指標 prev
、next
儲存左右節點,用void *指標value
儲存節點的值
list
儲存了頭尾指標head
、tail
,方便連結串列的操作。len
來記錄連結串列的長度。dup
、free
、match
這三個成員是三個函式的指標,指向用於實現多型連結串列所需的型別特定函式
- dup用於複製節點值
- free用於釋放節點儲存的值
- match用於比較節點的值與輸入值是否相等
三、壓縮列表ziplist
zset和hash物件在元素個數比較少的時候,底層使用壓縮列表ziplist來進行儲存。
ziplist是redis為了節約記憶體而設計的,是由一系列特殊編碼的連續記憶體塊組成的序列型資料結構。它本質上就是一大塊連續的位元組陣列,但是會比常規的數字更節省記憶體,因為陣列要求每個元素的大小相同,這就導致很多記憶體的浪費。而壓縮列表的元素長度則是動態的、不固定的。
壓縮列表
屬性 | 型別 | 長度 | 用途 |
---|---|---|---|
zlbytes |
uint32_t |
4 位元組 |
記錄整個壓縮列表佔用的記憶體位元組數:在對壓縮列表進行記憶體重分配, 或者計算 zlend 的位置時使用。 |
zltail |
uint32_t |
4 位元組 |
記錄表尾節點與起始地址的偏移量: 用來計算表位節點的地址 |
zllen |
uint16_t |
2 位元組 |
記錄了壓縮列表包含的節點數量: 當這個屬性的值小於 UINT16_MAX (65535 )時, 這個屬性的值就是壓縮列表包含節點的數量; 當這個值等於 UINT16_MAX 時, 節點的真實數量需要遍歷整個壓縮列表才能計算得出。 |
entryX |
列表節點 | 不定 | 壓縮列表包含的各個節點,節點的長度由節點儲存的內容決定。 |
zlend |
uint8_t |
1 位元組 |
特殊值 0xFF (十進位制 255 ),用於標記壓縮列表的末端。 |
壓縮列表節點
每個壓縮列表節點有三部分構成
- prevrawlen 表示前一個節點的長度
- 當ziplist倒敘遍歷時,可以根據prevrawlen計算出前一個節點的起始地址
- 前一個節點的長度小於254位元組,那麼prevlen使用一個位元組
- 前一個節點的長度大於254位元組,那麼prevlen使用5個位元組,第一個位元組固定是0xFE(254),後四個位元組用來儲存前一個節點的長度(第一個位元組為什麼不是255呢?因為255被
zlend
使用了)
- encoding 記錄了節點的長度 ,和content屬性所儲存的資料的型別
- 長度有三種,一個位元組,兩個位元組,五個位元組
- encoding告訴我們content表示的是字串(位元組陣列)還是整數型別(int16_t、int32_t...)
- content 儲存節點的值,可以儲存任意二進位制序列(位元組陣列)
壓縮列表插入和查詢的平均複雜度為O(N),因為ziplist沒有指標,所以每次插入或者刪除節點,都要重新調整節點的位置,因而會發生記憶體拷貝,所以ziplist只適合用來儲存少量的資料。
四、快速列表quicklist
linkedlist 每個節點只能儲存一個元素,而且需要使用 prev 和 next 兩個指標,佔據了16個位元組,空間附加值太高,而且每個節點都是單獨分配,會加劇記憶體的碎片化。因此在 redis3.2 版本之後,使用了新的 quicklist+ziplist 結構代替了 linkedlist 來實現 list 列表物件。
quicklist還是一個雙向連結串列,只不過每個節點都是一個壓縮列表,先來看兩個相關配置引數:
- 壓縮列表的長度由引數
list-max-ziplist-size
來控制,使用者可以自己設定,預設值是 -2代表8KB(正數代表個數,負數的意義參考redis.conf) - 當列表很長時,兩端的資料是最容易被訪問的,而中間的資料訪問頻次比較低,所以redis提供了一個選項,能夠把中間的節點的資料進行壓縮,進一步節約記憶體。引數
list-compress-depth
代表quicklist兩端不被壓縮的節點個數,預設是特殊值0,表示都不壓縮
quicklist相關結構在quicklist.h中定義
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF;
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* total count of all entries in all ziplists */
unsigned long len; /* number of quicklistNodes */
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
tip:quicklistNode結構體中count、encoding等欄位後面跟著一個冒號和一個數字,這是C語言結構體的位域的語法,它告訴編譯器,這個欄位所使用的位的寬度,可以把資料以位的形式緊湊儲存,並允許程式設計師對此結構的位進行操作
quicklist節點
quicklistNode欄位含義如下:
- prev、next:是前後指標
- zl:是一個char* 位元組陣列指標,如果節點沒有被壓縮,它指向一個ziplist;否則,它指向一個
quicklistLZF
結構 - sz:size,ziplist的位元組大小
- count: 節點的元素個數
- encoding:標識節點的編碼方式,1代表ziplist,2代表壓縮過的節點(quicklistLZF)
- container:1代表直接儲存資料(預留,實際未實現),2代表使用ziplist儲存資料
- recompress:如果我們訪問了被壓縮的節點資料,需要把資料暫時解壓,這時設定recompress=1,表示後面需要把資料重新壓縮
壓縮過的節點
quicklistLZF表示一個被壓縮過的ziplist,redis使用LZF演算法壓縮ziplist
- sz:壓縮後的大小
- compressed:一個柔性陣列,儲存壓縮後的資料
quicklist
struct quicklist 是快速列表的的真正結構
- head、tail 首位指標
- count:元素個數(所有節點元素個數的總和)
- len:節點數
- fill:儲存
list-max-ziplist-sized
的值,表示ziplist的容量 - compress:儲存
list-compress-depth
的值,表示節點壓縮深度
上圖是一個 list-max-ziplist-size=3 list-compress-dept=2 的quicklist 示意圖
五、字典dict
redis的hash物件底層使用字典來實現,字典是一種儲存鍵值對的抽象資料結構,在redis中應用相當廣泛,redis的資料庫也是使用字典來作為底層實現的
dict相關結構在dict.h中定義
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
typedef struct dictType {
uint64_t (*hashFunction)(const void *key);
void *(*keyDup)(void *privdata, const void *key);
void *(*valDup)(void *privdata, const void *obj);
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
void (*keyDestructor)(void *privdata, void *key);
void (*valDestructor)(void *privdata, void *obj);
int (*expandAllowed)(size_t moreMem, double usedRatio);
} dictType;
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
} dict;
雜湊表
dictht定義了雜湊表的結構, ht是hash table意思
-
table
:是一個元素為dictEntry *的陣列的指標,每個dictEntry儲存一個鍵值對 -
size
:是table陣列的長度 -
sizemask
:總是等於size-1,一個鍵的雜湊值可以和sizemask做與運算(比取餘效率高),快速得到其在陣列中的索引位置 -
used
:記錄雜湊表已有鍵值對個數
雜湊表節點
dictEntry定義了雜湊表節點
-
key
:用來儲存鍵值對中的鍵,void *指標型別,可以指向任何值 -
v
:用來儲存鍵值對中的值,是一個union結構,可以直接儲存uint64_t、int64_t或double型別,也可以是void* -
next
:指向另一個雜湊表節點,當多個鍵的索引相同時,組成連結串列來解決雜湊衝突問題,這種通過連結串列解決雜湊衝突的方法通常叫做鏈地址法(拉鍊法)。速度考慮,新節點總是被新增在連結串列頭的位置(複雜度O(1))
字典
dict就是redis的字典結構了
-
type
:指向一個dictType結構,用來實現不同型別的多型字典 -
privdata
:儲存了需要傳給特定型別函式的可選引數 -
ht
:包含了兩個dctht,一般情況下,字典只會使用ht[0],ht[1]只會在對ht[0]進行rehash的時候使用。 -
rehashidx
:記錄了rehash的進度,沒有在進行rehash的話,值為-1 -
pauserehash
:大於0時表示rehash被暫停
dictType則儲存了一些用於特定型別鍵值對的函式
-
hashFunction
:用來計算鍵的雜湊值 -
keyDup
、valDup
、keyDestructor
、valDestructor
:分別是複製鍵和值的函式,銷燬鍵和值的函式 -
keyCompare
:比較鍵的函式
rehash
當雜湊表儲存過多的鍵值對時,雜湊衝突的可能性大大增減,為了保持O(1)的訪問複雜度,需要對雜湊表進行擴容;當雜湊表中的鍵被大量的刪除,縮容能釋放雜湊表的記憶體佔用。
負載因子(load factor)表示雜湊表已儲存的元素個數和雜湊表陣列的比值 load_factor = ht[0].uesd / ht[0].size
- 當雜湊表的負載因子大於1的時候,redis會對雜湊表執行擴容操作(如果伺服器正在執行RDB備份,或者AOF檔案重寫,負載因子大於5才開始擴容)
- 當負載因子小於0.1的時候,會執行縮容操作
rehash的過程
- 為ht[1]分配表空間,其大小總是2^n,即每次都是2的整數次冪的倍數擴縮容,這樣sizemask才能用來快速計算雜湊值的索引位置
- 將儲存在ht[0]上的鍵值對rehash到ht[1]上,即重新計算鍵的雜湊值和其在ht[1]索引值,然後將鍵值對放在ht[1]指定的位置上
- 當ht[0]包含的所有鍵值對都遷移到了ht[1]上後,釋放ht[0],將ht[1]設定為ht[0],然後在ht[1]新建一個空表雜湊表,為下次rehash做準備
漸進式rehash
rehash這個動作不是一次性集中完成的,而是分多次,漸進式的
- rehashidx記錄著rehash的進度,他的值是當前需要被rehash的索引位置,值設定為0,代表rehash開始(沒有rehash時是-1)
- rehash期間,每次對字典執行增刪改查的同時,也會將ht[0]上rehashidx索引位置的全部鍵值對rehash到ht[1]上,然後將rehashidx的值加一
- 除了在對字典操作時會執行rehash操作,伺服器的定時任務也會主動的進行rehash,可以通過activerehashing引數配置,預設值yes
- rehash結束後rehashidx會被設定為-1
- 漸進式rehash執行期間,新新增到字典的價值對會被儲存在ht[1]中,查詢、刪除、更新的操作會先在ht[0]中查詢對應的鍵,如果沒找到的話,就會繼續在ht[1]裡面進行查詢,然後執行對應的邏輯
六、跳躍表skiplist
跳躍表是一種有序的資料結構,有兩大特點
- 支援平均O(logN)時間複雜度的節點查詢
- 可以通過順序性操作來批量處理節點
大部分情況下跳躍表的效率可以和平衡樹媲美,而且跳躍表實現更簡單,範圍查詢也比平衡樹更方便,效率更好
跳躍表是zset的實現之一(集合包含元素較少多,或者字串較長時)
redis跳躍表由zskiplistNode
和 zskiplist
實現,在server.h中定義
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
typedef struct zskiplistNode {
sds ele; //sds 字串
double score; //分值
struct zskiplistNode *backward; //後退指標,每次只能退一個節點
struct zskiplistLevel {
struct zskiplistNode *forward; //每層都有一個前進指標,層數越高,跨度越大
unsigned long span; //跨度記錄兩個節點之間的距離
} level[]; //層高在1-32之間,根據冪次定律,越大的數出現的概率越小
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
先看兩個相關的常量
-
ZSKIPLIST_MAXLEVEL
:跳躍表的最大層數為32層 -
ZSKIPLIST_P
:如果一個節點有第i層(i>=1)指標,那麼它有第(i+1)層指標的概率為p=1/4。
跳躍表結構
zskiplist結構,跟普通的雙鏈表很像
-
header
、tail
:分別指向跳躍表的頭節點、尾結點 -
length
:跳躍表的長度,即節點個數(頭節點不計算在內) -
level
:跳躍表當前的最大層高,即層數最大的節點的層高(頭節點的層數不計算在內)
跳躍表節點
跳躍表的節點zskiplistNode
-
ele
:儲存了一個sds,即zset中的成員,成員不可重複 -
score
:ele對應的分數,跳躍表中,跳躍表節點按照分數從小到大排序 -
backward
:節點的後退指標,指向前一個節點,用來從後向前遍歷跳躍表 -
level
:level陣列也是柔性陣列,具體層數不確定,在新建節點的時候會根據ZSKIPLIST_P計算出當前節點的層數,越大的層數出現的概率越低-
forward
:每一層裡有一個前進指標,指向後面的某個節點 -
span
:記錄前進指標的跨度,是當前節點和前進指標指向的節點的距離,用於計算元素排名(rank)
-
上圖展示了一個跳躍表示例,以及遍歷一個跳躍表的過程(虛線)
skiplist與平衡樹的比較
- 在做範圍查詢的時候,平衡樹比skiplist操作要複雜。在平衡樹上,我們找到指定範圍的小值之後,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這裡的中序遍歷並不容易實現。而在skiplist上進行範圍查詢就非常簡單,只需要在找到小值之後,對第1層連結串列進行若干步的遍歷就可以實現。
- 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯複雜,而skiplist的插入和刪除只需要修改相鄰節點的指標,操作簡單又快速。
- 從記憶體佔用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節點包含2個指標(分別指向左右子樹),而skiplist每個節點包含的指標數目平均為1/(1-p),具體取決於引數p的大小。如果像Redis裡的實現一樣,取p=1/4,那麼平均每個節點包含1.33個指標,比平衡樹更有優勢。
- 從演算法實現難度上來比較,skiplist比平衡樹要簡單得多。
七、整數集合
intset是set集合型別的底層實現之一,當set的元素數量小於set-max-intset-entries
(預設值 512),並且只包含整數型別時,redis會使用intset作為set的底層實現
intsest可以儲存int16_t、int32_t、int64_t型別的整數,原始碼定義在intset.h和intset.c
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
#define INTSET_ENC_INT16 (sizeof(int16_t)) // 值為2
#define INTSET_ENC_INT32 (sizeof(int32_t)) // 值為4
#define INTSET_ENC_INT64 (sizeof(int64_t)) // 值為8
-
contents
:contents柔性陣列是整數集合的底層實現,集合元素在陣列中從小到大排列,並且不重複。雖然其宣告為int8_t型別的陣列,但實際上contents陣列的真正型別取決於encoding的值 -
encoding
:encoding的值決定了contents陣列的型別,有三種取值- INTSET_ENC_INT16,表示contents是一個int16_t型別的陣列
- INTSET_ENC_INT32,表示contents是一個int32_t型別的陣列
- INTSET_ENC_INT64,表示contents是一個int64_t型別的陣列
-
length
:記錄了整數集合的元素個數
如果我們將一個新元素插入到整數集合中,並且新元素的型別比現有的所有元素型別都要大,整數集合就要先進行升級操作。升級的步驟大致如下:
- 根據新元素的型別為整數集合底層陣列重新分配空間大小(包含新元素的空間)
- 將底層陣列的型別都調整為新的型別,並從後向前依次將他們放到升級後的位置上
- 觸發升級的新元素要麼小於所有元素(負數),要麼大於所有元素,所以新元素要麼放在陣列開頭,要麼放在陣列末尾
intset通過自動升級底層陣列來適應新元素,所以我們可以隨意的將int16_t、int32_t、int64_t型別的整數新增到集合中,而不是像普通的陣列那樣只能儲存一種型別的元素;另外又可以確保升級只在需要的時候進行,以此來儘量的節省記憶體。
另外,intset不支援降級操作。
不像ziplist能夠儲存任何二進位制序列,每個元素採用變長編碼,intset只能用來儲存整數,而且每個元素的長度相同(編碼相同)
以上我們陸續介紹了redis中用到的主要資料結構,但redis並沒有直接使用這些資料結構來實現資料庫的各種型別,而是基於這些資料結構建立了一個物件系統。下一篇文章,將詳細的介紹redis的物件系統。
參考: