Redis筆記(1)數據結構與對象
1.前言
此系列博客記錄redis設計與實現一書的筆記,提取書本中的知識點,省略相關說明,方便查閱。
2.基本數據結構
2.1 簡單動態字符串SDS(simple dynamic string)
結構體定義:
len: buf數組中已使用字節的數量,使用len判斷實際內容長度,而不是‘\0‘字符
free: 未使用字節的數量,查詢該值,杜絕內存溢出
buf[]: 實際分配空間及存儲內容(字節數組,保證二進制安全,怎麽存怎麽取)
保留C語言的習慣,字符串以‘\0‘結束,好處在於可以兼容使用C的API。
分配策略:
1.修改後小於1MB,需要擴展,分配與修改後len相同長度的額外空間,即buf總大小變成len+len+1(結尾字符)
2.修改後大於等於1MB,需要擴展,分配1MB,即buf大小變成len+1MB
釋放策略:惰性釋放,長度變短時不進釋放空間,有需要時釋放。
2.2 鏈表
在redis中使用廣泛,列表鍵底層實現之一就是鏈表,當一個列表鍵包含了數量較多的元素,或者元素都是比較長的字符串,會使用鏈表作為列表鍵的底層實現。
LLEN,LRANGE等命令:即list
鏈表節點結構體定義listNode:
listNode *prev: 前置節點
listNode *next: 後置節點
void *valud: 節點的值
鏈表節點有前後節點,可以構成雙向鏈表
鏈表的結構體list定義
listNode *head: 表頭節點
listNode *tail: 表尾節點
unsigned long len: 節點數量
void *(*dup)(void *ptr): 節點值復制函數
void (*free) (void *ptr): 節點值釋放函數
int (*match)(void *ptr, void *key): 節點值對比函數
特性:
雙端:前後查詢O(1)
無環:以NULL節點終結
頭尾指針:獲取頭尾迅速O(1)
長度計數:O(1)
多態: 三個函數可以支持保存各種不通類型的值
2.3 字典map
使用廣泛,比如數據庫就是使用字典作為底層實現的。SET msg "hello world" 或者 HLEN HGETALL等命令。就是map和普通鍵值對。
哈希表dictht定義:
dictEntry **table: 哈希表數組
unsigned long size: 哈希表大小
unsigned long sizemask: 哈希表大小掩碼,用於計算索引值,等於size-1
unsigned long used: 哈希表有節點的數量
哈希節點dictEntry結構定義:
void *key: 鍵
union {
void *val;
unit64_t u64;
int64_t s64;
}v:值
dictEntry *next:下一個哈希表節點,形成鏈表,解決hash沖突
字典dict結構定義:
dictType *type: 類型特定函數,多態,針對不同類型的鍵值對
void *privdata: 私有數據
dictht ht[2]: 哈希表 2個空間用於rehash操作,一般使用0,下標1的在rehash時使用
int trehashidx: rehash索引,不進行時,為-1
表位置計算:
1.hash值 MurmurHash2算法
2.hash & sizemask
rehash步驟:
0.rehash條件:
負載因子: used / 表大小
滿足一個即可:
1)沒有執行BGSAVE或者BGREWRITEAOF命令時,負載因子大於等於1
2)執行了上訴兩個命令,且負載因子大於等於5
3)負載因子小於0.1時,自動收縮
1.為ht[1]哈希表分配空間:
擴展操作或收縮操作,ht[1]的大小>=ht[0].used*2^n(n取值使得右邊最小)
比如used為7 那麽新表大小為8=2^3>7
2.設置rehashidx,將其設置為0,表示開始rehash
3.從hash表的rehashidx下標的鏈表開始,重新計算hash值,將其完全移動至另一個hash表,之後rehashidx增加1
4.rehash期間,所有的增刪改查操作會在兩個hash表上進行:
新增的只會添加在ht[1]表上,查找先在ht[0]上進行,沒找到再去ht[1]查找。修改刪除一樣。
單線程執行保證沒有並發問題,漸進式rehash也是為了避免數據太大,造成一段時間內停止服務,所以一個下標一個下標移動。擴容機制以及hash算法保證hash碰撞不會過於集中,決定了單個下標數據不會很多。
5.ht[0]上的所有鍵值對都放入到ht[1]後,rehash完成,rehashidx置為-1。
6.轉移完畢後釋放ht[0]空間,將ht[1]設置成ht[0],在ht[1]新創建一個空白的hash表,為下一次rehash準備。
2.4 跳躍表
跳躍表只有在有序集合鍵中和集群節點中使用到了,其余時候沒有作用。有序集合鍵如ZRANGE ZCARD命令相關。
跳躍表節點zskiplistNode結構:
zskiplistNode *backward: 後退指針
double socre: 分值
robj *obj: 成員對象
zskiplistLevel {
zskiplistNode *forward: 前進指針
unsigned int span: 跨度
} level[]; // 層
層level數組可以包含多個元素,每個元素包含一個指向其他節點的指針,可以通過層加快訪問速度。層數越多,速度越快。創建節點時會隨機生成一個1~32的數值作為該節點的level數組大小。
跨度指的是兩個節點之間的距離,NULL的前指針跨度為0。跨度可用來計算目標節點在跳躍表中的位置。
後退指針只有一個,只能退到上一個節點。
跳躍表中的成員通過分值從小到大排列,成員對象指向一個字符串對象,即SDS值。成員對象唯一,多個成員對象可以有相同的分值
跳躍表zskiplist結構:
zskiplistNode *header, *tail:頭尾節點
unsigned long length: 表中節點數量
int level: 表中層數最大的節點的層數
2.5 整數集合
整數集合用於數量不多,且都是整型的情況,比如 SADD numbers 1 3 5 7 9, OBJECT ENCODING numbers可以顯示 "intset".
可以保存int16_t、int32_t、int64_t類型的值。
整數集合intset結構:
uint32_t encoding: 編碼方式
unit32_t length: 包含的元素數量
int8_t contents[]: 保存元素的數組
集合中每一個元素都是contents中的一個項item,從小到大排列,不能重復。contents聲明為int8_t,但是實際上不保存這個類型的值,真正類型取決於encoding,INTSET_ENC_INT(16,32,64)。contents的數組大小等於encoding*length。比如是16位的整型,5個元素,contents大小就是80。
升級過程:比如原本encoding是16的整型,現在新增一個32的,就需要升級。
1.根據新類型和元素數量,擴展contents的大小,分配空間。
2.將原元素轉換成新元素類型,放入正確的位置,保證順序不變。
3.將新元素添加到底層數組裏面。
4.修改encoding的值,length+1
每次添加元素都可能造成升級,每次升級要處理所有的元素,時間復雜度為o(N)。
升級提升靈活度,隨意將16,32,64位的整型添加到集合中。節約內存,要存放16,32,64位的整型最好的方法是直接使用64位的,升級可以減少內存消耗。
inset集合不支持降級操作。
2.6 壓縮列表
壓縮列表是列表鍵和哈希鍵的底層實現之一。列表鍵中包含少量列表項,並且列表項是小整數值或長度較短的字符串,就會使用壓縮列表。
比如RPUSH lst 1 3 5 10086 "hello" "world" OBJECT ENCODING lst 輸出”ziplist"
HMSET profile “name" "jack" ...
壓縮列表用於節約內存,特殊編碼的連續內存塊組成的順序型數據結構。一個壓縮列表可以保存一個字節數組或者整數值。
具體結構按照下列順序排列:
zlbytes unit32_t 4字節 記錄整個壓縮列表占用內存字節數,在進行內存重分配或計算zlend位置時使用。
zltail unit32_t 4字節 記錄壓縮列表尾節點距起始地址有多少字節,通過這個程序無需遍歷整個壓縮列表可以確定尾節點位置。
zllen unit16_t 2字節 記錄壓縮列表包含的節點數量,小於65535時為真,等於需要遍歷才能計算出來。
entryX 列表節點 不定 各個節點,長度由節點保存內容決定
zlend unit8_t 1字節 特殊值0xFF 用於標記壓縮列表的末端
壓縮節點的構成entryX:
1.字節數組長度為下面3種之一:
長度小於等於63 2^6-1字節的字節數組
長度小於等於16383 2^14-1字節的字節數組
長度小於等於2^32-1字節的字節數組
2.整型可以是下面6種之一:
4位長度,0~12之間的無符號整型
1字節長的有符合整數
3字節長的有符號整數
int16_t類型整數
int32_t類型整數
int64_t類型整數
3.由下面三個內容構成一個節點:
previous_entry_length: 記錄了壓縮列表中前一個節點的長度,該字段長度可以是1字節(前節點長度小於254)或5字節(大於等於254,第一個節點會設置成254後面4個節點保存前一個節點長度)。通過這個屬性可以遍歷到頭節點。
encoding:保存數據的類型以及長度。由開頭前2位判斷類型及該字段的長度,後面的判斷長度
00:該字段占用一個字節,後面6個比特位是數組長度,即長度小於等於63的字節數組
01:該字段占用2個字節,後面6+8個比特位是數組長度,即長度小於等於2^14-1個字節數組
10:該字段占用5個字節,後面4個字節記錄長度,即長度小於等於2^32-1個字節數組
11000000: int16_t類型整數
11010000: int32_t類型整數
11100000: int64_t類型整數
11110000: 24位有符號整數
11111110: 8位有符號整數
1111xxxx: 該值的時候就沒有content屬性了,因為本身xxxx就足夠保存0~12之間的值了。
content:具體的內容。由encoding決定
連鎖更新:
由於previous_entry_length記錄了之前的節點長度,但是其有兩種形態1字節和5字節。這裏就會產生一個麻煩,原本前一個節點是小於254個字節的,本節點使用的1字節形態的previous_entry_length記錄了這個情況,現在在該節點前插入了一個大於254個字節長度的節點,要將其改成5字節形態。但是這又產生了一個麻煩,比如當前節點是253個字節,由於previous_entry_length由1變成了5,增加了4個字節長度,導致該節點超過了254個字節,進而後置節點的previous_entry_length也要改變形態,這樣可能會發生連鎖反應。
刪除節點一樣會導致連鎖反應,都稱之為連鎖更新。最壞的情況下需要N次空間重新分配,每次最壞O(N),所以最壞為O(N^2)。但是由於可能性太小了,而且只要不是大面積的連鎖更新都是可以接受的。所以這個不需要過度擔心。
3.對象
redis中沒有直接使用上述數據結構來實現鍵值對數據庫,而是基於此實現了一個對象系統,包含:字符串對象,列表對象,哈希對象,集合對象和有序集合對象。redis使用了引用計數技術來控制內存回收機制,不再使用的對象會被釋放。
對象基本結構redisObject:
unsigned type:4 類型
unsigned encoding:4 編碼
void *ptr 指向底層的數據結構的指針
...
類型有5種:REDIS_STRING 字符串對象、REDIS_LIST 列表對象、REDIS_HASH 哈希對象、REDIS_SET 集合對象、REDIS_ZSET 有序集合對象。使用type命令可以查看對象類型。
encoding決定ptr指向的數據結構,一共有以下幾種數據結構:
REDIS_ENCODING_INT long類型的整數
REDIS_ENCODING_EMBSTR embstr編碼的簡單動態字符串
REDIS_ENCODING_RAW 簡單動態字符串
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 雙端鏈表
REDIS_ENCODING_ZIPLIST 壓縮列表
REDIS_ENCODING_INSET 整數集合
REDIS_ENCODING_SKIPLIST 跳躍表和字典
不同的對象類型有相關的encoding,下面是一個對應關系:
String: int、embstr、raw
list: ziplist、linkedlist
hash: ziplist、ht
set: intset、ht
zset: ziplist、 skiplist
使用OBJECT ENCODING可以看見對象當前編碼。
3.1 字符串對象
字符串對象的編碼可以是int、raw、embstr。
如果一個字符串對象保存的是整型,並且可以用long類型表示,就會設置成int編碼。
如果一個字符串對象保存的是字符串值,並且長度大於39字節,那麽使用SDS來保存這個字符串值,設置編碼為raw
如果一個字符串對象保存的是字符串值,並且長度小於等於39字節,那麽使用embstr來保存這個字符串值。
raw和embstr的區別在於,raw會開辟兩次空間,創建redisObject和sdshdr結構,但是embstr只分配一塊連續的區間,依次包含redisObject和sdshdr。對應的釋放空間也只需要一次。
編碼的轉換:
int和embstr在條件滿足的情況下會被轉換成raw編碼。
假如一個字符串對象中保存的是整數值,但是使用了append命令追加了字符串,就會變成raw:set number 123, append number " xxx"
embstr編碼的字符串沒有編寫任何的修改程序,所以該類型實際上是只讀的。修改的時候都會變成raw,再執行修改命令。
3.2 列表對象
列表對象的編碼可以是ziplist或者是linkedlist。
編碼的轉換:
滿足以下2個條件的時候會使用ziplist:
列表對象保存的所有字符串元素長度都小於64個字節。
列表對象保存的元素數量小於512個。
不滿足上述條件的會轉成linkedlist。
這兩個條件可以進行修改,配置list-max-ziplist-value和list-max-ziplist-entries。
3.3 哈希對象
哈希對象的編碼可以是ziplist或者是hashtable。
ziplist的時候,鍵值是緊挨在一起的,先鍵放入尾端,再把值放入尾端。
編碼的轉換:
滿足以下2個條件的時候會使用ziplist:
列表對象保存的所有字符串元素長度都小於64個字節。
列表對象保存的元素數量小於512個。
不滿足上述條件會轉成hashtable。
這兩個條件可以進行修改,配置hash-max-ziplist-value和hash-max-ziplist-entries。
3.4 集合對象
集合對象的編碼可以是intset或者是hashtable
編碼的轉換:
滿足以下兩個條件時,對象使用intset編碼:
集合對象保存的所有元素都是整數值
集合對象保存的元素數量不超過512個
不滿足上述條件的會使用hashtable編碼
第二個上限值是可以修改的,配置set-max-intset-entries選項。
3.5 有序集合對象
有序集合的編碼可以是ziplist或者skiplist。
ziplist作為實現的時候,第一個節點保存元素的成員,第二個元素則保存元素的分值。
zset結構包含一個zsl跳躍表和一個dict字典表。跳躍表使得有序集合可以進行範圍型操作,如ZRANK和ZRANGE命令。字典表可以O(1)復雜度查找給定成員的分值,ZSCORE命令。
編碼的轉化:
滿足以下兩個條件時,對象使用ziplist編碼:
有序集合保存的元素數量小於128個
有序集合保存的所有元素成員的長度都小於64字節。
不能滿足以上兩個條件的有序集合對象使用skiplist編碼。
以上兩個條件的值可以修改,配置zset-max-ziplist-entries和zset-max-ziplist-value。
4.其它概念
4.1 類型檢查和命令多態
redis的命令基本上分兩種類型:
所有鍵都能執行,比如delete、expire、rename、type、object。
特定類型鍵執行,比如:
SET、GET、APPEND、STRLEN只能針對字符串鍵執行
HDEL、HSET、HGET、HLEN只能針對哈希鍵執行
RPUSH、LPOP、LINSERT、LLEN只能針對列表鍵執行
SADD、SPOP、SINTER、SCARD只能針對集合鍵執行
ZADD、ZCARD、ZRANK、ZSCORE只能針對有序集合鍵執行
為了不執行錯誤的命令,都會先進行類型檢查,通過redisObject type來實現。
相同的類型但是編碼是不同的,意味著命令要適應不同編碼結構,這就是命令的多態。
4.2 內存回收
redis構建了一個引用計數技術來實現內存回收機制,在適當的時候自動釋放對象,進行內存回收。
redisObject中有一個refcount字段用於引用計數。
創建對象時,引用計數的值會被初始化為1.
被使用時,+1
不被使用時,-1
為0時,釋放內存。
4.3 對象共享
鍵A創建了一個整型100,鍵B也要創建一個整型100,做法有兩種:新建一個或者使用A的。當然後者更節省內存。
1.B指向A的值
2.值的引用計數+1
redis在初始化服務器的時候,會創建一萬個字符串對象,包含從0~9999所有整數值。
redis只共享整型值的字符串對象,因為字符串類型的驗證相同操作復雜,多個對象時更復雜。
4.4 對象的空閑時長
redisObject還有一個屬性是lru屬性,記錄最後一次被程序訪問的時間。OBJECT IDLETIME 可以打印出對象從當前時間到最後一次訪問時間的空閑時長。這個命令不會修改lru的值。
空閑時長的一個作用在於,如果服務器打開了maxmemory選項,並且服務器用於回收內存的算法是volatile-lru或者是allkeys-lru,當占用內存超過了maxmemory設置的上限,空轉時長較高的鍵會被優先釋放,從而回收內存。
配置文件中maxmemory和maxmeory-policy選項介紹了相關信息。
Redis筆記(1)數據結構與對象