1 前言
Redis的5種數據類型(String,Hash,List,Set,Sorted Set),每種數據類型都提供了最少兩種內部的編碼格式,而且每個數據類型內部編碼方式的選擇對用戶是完全透明的,Redis會根據數據量自適應地選擇較優化的內部編碼格式。
查看某個鍵的內部編碼格式,使用命令object encoding keyname
Reids的每個鍵值內部都是使用叫redisObject
這個C語言結構體保存的,代碼如下:
- type:表示鍵值的數據類型,也就是那5種基本數據類型。
- encoding:表示鍵值的內部編碼方式,
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
- refcount:表示該鍵值被引用的數量,即一個鍵值可被多個鍵引用
2 string(字符串)
Redis的字符串是簡單動態字符串(SDS),是可以修改的字符串,內部結構實現上類似于 Java 的 ArrayList,采用預分配冗余空間的方式來減少內存的頻繁分配。
如圖所示,內部為當前字符串實際分配的空間capacity一般是要高于實際字符串長度 len。
當字符串長度小于 1M 時,擴容都是加倍現有的空間,如果超過 1M,擴容時一次只會多擴 1M 的空間。需要注意的是字符串最大長度為 512M。
<1> 內部結構:
在內存中以字節數組的形式存在,SDS的結構是帶有長度信息的字節數組。
struct SDS<T> {
T capacity; // 數組容量
T len; // 數組長度
byte flags; // 特殊標識位,不理睬它
byte[] content; // 數組內容
}
capacity
表示所分配數組的長度,len
表示字符串的實際長度;
content
保存的就是字符串內容,和c語言一樣以0x\0作為結束字符,但是這個結束字符不包括在len中。
需要注意的是:
創建字符串時,也就是初次分配時,len和capacity一樣長,不會多分配冗余空間。執行append之后開始冗余,因為平時的應用中大多數字符串只有只讀的需求,一旦遇到append指令意味著它是需要支持修改的,于是才給分配了冗余空間。
<2> 字符串編碼格式:
- int編碼(保存long型的64位有符號整數,即長度小于20)
當儲存的值是64 位有符號整數類型的時候將會采用 int 編碼,這時可以使用鍵值自增操作
Redis啟動時會預先建立10000個分別存儲0~9999的redisObject變量作為共享對象,這就意味著如果 set字符串的鍵值在 0~10000 之間的話,則可以直接指向共享對象而不需要再建立新對象,此時鍵值不占空間!
embstr編碼(保存長度小于44字節的字符串)
embedded string
,表示嵌入式的String,從內存結構上來說,就是字符串 sds 結構體與其對應的 redisObject 對象分配在 同一塊連續的內存空間,這就仿佛字符串 sds 嵌入在 redisObject 對象之中一樣raw編碼(保存長度大于44字節的字符串)
與embstr不同的是,此時態字符串 sds 的內存與其依賴的 redisObject 的內存不再連續了
3 list(列表)
Redis 的列表相當于 Java 語言里面的 LinkedList,注意它是雙向鏈表而不是數組。這意味著 list 的插入和刪除操作非常快,時間復雜度為 O(1),獲取頭結點和尾節點也很快,但是索引定位即隨機定位很慢,時間復雜度為 O(n),這點讓人非常意外。常常用作消息隊列。
當列表彈出了最后一個元素之后,該數據結構自動被刪除,內存被回收。
用作隊列,先進先出
在右邊push,從左邊pop,先進先出。
用作棧,先進后出
rpush books python java golang
(integer) 3
rpop books
"golang"
rpop books
"java"
<1> 內部結構
Redis中鏈表的內部實現不是簡單的雙向列表,在數據量較少的時候它的底層存儲結構為一塊連續內存,稱之為ziplist
(壓縮列表);當數據量較多的時候將會變成鏈表的結構,后來因為鏈表需要 prev 和 next 兩個指針占用內存很多(64bit系統的指針是8個字節),改用 ziplist+鏈表的混合結構,稱之為 quicklist(快速鏈表)。在新的版本中 Redis 鏈表統一使用 quicklist來存儲。
ziplist 壓縮列表
struct ziplist<T>{
int32 zlbytes; //壓縮列表占用字節數
int32 zltail_offset; //最后一個元素距離起始位置的偏移量,用于快速定位到最后一個節點
int16 zllength; //元素個數
T[] entries; //元素內容
int8 zlend; //結束位 0xFF
}
如圖所示:
有了ztail_offset就可以快讀定位到最后一個節點,這樣就可以倒序遍歷了,ziplist支持雙向遍歷。
entry的內部實現:
struct entry{
int<var> prevlen; //前一個 entry 的長度
int<var> encoding; //元素類型編碼
optional byte[] content; //元素內容
}
當 ziplist 倒序遍歷的時候,就是通過這個pervlen定位到前一個元素位置的;
encoding 保存了 content 的編碼類型;
content 則是保存的元素內容,它是optional 類型表示是這個字段是可選的.當content 是很小的整數時,他會內聯到 encoding 字段的尾部。
增加元素
因為 ziplist 都是緊湊存儲,沒有冗余空間 (對比一下 Redis 的字符串結構)。意味著插入一個新的元素就需要調用 realloc 擴展內存。取決于內存分配器算法和當前的 ziplist 內存大小,realloc 可能會重新分配新的內存空間,并將之前的內容一次性拷貝到新的地址,也可能在原有的地址上進行擴展,這時就不需要進行舊內容的內存拷貝。
如果 ziplist 占據內存太大,重新分配內存和拷貝內存就會有很大的消耗。所以 ziplist 不適合存儲大型字符串,存儲的元素也不宜過多。
quicklist 快速列表
quicklist是ziplist和linkedlist的混合體,下面是quicklist和node的數據結構:
struct quicklist{
quicklistNode* head; //指向頭結點
quicklistNode* tail; //指向尾節點
long count; //元素總數
int nodes; //quicklistNode節點的個數
int compressDepth; //壓縮算法深度 LZF
...
}
將linkedlist按段切分,每一段使用ziplist來緊湊存儲,多個ziplist之間使用雙向指針串接起來。
quicklist 內部默認單個 ziplist 長度為 8k 字節,超出了這個字節數,就會新起一個 ziplist。ziplist 的長度由配置參數list-max-ziplist-size決定。
4 hash(字典)
Redis中的字典相當于Java中的HashMap,無序字典。其內部結構與Java的HashMap也是一致的,同樣的數組+鏈表二維結構。在第一維Hash的數組位置發生碰撞的時候,就會將碰撞的元素使用鏈表串接起來。
redis的字典的值是能是字符串,不同于Java的HashMap一次性rehash(十分耗時),redis為了高性能,不堵塞服務,所以采用了漸進式rehash策略。
漸進式rehash會在rehash的同時,保留新舊兩個hash結構,查詢時會同時查詢兩個hash結構,然后在后續的定時任務中以及 hash 操作指令中,循序漸進地將舊 hash 的內容一點點遷移到新的 hash 結構中。當搬遷完成了,就會使用新的hash結構取而代之。
hashtable[1]在擴容的時候會有數據,并且優先查找hashtable[0],查不到就再去查hashtable[1],擴容過程中,新來的數據直接插入到hashtable[1]中
<1> dict內部結構
如上圖所示,dict結構內部包含兩個hashtable,通常情況下只有一個hashtable是有值的,但是在dict 擴容縮容時,需要分配新的 hashtable,然后進行漸進式搬遷,這時候兩個 hashtable 存儲的分別是舊的 hashtable 和新的 hashtable。待搬遷結束后,舊的 hashtable 被刪除,新的 hashtable 取而代之。
所以,字典數據結構的精華就落在了hashtable結構上,hashtable的結構和Java的HashMap幾乎是一樣的,都是通過分桶的方式解決 hash 沖突。第一維是數組,第二維是鏈表。數組中存儲的是第二維鏈表的第一個元素的指針。
struct dictEntry {
void* key;
void* val;
dictEntry* next; // 鏈接下一個 entry
}
struct dictht {
dictEntry** table; // 二維
long size; // 第一維數組的長度
long used; // hash 表中的元素個數
...
}
<2> 漸進式rehash
大字典的擴容是比較耗時間的,需要重新申請新的數組,然后將舊字典所有鏈表中的元素重新掛接到新的數組下面,這是一個O(n)級別的操作,作為單線程的Redis表示很難承受這樣耗時的過程,所以redis使用漸進式rehash。
一般來說,搬遷操作埋伏在當前字典的后續指令中(來自客戶端的hset/hdel指令等),但是有可能客戶端閑下來了,沒有后續指令來觸發這個搬遷,這個時候,redis還會在定時任務中對字典進行主動搬遷。
<3> 擴容條件
正常情況下,當hash表中元素的個數在等于第一維數組的長度時,就會開始擴容,擴容的新數組是原數組大小的2倍,不過如果redis正在做 bgsave,為了減少內存頁的過多分離 (Copy On Write),Redis 盡量不去擴容 (dict_can_resize),但是如果 hash 表已經非常滿了,元素的個數已經達到了第一維數組長度的 5 倍 (dict_force_resize_ratio),說明 hash 表已經過于擁擠了,這個時候就會強制擴容。
什么是bgsave?
bgsave在命令執行之后立即返回ok,然后redis fork出一個新子進程,原來的redis進程(父進程)繼續處理客戶端請求,而子進程則負責將數據保存到磁盤,然后退出。
而save保存是阻塞主進程,客戶端無法連接reids,等save完成后,主進程才開始工作,客戶端可以連接。
<4> 縮容條件
當hash表因為元素刪除逐漸變得越來越稀疏,Redis 會對 hash 表進行縮容來減少 hash 表的第一維數組空間占用。縮容的條件是元素個數低于數組長度的 10%。
5 set(集合)
redis的集合相當于java里面的hashset,其內部的鍵值對是無序的唯一的,它的內部實現相當于一個特殊的字典 hash,字典中所有的value都是一個值NULL。
6 zset(有序集合)
面試時最常問的,其類似于Java的SortedSet和HashMap的結合體,一方面是一個set,保證內部value的唯一性,另一方面它可以給每個value賦予一個score,代表這個value的排序權重,內部實現【跳躍列表】。
zset 可以用來存粉絲列表,value 值是粉絲的用戶 ID,score 是關注時間。我們可以對粉絲列表按關注時間進行排序。
<1> 跳躍列表
相關博客:redis-zset內部實現
zset要支持隨機的插入和刪除,不好用數組來表示,先看一下普通的鏈表結構。
需要這個鏈表按照score值進行排序,意味著當有新元素需要插入時,要定位到特定位置的插入點,這樣才能保證鏈表是有序的(那么如何快速查找插入點呢?)
跳躍列表:層級制,最下面一層所有的元素都會串起來。然后每隔幾個元素挑選出一個代表來,再將這幾個代表使用另外一級指針串起來。然后在這些代表里再挑出二級代表,再串起來。最終就形成了金字塔結構。
跳躍列表的插入,刪除,查找的復雜度都是O(logN)
舉例:
redis-zset內部實現
skiplist不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關系,而是給每個節點隨機出一個層數(level)。比如,一個節點隨機出的層數是3,那么就把它鏈入到第1層到第3層這三層鏈表中,
基于上使得其插入性能上明顯優于平衡樹。
實際應用中的skiplist每個節點應該包含key和value兩部分,上圖中沒有具體區分key和value,但是實際上列表是按照key(score)進行查詢的,查詢過程也是根據key在比較。