redis內部資料結構深入淺出
最大感受,無論從設計還是原始碼,Redis都儘量做到簡單,其中運用到的原理也通俗易懂。特別是原始碼,簡潔易讀,真正做到clean and clear, 這篇文章以unstable分支的原始碼為基準,先從大體上整理Redis的物件型別以及底層編碼。 當我們在本文中提到Redis的“資料結構”,可能是在兩個不同的層面來討論它。
- 第一個層面,是從使用者的角度,string,list,hash,set,sorted set
- 第二個層面,是從內部實現的角度,屬於更底層的實現, ht(dict),raw,embstr,intset,sds,ziplist,quicklist,skiplist
在討論任何一個系統的內部實現的時候,我們都要先明確它的設計原則,這樣我們才能更深刻地理解它為什麼會進行如此設計的真正意圖。
-
儲存效率(memory efficiency)。Redis是專用於儲存資料的,它對於計算機資源的主要消耗就在於記憶體,因此節省記憶體是它非常非常重要的一個方面。這意味著Redis一定是非常精細地考慮了壓縮資料、減少記憶體碎片等問題。
-
快速響應時間(fast response time)。與快速響應時間相對的,是高吞吐量(high throughput)。Redis是用於提供線上訪問的,對於單個請求的響應時間要求很高,因此,快速響應時間是比高吞吐量更重要的目標。有時候,這兩個目標是矛盾的。
-
單執行緒(single-threaded)。Redis的效能瓶頸不在於CPU資源,而在於記憶體訪問和網路IO。而採用單執行緒的設計帶來的好處是,極大簡化了資料結構和演算法的實現。相反,Redis通過非同步IO和pipelining等機制來實現高速的併發訪問。顯然,單執行緒的設計,對於單個請求的快速響應時間也提出了更高的要求。
比如:Redis一個重要的基礎資料結構:dict。
-
dict是一個用於維護key和value對映關係的資料結構,與很多語言中的Map或dictionary類似。Redis的一個database中所有key到value的對映,就是使用一個dict來維護的。不過,這只是它在Redis中的一個用途而已,它在Redis中被使用的地方還有很多。比如,一個Redis hash結構,當它的field較多時,便會採用dict來儲存。再比如,Redis配合使用dict和skiplist來共同維護一個sorted set
-
dict本質上是為了解決演算法中的查詢問題(Searching),一般查詢問題的解法分為兩個大類:一個是基於各種平衡樹,一個是基於雜湊表。我們平常使用的各種Map或dictionary,大都是基於雜湊表實現的。在不要求資料有序儲存,且能保持較低的雜湊值衝突概率的前提下,基於雜湊表的查詢效能能做到非常高效,接近O(1),而且實現簡單。
-
dict也是一個基於雜湊表的演算法。和傳統的雜湊演算法類似,它採用某個雜湊函式從key計算得到在雜湊表中的位置,採用拉鍊法解決衝突,並在裝載因子(load factor)超過預定值時自動擴充套件記憶體,引發重雜湊(rehashing)。Redis的dict實現最顯著的一個特點,就在於它的重雜湊。它採用了一種稱為增量式重雜湊(incremental rehashing)的方法,在需要擴充套件記憶體時避免一次性對所有key進行重雜湊,而是將重雜湊操作分散到對於dict的各個增刪改查的操作中去。這種方法能做到每次只對一小部分key進行重雜湊,而每次重雜湊之間不影響dict的操作。dict之所以這樣設計,是為了避免重雜湊期間單個請求的響應時間劇烈增加,這與前面提到的“快速響應時間”的設計原則是相符的。
一、物件型別
redis 是 key-value 儲存系統,其中 key 型別一般為字串,而 value 型別則為 redis 物件(redis object),可以繫結各種型別的資料,譬如 string、list 和set,redis.h 中定義了 struct redisObject,它是一個簡單優秀的資料結構
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
|
其中,void *ptr 已經給了我們無限的遐想空間了(把最後一個指標留給了真正的資料)
每種型別的物件至少都有兩種或以上的encoding方式,不同編碼可以在不同的使用場景上優化物件的使用場景,用TYPE命令可檢視某個鍵值對的型別
二、物件編碼
不同型別和編碼的物件
1 2 3 4 5 6 7 8 9 10 11 |
|
OBJECT ENCODING 對不同編碼的輸出
1 2 3 4 5 6 7 8 |
|
本質上,Redis就是基於這些資料結構而構造出一個物件儲存系統。
關於redisObject
-
ptr指標,指向物件的底層實現資料結構
-
encoding屬性記錄物件所使用的編碼
-
淘汰時鐘,Redis 對資料集佔用記憶體的大小有「實時」的計算,當超出限額時,會淘汰超時的資料
-
引用計數,一個 Redis 物件可能被多個指標引用。當需要增加或者減少引用的時候,必須呼叫相應的函式,程式設計師必須遵守這一準則
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
得益於 Redis 是單程序單執行緒工作的,所以增加/減少引用的操作不必保證原子性,這在 memcache 中是做不到的(memcached 是多執行緒的工作模式,需要做到互斥)
1、Keys
redis是一個key-value db,首先key也是字串型別,但是key中不能包括邊界字元,由於key不是binary safe的字串,所以像”my key”和”mykey\n”這樣包含空格和換行的key是不允許的 ,順便說一下在redis內部並不限制使用binary字元,這是redis協議限制的,”\r\n”在協議格式中會作為特殊字元。
redis 1.2以後的協議中部分命令已經開始使用新的協議格式了(比如MSET),總之目前還是把包含邊界字元當成非法的key,另外關於key的一個格式約定介紹下,object-type:id:field。比如user:1000:password,blog:xxidxx:title
2、string
string是redis最基本的型別,而且string型別是二進位制安全的。意思是redis的string可以包含任何資料,比如jpg圖片或者序列化的物件。從內部實現來看其實string可以看作byte陣列,最大上限是1G位元組。
1 2 3 4 5 |
|
buf是個char陣列用於存貯實際的字串內容。其實char和c#中的byte是等價的,都是一個位元組 ,len是buf陣列的長度,free是陣列中剩餘可用位元組數。 由此可以理解為什麼string型別是二進位制安全的了。因為它本質上就是個byte陣列。當然可以包含任何資料了。 另外string型別可以被部分命令按int處理,比如incr等命令,redis的其他型別像list,set,sorted set ,hash它們包含的元素與都只能是string型別。
編碼
字串物件的編碼可以是 INT、RAW 或 EMBSTR。如果儲存的是整數值並且可以用long表示,那麼編碼會設定為INT。當字串值得長度大於44位元組使用RAW,小於等於44位元組使用EMBSTR。
Redis在3.0引入EMBSTR編碼,這是一種專門用於儲存短字串的一種優化編碼方式,這種編碼和RAW編碼都是用sdshdr簡單動態字串結構來表示。RAW編碼會呼叫兩次記憶體分配函式來分別建立redisObject和sdshdr結構,而EMBSTR只調用一次記憶體分配函式來分配一塊連續的空間儲存資料,比起RAW編碼的字串更能節省記憶體,以及能提升獲取資料的速度。
不過要注意!EMBSTR是不可修改的,當對EMBSTR編碼的字串執行任何修改命令,總會先將其轉換成RAW編碼再進行修改;而INT編碼在條件滿足的情況下也會被轉換成RAW編碼。
兩種字串物件編碼方式的區別
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
字串物件編碼的優化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
|
3、list
list型別其實就是一個每個子元素都是string型別的雙向連結串列。所以[lr]push和[lr]pop命令的演算法時間複雜度都是O(n),另外list會記錄連結串列的長度。所以llen操作也是O(n).連結串列的最大長度是(2的32次方-1)。 我們可以通過push,pop操作從連結串列的頭部或者尾部新增刪除元素。這使得list既可以用作棧,也可以用作佇列。 有意思的是list的pop操作還有阻塞版本的。當我們[lr]pop一個list物件,如果list是空,或者不存在,會立即返回nil。但是阻塞版本的b[lr]pop可以則可以阻塞, 當然可以加超時時間,超時後也會返回nil。為什麼要阻塞版本的pop呢,主要是為了避免輪詢。 如果我們用list來實現一個工作佇列。執行任務的thread可以呼叫阻塞版本的pop去 ,獲取任務這樣就可以避免輪詢去檢查是否有任務存在。當任務來時候工作執行緒可以立即返回,也可以避免輪詢帶來的延遲。
編碼
Redis3.0之前的列表物件的編碼可以是ziplist或者linkedlist。當列表物件儲存的字串元素的長度都小於64位元組並且儲存的元素數量小於512個,使用ziplist編碼,可以通過修改配置list-max-ziplist-value和list-max-ziplist-entries來修改這兩個條件的上限值、兩個條件任意一個不滿足時,ziplist會變為linkedlist。
從3.2開始Redis只使用quicklist作為列表的編碼,quicklist是ziplist和雙向連結串列的結合體,quicklist的每個節點都是一個ziplist。可以通過修改list-max-ziplist-size來設定一個quicklist節點上的ziplist的長度,取正值表示通過元素數量來限定ziplist的長度;負數表示按照佔用位元組數來限定,並且Redis規定只能取-1到-5這五個負值
1 2 3 4 5 |
|
另外配置引數list-compress-depth表示一個quicklist兩端不被壓縮的節點個數
1 2 3 4 5 |
|
這裡採用的是一種叫LZF的無失真壓縮演算法
4、hash
雜湊物件的編碼可以是ziplist或者hashtable。使用ziplist 編碼時,儲存同一鍵值對的兩個節點總是緊挨在一起,鍵節點在前,值節點在後,同時滿足以下兩個條件將使用ziplist編碼:
-
所有鍵和值的字串長度小於64位元組
-
鍵值對的數量小於512個
不能滿足這兩個條件的都需要使用hashtable編碼。以上兩個上限值可以通過hash-max-ziplist-value和hash-max-ziplist-entries來修改
hash是一個string型別的field和value的對映表,它的新增,刪除操作都是O(1),hash特別適合用於儲存物件。 相較於將物件的每個欄位存成單個string型別,將一個物件儲存在hash型別中會佔用更少的記憶體,並且可以更方便的存取整個物件。
省記憶體的原因是新建一個hash物件時開始是用zipmap(又稱為small hash)來儲存的。 這個zipmap其實並不是hash table,但是zipmap相比正常的hash實現可以節省不少hash本身需要的一些元資料儲存開銷。 儘管zipmap的新增,刪除,查詢都是O(n),但是由於一般物件的field數量都不太多。 所以使用zipmap也是很快的,也就是說新增刪除平均還是O(1)。 如果field或者value的大小超出一定限制後,redis會在內部自動將zipmap替換成正常的hash實現,這個限制可以在配置檔案中指定
1 2 |
|
5、set
集合物件的編碼可以是intset或者hashtable。當滿足以下兩個條件時使用intset編碼:
-
所有元素都是整數值
-
元素數量不超過512個
可以修改set-max-intset-entries設定元素數量的上限。使用hashtable編碼時,字典的每一個鍵都是字串物件,每個字串物件包含一個集合元素,字典的值全部設定為null。
redis的set是string型別的無序集合。set元素最大可以包含(2的32次方-1)個元素。 set的是通過hash table實現的,所以新增,刪除,查詢的複雜度都是O(1)。hash table會隨著新增或者刪除自動的調整大小。 需要注意的是調整hash table大小時候需要同步(獲取寫鎖)會阻塞其他讀寫操作。 可能不久後就會改用跳錶(skip list)來實現跳錶已經在sorted set中使用了 關於set集合型別除了基本的新增刪除操作,其他有用的操作還包含集合的取並集(union),交集(intersection), 差集(difference)。
6、sorted set
有序集合物件的編碼可以是ziplist或者skiplist。同時滿足以下條件時使用ziplist編碼:
-
元素數量小於128個
-
所有member的長度都小於64位元組
以上兩個條件的上限值可通過zset-max-ziplist-entries和zset-max-ziplist-value來修改。
ziplist編碼的有序集合使用緊挨在一起的壓縮列表節點來儲存,第一個節點儲存member,第二個儲存score。ziplist內的集合元素按score從小到大排序,score較小的排在表頭位置。
skiplist編碼的有序集合底層是一個命名為zset
的結構體,而一個zset結構同時包含一個字典和一個跳躍表。跳躍表按score從小到大儲存所有集合元素。而字典則儲存著從member到score的對映,這樣就可以用O(1)的複雜度來查詢member對應的score值。雖然同時使用兩種結構,但它們會通過指標來共享相同元素的member和score,因此不會浪費額外的記憶體。
和set一樣sorted set也是string型別元素的集合,不同的是每個元素都會關聯一個double型別的score。sorted set的實現是skip list和hash table的混合體,當元素被新增到集合中時,一個元素到score的對映被新增到hash table中,所以給定一個元素獲取score的開銷是O(1),另一個score到元素的對映被新增到skip list並按照score排序,所以就可以有序的獲取集合中的元素。 新增,刪除操作開銷都是O(1)和skip list的開銷一致,redis的skip list實現用的是雙向連結串列,這樣就可以逆序從尾部取元素。 sorted set最經常的使用方式應該是作為索引來使用,我們可以把要排序的欄位作為score儲存,物件的id當元素儲存。