Redis做爲一個不少大廠用來解決併發和快速響應的利器,極高的性能讓它獲得不少公司的青睞,我認爲Redis的高性能和其底層的數據結構的設計和實現是分不開的。使用過Redis的同窗可能都知道Redis有五種基本的數據類型:string、list、hash、set、zset;這些只是Redis服務對於客戶端提供的第一層面的數據結構。其實內部的數據結構仍是有第二個層面的實現,Redis利用第二個層面的一種或多種數據類型來實現了第一層面的數據類型。我想有和我同樣對Redis底層數據結構感興趣的人,那咱們就一塊兒來研究一下Redis高性能的背後的實現底層數據結構的設計和實現。html
這裏咱們主要研究的是第二層面的數據結構的實現,其Redis中五種基本的數據類型都是經過如下數據結構實現的,咱們接下來一個一個來看:node
String類型無論是在什麼編程語言中都是最多見和經常使用的數據類型,Redis底層是使用C語言編寫的,可是Redis沒有使用C語言字符串類型,而是自定義了一個Simple Dynamic String (簡稱SDS)做爲Redis底層String的實現,其SDS相比於C語言的字符串有如下優點:git
下面是Redis中SDS的部分源碼。sds源碼github
typedef char *sds;
/* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */ 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
的內部組成,咱們發現sds被定義成了char類型,難道Redis的String類型底層就是char嗎?其實sds爲了和傳統的C語言字符串保持類型兼容,因此它們的類型定義是同樣的,都是char *,可是sds不等同是char。web
真正存儲數據的是在sdshdr
中的buf
中,這個數據結構除了能存儲字符串之外,還能夠存儲像圖片,視頻等二進制數據,。SDS爲了兼容C語言的字符串,遵循了C語言字符串以空字符結尾的慣例, 因此在buf
中, 用戶數據後總跟着一個\0
. 即圖中 "數據" + "\0" 是爲所謂的buf
。另外注意sdshdr有五種類型,其實sdshdr5是不使用的,其實使用的也就四種。定義這麼多的類型頭是爲了能讓不一樣長度的字符串可使用不一樣大小的header。這樣短字符串就能使用較小的 header,從而節省內存。redis
SDS概覽以下圖:算法
除了sdshdr5以外,其它4個header的結構都包含3個字段數據庫
\0
結束符在內)。
\0
字節)。
Redis對外暴露的是list
數據類型,它底層實現所依賴的內部數據結構其實有幾種,在Redis3.2版本以前,鏈表的底層實現是linkedList
和zipList
,可是在版本3.2以後 linkedList
和zipList
就基本上被棄用了,使用quickList
來做爲鏈表的底層實現,ziplist雖然被被quicklist替代,可是ziplist仍然是hash和zset底層實現之一。編程
這裏咱們使用Redis2.8版本能夠看出來,當我插入鍵 k5 中 110條比較短的數據時候,列表是ziplist編碼,當我再往裏面插入10000條數據的時候,k5的數據編碼就變成了linkedlist。api
Redis3.2版本以前,list
底層默認使用的zipList
做爲列表底層默認數據結,在必定的條件下,zipList
會轉成 linkedList
。Redis之因此這樣設計,由於雙向鏈表佔用的內存比壓縮列表要多, 因此當建立新的列表鍵時, 列表會優先考慮使用壓縮列表, 而且在有須要的時候, 才從壓縮列表實現轉換到雙向鏈表實現。在什麼狀況下zipList
會轉成 linkedList
,須要知足一下兩個任意條件:
這兩個條件是能夠修改的,在 redis.conf 中:
list-max-ziplist-value 64
list-max-ziplist-entries 512
複製代碼
注意:這裏列表list的這個配置,只有在Redis3.2版本以前的配置中才能找到,由於Redis3.2和3.2之後的版本去掉了這個配置,由於底層實現不在使用ziplist,而是採用quicklist來做爲默認的實現。
當鏈表entry數據超過5十二、或單個value 長度超過64,底層就會將zipList轉化成linkedlist編碼,linkedlist是標準的雙向鏈表,Node節點包含prev和next指針,能夠進行雙向遍歷;還保存了 head 和 tail 兩個指針。所以,對鏈表的表頭和表尾進行插入的時間複雜度都爲O (1) , 這是也是高效實現 LPUSH 、 RPOP、 RPOPLPUSH 等命令的關鍵。
雖然Redis3.2版本之後再也不直接使用ziplist來實現列表建,可是底層仍是間接的利用了ziplist來實現的。
壓縮列表是Redis爲了節省內存而開發的,Redis官方對於ziplist的定義是(出自Redis源碼中src/ziplist.c註釋):
The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values,where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time. However, because every operation requires a reallocation of the memory used by the ziplist, the actual complexity is related to the amount of memory used by the ziplist
翻譯:ziplist是一個通過特殊編碼的雙向鏈表,它的設計目標就是爲了提升存儲效率。ziplist能夠用於存儲字符串或整數,其中整數是按真正的二進制表示進行編碼的,而不是編碼成字符串序列。它能以O(1)的時間複雜度在表的兩端提供
push
和pop
操做。
ziplist 將列表中每一項存放在先後連續的地址空間內,每一項因佔用的空間不一樣,而採用變長編碼。當元素個數較少時,Redis 用 ziplist 來存儲數據,當元素個數超過某個值時,鏈表鍵中會把 ziplist 轉化爲 linkedlist,字典鍵中會把 ziplist 轉化爲 hashtable。因爲內存是連續分配的,因此遍歷速度很快。
ziplist 是一個特殊的雙向鏈表,ziplist沒有維護雙向指針:prev next;而是存儲上一個 entry的長度和 當前entry的長度,經過長度推算下一個元素在什麼地方,犧牲讀取的性能,得到高效的存儲空間,這是典型的"時間換空間"。
ziplist使用連續的內存塊,每個節點(entry)都是連續存儲的;ziplist 存儲分佈以下:
每一個字段表明的含義。
zlbytes
: 32bit,表示ziplist佔用的字節總數(也包括
zlbytes
自己佔用的4個字節)。
zltail
: 32bit,表示ziplist表中最後一項(entry)在ziplist中的偏移字節數。
zltail
的存在,使得咱們能夠很方便地找到最後一項(不用遍歷整個ziplist),從而能夠在ziplist尾端快速地執行push或pop操做。
zllen
: 16bit, 表示ziplist中數據項(entry)的個數。zllen字段由於只有16bit,因此能夠表達的最大值爲2^16-1。這裏須要特別注意的是,若是ziplist中數據項個數超過了16bit能表達的最大值,ziplist仍然能夠來表示。那怎麼表示呢?這裏作了這樣的規定:若是
zllen
小於等於2^16-2(也就是不等於2^16-1),那麼
zllen
就表示ziplist中數據項的個數;不然,也就是
zllen
等於16bit全爲1的狀況,那麼
zllen
就不表示數據項個數了,這時候要想知道ziplist中數據項總數,那麼必須對ziplist從頭至尾遍歷各個數據項,才能計數出來。
entry
: 表示真正存放數據的數據項,長度不定。一個數據項(entry)也有它本身的內部結構。
zlend
: ziplist最後1個字節,是一個結束標記,值固定等於255。
ziplist每個存儲節點、都是一個 zlentry。zlentry的源碼在ziplist.c 第 268行
/* We use this function to receive information about a ziplist entry. * Note that this is not how the data is actually encoded, is just what we * get filled by a function in order to operate more easily. */ typedef struct zlentry { unsigned int prevrawlensize; /* prevrawlensize是指prevrawlen的大小,有1字節和5字節兩種*/ unsigned int prevrawlen; /* 前一個節點的長度 */ unsigned int lensize; /* lensize爲編碼len所需的字節大小*/ unsigned int len; /* len爲當前節點長度*/ unsigned int headersize; /* 當前節點的header大小 */ unsigned char encoding; /*節點的編碼方式:ZIP_STR_* or ZIP_INT_* */ unsigned char *p; /* 指向節點的指針 */ } zlentry; 複製代碼
由於ziplist採用了一段連續的內存來存儲數據,減小了內存碎片和指針的內存佔用。其次表中每一項存放在先後連續的地址空間內,每一項因佔用的空間不一樣,而採用變長編碼,並且當節點較少時,ziplist更容易被加載到CPU緩存中。這也是ziplist能夠作到壓縮內存的緣由。
經過上面咱們已經清楚的瞭解的ziplist的數據結構,在ziplist中每一個zlentry都存儲着前一個節點所佔的字節數,而這個數值又是變長的,這樣的數據結構可能會引發ziplist的連鎖更新
。假設咱們有一個壓縮鏈表 entry1 entry2 entry3 .......,entry1的長度正好是 253個字節,那麼按照咱們上面所說的,entry2.prevrawlen 記錄了entry1的長度,使用1個字節來保存entry1的大小,假如如今在entry1 和 entry2之間插入了一個新的 new_entry節點,而new_entry的大小正好是254,那此時entry2.prevrawlen就須要擴充爲5字節;若是entry2的總體長度變化又引發了entry3.prevrawlen的存儲長度變化,如此連鎖的更新直到尾結點或者某一個節點的prevrawlen足以存放以前節點的長度,固然刪除節點也是一樣的道理,只要咱們的操做的節點以後的prevrawlen發生了改變就會出現這種連鎖更新。
因爲ziplist連鎖更新的問題,也使得ziplist的優缺點極其明顯;ziplist被設計出來的目的是節省內存,這種結構並不擅長作修改操做。一旦數據發生改動,就會引起內存從新分配,可能致使內存拷貝。也使得後續Redis採起折中,利用quicklist替換了ziplist。
基於上面所說,咱們已經知道了ziplist的缺陷,因此在Redis3.2版本之後,列表的底層默認實現就使用了quicklist來代替ziplist和linkedlist?接下來咱們就看一下quicklist的數據結構是什麼樣的,爲何使用quicklist做爲Redis列表的底層實現,它的優點相比於ziplist優點在哪裏,接下來咱們就一塊兒來看一下quicklist的具體實現。下面是我基於Redis3.2的版本作的操做,這裏咱們能夠看到列表的底層默認的實現是quicklist
對象編碼。
quicklist總體的數據結構以下:
quicklist源碼 redis/src/quicklist.h結構定義以下:
typedef struct quicklist {
quicklistNode *head; // 頭結點 quicklistNode *tail; // 尾結點 unsigned long count; // 全部ziplist數據項的個數總和 unsigned long len; //quicklistNode的節點個數 int fill : QL_FILL_BITS; //ziplist大小設置,經過配置文件中list-max-ziplist-size參數設置的值。 unsigned int compress : QL_COMP_BITS; //節點壓縮深度設置,經過配置文件list-compress-depth參數設置的值。 unsigned int bookmark_count: QL_BM_BITS; quicklistBookmark bookmarks[]; } quicklist; 複製代碼
其實就算使用的quicklist結構來代替ziplist,那quicklist也是有必定的缺點,底層仍然使用了ziplist,這樣一樣會有一個問題,由於ziplist是一個連續的內存地址,若是ziplist過小,就會產生不少小的磁盤碎片,從而下降存儲效率,若是ziplist很大,那分配連續的大塊內存空間的難度也就越大,也會下降存儲的效率。如何平衡ziplist的大小呢?那這樣就會取決於使用的場景,Redis提供了一個配置參數list-max-ziplist-size
能夠調整ziplist的大小。
當取正值的時候,表示按照數據項個數來限定每一個quicklist節點上的ziplist長度。好比,當這個參數配置成5的時候,表示每一個quicklist節點的ziplist最多包含5個數據項。當取負值的時候,表示按照佔用字節數來限定每一個quicklist節點上的ziplist長度。這時,它只能取-1到-5這五個值,每一個值含義以下:
當咱們數據量很大的時候,最方便訪問的數據基本上就是隊列頭和隊尾的數據(時間複雜度爲O(1)),中間的數據被訪問的頻率比較低(訪問性能也比較低,時間複雜度是O(N),若是你的使用場景符合這個特色,Redis爲了壓縮內存的使用,提供了list-compress-depth
這個配置可以把中間的數據節點進行壓縮。quicklist內部節點的壓縮算法,採用的LZF——一種無損壓縮算法。
這個參數表示一個quicklist兩端不被壓縮的節點個數。參數list-compress-depth
的取值含義以下:
quicklist是由一個個quicklistNode的雙向鏈表構成。
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; char compressed[]; } quicklistLZF; 複製代碼
quicklist中的每一個節點都是一個quicklistNode,其中各個字段的含義以下:
quicklist結構結合ziplist和linkedlist的優勢,quicklist權衡了時間和空間的消耗,很大程度的優化了性能,quicklist由於隊頭和隊尾操做的時間複雜度都是O(1),因此Redis的列表也能夠被做用隊列來使用。
經過上圖咱們可以看到hash鍵的底層默認實現的數據結構是ziplist,隨着hash鍵的數量變大時,數據結構就變成了hashtable,雖然這裏的咱們看到的對象編碼格式hashtable,可是Redis底層是使用字典dict來完成了Hash鍵的底層數據結構,不過字典dict的底層實現是使用哈希表來實現的。Redis服務對於客戶端來講,對外暴露的類型是hash,其底層的數據結構實現有兩種,一種是壓縮列表(ziplist),另一種則是字典(dict);關於ziplist的,咱們在說鏈表(list)的時候已經說過了,這裏不重複去說了。咱們這裏就着重的去看一下字典(dict)的具體實現。
這裏仍是要說一下什麼狀況下會從ziplist
轉成hashtable
呢?redis.conf中提供了兩個參數
hash-max-ziplist-entries 512
hash-max-ziplist-value 64 複製代碼
字典算是Redis比較重要的一個數據結構了,Redis數據庫自己就能夠當作是一個大的字典,Redis之因此會有很高的查詢效率,其實和Redis底層使用的數據類型是有關係的,一般字典的實現會用哈希表做爲底層的存儲,redis的字典實現也是基於時間複雜度爲O(1)的hash算法。
Redis源碼其結構定義以下:dict源碼定義
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); } 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 */ unsigned long iterators; /* number of iterators currently running */ } dict; 複製代碼
下圖能更清晰的展現dict的數據結構。
經過上圖和源碼咱們能夠很清晰的看到,一個字典dict的構成由下面幾項構成:
這裏最重要的仍是dictht這個結構,dictht定義了一個哈希表,其結構由如下組成:
總體看下來,有點相似於Java中HashMap的實現,在處理哈希衝突和數組的大小都是和Java中的HashMap是同樣的,可是這裏有一點不同就是關於擴容的機制,Redis這裏利用了兩個哈希表,另一個哈希表就是擴容用的。Redis中的字典和Java中的HashMap同樣,爲了保證隨着數據量增大致使查詢的效率問題,要適當的調整數組的大小,也就是rehash,也就是咱們熟知擴容。咱們這裏不說Java中的HashMap的擴容了,這裏主要看一下Redis中對於字典的擴容。
那麼何時纔會rehash呢?條件: 1. 服務器目前沒有執行的BGSAVE命令或者BGREWRUTEAOF命令,而且哈希表的負載因子大於等於1; 2. 服務器目前正在執行BGSAVE命令或者BGREWRUTEAOF命令,而且哈希表的負載因子大於等於5;
那究竟是如何進行rehash的,根據上面源碼和數據結構圖能夠看到,字典中定義一個大小爲2的哈希表數組,前面咱們也說到了,在不進行擴容的時候,全部的數據都是存儲在第一個哈希表中,只有在進行擴容的時候纔會用到第二個哈希表。當須要進行rehash的時候,將dictht[1]的哈希表大小設置爲須要擴容以後的大小,而後將dictht[0]中的全部數據從新rehash到dictht[1]中;並且Redis爲了保證在數據量很大的狀況rehash不太過消耗服務器性能,其採用了漸進式rehash,當數據量很小的時候咱們一次性的將數據從新rehash到擴容以後的哈希表中,對Redis服務的性能是能夠忽略不計的,可是當Redis中hash鍵的數量很大,幾十萬甚至上百萬的數據時,這樣rehash對Redis帶來的影響是巨大的,甚至會致使一段時間內Redis中止服務,這是不能接受的。
Redis服務在須要rehash的時候,不是一次性將dictht[0]中的數據所有rehash到dictht[1]中,而是分批進行依次將數據從新rehash到dictht[1]的哈希表中。這就是採用了分治的思想,就算在數據量很大的時候也能避免集中式rehash帶來的巨大計算量。當進行rehash的期間,對字典的增刪改查都會操做兩個哈希表,由於在進行rehahs的時候,兩個哈希表都有說句,當咱們在一個哈希表中查找不到數據的時候,也會去另外一個哈希表查數據。在rehash期間的新增,不會在第一個哈希表中新增,會直接把新增的數據保存到第二個哈希表中這樣能夠確保第一個哈希表中的數據只減不增,直到數據爲空結束rehash。
熟悉Java的同窗可能會想起HashMap中擴容算法,其實包括從容量的設計上和內部的結構都有不少類似的地方,有興趣的同窗能夠去了解一下,也能夠參考我寫的這篇文章《Java1.8中HashMap的騷操做》,相比於Redis中的字典的rehash的方式,我更喜歡的是Java中對於HashMap中精妙的rehahs的方式,其思想仍是很是值得咱們去借鑑的。