1. 底層資料結構, 與Redis Value Type之間的關係
對於Redis的使用者來說, Redis作為Key-Value型的記憶體資料庫, 其Value有多種型別.
- String
- Hash
- List
- Set
- ZSet
這些Value的型別, 只是"Redis的使用者認為的, Value儲存資料的方式". 而在具體實現上, 各個Type的Value到底如何儲存, 這對於Redis的使用者來說是不公開的.
舉個粟子: 使用下面的命令建立一個Key-Value
$ SET "Hello" "World"
對於Redis的使用者來說, Hello
這個Key, 對應的Value是String型別, 其值為五個ASCII字元組成的二進位制資料. 但具體在底層實現上, 這五個位元組是如何儲存的, 是不對使用者公開的. 即, Value的Type, 只是表象, 具體資料在記憶體中以何種資料結構存放, 這對於使用者來說是不必要了解的.
Redis對使用者暴露了五種Value Type, 其底層實現的資料結構有8種, 分別是:
- SDS - simple synamic string - 支援自動動態擴容的位元組陣列
- list - 平平無奇的連結串列
- dict - 使用雙雜湊表實現的, 支援平滑擴容的字典
- zskiplist - 附加了後向指標的跳躍表
- intset - 用於儲存整數數值集合的自有結構
- ziplist - 一種實現上類似於TLV, 但比TLV複雜的, 用於儲存任意資料的有序序列的資料結構
- quicklist - 一種以ziplist作為結點的雙連結串列結構, 實現的非常苟
- zipmap - 一種用於在小規模場合使用的輕量級字典結構
而銜接"底層資料結構"與"Value Type"的橋樑的, 則是Redis實現的另外一種資料結構: redisObject
. Redis中的Key與Value在表層都是一個redisObject
例項, 故該結構有所謂的"型別", 即是ValueType
. 對於每一種Value Type
型別的redisObject
, 其底層至少支援兩種不同的底層資料結構來實現. 以應對在不同的應用場景中, Redis的執行效率, 或記憶體佔用.
2. 底層資料結構
2.1 SDS - simple dynamic string
這是一種用於儲存二進位制資料的一種結構, 具有動態擴容的特點. 其實現位於src/sds.h
與src/sds.c
中, 其關鍵定義如下:
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的總體概覽如下圖:
其中sdshdr
是頭部, buf
是真實儲存使用者資料的地方. 另外注意, 從命名上能看出來, 這個資料結構除了能儲存二進位制資料, 顯然是用於設計作為字串使用的, 所以在buf
中, 使用者資料後總跟著一個\0
. 即圖中 "資料" + "\0" 是為所謂的buf
SDS有五種不同的頭部. 其中sdshdr5
實際並未使用到. 所以實際上有四種不同的頭部, 分別如下:
len
分別以uint8
,uint16
,uint32
,uint64
表示使用者資料的長度(不包括末尾的\0
)alloc
分別以uint8
,uint16
,uint32
,uint64
表示整個SDS, 除過頭部與末尾的\0
, 剩餘的位元組數.flag
始終為一位元組, 以低三位標示著頭部的型別, 高5位未使用.
當在程式中持有一個SDS例項時, 直接持有的是資料區的頭指標, 這樣做的用意是: 通過這個指標, 向前偏一個位元組, 就能取到flag
, 通過判斷flag低三位的值, 能迅速判斷: 頭部的型別, 已用位元組數, 總位元組數, 剩餘位元組數. 這也是為什麼sds
型別即是char *
指標型別別名的原因.
建立一個SDS例項有三個介面, 分別是:
// 建立一個不含資料的sds:
// 頭部 3位元組 sdshdr8
// 資料區 0位元組
// 末尾 \0 佔一位元組
sds sdsempty(void);
// 帶資料建立一個sds:
// 頭部 按initlen的值, 選擇最小的頭部型別
// 資料區 從入參指標init處開始, 拷貝initlen個位元組
// 末尾 \0 佔一位元組
sds sdsnewlen(const void *init, size_t initlen);
// 帶資料建立一個sds:
// 頭部 按strlen(init)的值, 選擇最小的頭部型別
// 資料區 入參指向的字串中的所有字元, 不包括末尾 \0
// 末尾 \0 佔一位元組
sds sdsnew(const char *init);
- 所有建立sds例項的介面, 都不會額外分配預留記憶體空間
sdsnewlen
用於帶二進位制資料建立sds例項,sdsnew
用於帶字串建立sds例項. 介面返回的sds可以直接傳入libc中的字串輸出函式中進行操作, 由於無論其中儲存的是使用者的二進位制資料, 還是字串, 其末尾都帶一個\0, 所以至少呼叫libc中的字串輸出函式是安全的.
在對SDS中的資料進行修改時, 若剩餘空間不足, 會呼叫sdsMakeRoomFor
函式用於擴容空間, 這是一個很低階的API, 通常情況下不應當由SDS的使用者直接呼叫. 其實現中核心的幾行如下:
sds sdsMakeRoomFor(sds s, size_t addlen) {
...
/* Return ASAP if there is enough space left. */
if (avail >= addlen) return s;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
...
}
可以看到, 在擴充空間時
- 先保證至少有
addlen
可用 - 然後再進一步擴充, 在總體佔用空間不超過閾值
SDS_MAC_PREALLOC
時, 申請空間再翻一倍. 若總體空間已經超過了閾值, 則步進增長SDS_MAC_PREALLOC
. 這個閾值的預設值為1024 * 1024
SDS也提供了介面用於移除所有未使用的記憶體空間. sdsRemoveFreeSpace
, 該介面沒有間接的被任何SDS其它介面呼叫, 即預設情況下, SDS不會自動回收預留空間. 在SDS的使用者需要節省記憶體時, 由使用者自行呼叫:
sds sdsRemoveFreeSpace(sds s);
總結:
- SDS除了是某些Value Type的底層實現, 也被大量使用在Redis內部, 用於替代C-Style字串. 所以預設的建立SDS例項介面, 不分配額外的預留空間. 因為多數字符串在程式執行期間是不變的. 而對於變更資料區的API, 其內部則是呼叫了
sdsMakeRoomFor
, 每一次擴充空間, 都會預留大量的空間. 這樣做的考量是: 如果一個SDS例項中的資料被變更了, 那麼很有可能會在後續發生多次變更. - SDS的API內部不負責清除未使用的閒置記憶體空間, 因為內部API無法判斷這樣做的合適時機. 即便是在運算元據區的時候導致資料區佔用記憶體減少時, 內部API也不會清除閒置內在空間. 清除閒置記憶體空間責任應當由SDS的使用者自行擔當.
- 用SDS替代C-Style字串時, 由於其頭部額外儲存了資料區的長度資訊, 所以字串的求長操作時間複雜度為O(1)
2.2 list
這是普通的連結串列實現, 連結串列結點不直接持有資料, 而是通過void *
指標來間接的指向資料. 其實現位於 src/adlist.h
與src/adlist.c
中, 關鍵定義如下:
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
typedef struct listIter {
listNode *next;
int direction;
} listIter;
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;
其記憶體佈局如下圖所示:
這是一個平平無奇的連結串列的實現. list
在Redis除了作為一些Value Type的底層實現外, 還廣泛用於Redis的其它功能實現中, 作為一種資料結構工具使用. 在list
的實現中, 除了基本的連結串列定義外, 還額外增加了:
- 迭代器
listIter
的定義, 與相關介面的實現. - 由於
list
中的連結串列結點本身並不直接持有資料, 而是通過value
欄位, 以void *
指標的形式間接持有, 所以資料的生命週期並不完全與連結串列及其結點一致. 這給了list
的使用者相當大的靈活性. 比如可以多個結點持有同一份資料的地址. 但與此同時, 在對連結串列進行銷燬, 結點複製以及查詢匹配時, 就需要list
的使用者將相關的函式指標賦值於list.dup
,list.free
,list.match
欄位.
2.3 dict
dict
是Redis底層資料結構中實現最為複雜的一個資料結構, 其功能類似於C++標準庫中的std::unordered_map
, 其實現位於 src/dict.h
與 src/dict.c
中, 其關鍵定義如下:
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;
/* If safe is set to 1 this is a safe iterator, that means, you can call
* dictAdd, dictFind, and other functions against the dictionary even while
* iterating. Otherwise it is a non safe iterator, and only dictNext()
* should be called while iterating. */
typedef struct dictIterator {
dict *d;
long index;
int table, safe;
dictEntry *entry, *nextEntry;
/* unsafe iterator fingerprint for misuse detection. */
long long fingerprint;
} dictIterator;
其記憶體佈局如下所示:
dict
中儲存的鍵值對, 是通過dictEntry
這個結構間接持有的,k
通過指標間接持有鍵,v
通過指標間接持有值. 注意, 若值是整數值的話, 是直接儲存在v欄位中的, 而不是間接持有. 同時next
指標用於指向, 在bucket索引值衝突時, 以鏈式方式解決衝突, 指向同索引的下一個dictEntry
結構.- 傳統的雜湊表實現, 是一塊連續空間的順序表, 表中元素即是結點. 在
dictht.table
中, 結點本身是散佈在記憶體中的, 順序表中儲存的是dictEntry
的指標 - 雜湊表即是
dictht
結構, 其通過table
欄位間接的持有順序表形式的bucket, bucket的容量儲存在size
欄位中, 為了加速將雜湊值轉化為bucket中的陣列索引, 引入了sizemask
欄位, 計算指定鍵在雜湊表中的索引時, 執行的操作類似於dict->type->hashFunction(鍵) & dict->ht[x].sizemask
. 從這裡也可以看出來, bucket的容量適宜於為2的冪次, 這樣計算出的索引值能覆蓋到所有bucket索引位. dict
即為字典. 其中type
欄位中儲存的是本字典使用到的各種函式指標, 包括雜湊函式, 鍵與值的複製函式, 釋放函式, 以及鍵的比較函式.privdata
是用於儲存使用者自定義資料. 這樣, 字典的使用者可以最大化的自定義字典的實現, 通過自定義各種函式實現, 以及可以附帶私有資料, 保證了字典有很大的調優空間.- 字典為了支援平滑擴容, 定義了
ht[2]
這個陣列欄位. 其用意是這樣的:- 一般情況下, 字典
dict
僅持有一個雜湊表dictht
的例項, 即整個字典由一個bucket實現. - 隨著插入操作, bucket中出現衝突的概率會越來越大, 當字典中儲存的結點數目, 與bucket陣列長度的比值達到一個閾值(1:1)時, 字典為了緩解效能下降, 就需要擴容
- 擴容的操作是平滑的, 即在擴容時, 字典會持有兩個
dictht
的例項,ht[0]
指向舊雜湊表,ht[1]
指向擴容後的新雜湊表. 平滑擴容的重點在於兩個策略:- 後續每一次的插入, 替換, 查詢操作, 都插入到
ht[1]
指向的雜湊表中 - 每一次插入, 替換, 查詢操作執行時, 會將舊錶
ht[0]
中的一個bucket索引位持有的結點連結串列, 遷移到ht[1]
中去. 遷移的進度儲存在rehashidx
這個欄位中.在舊錶中由於衝突而被連結在同一索引位上的結點, 遷移到新表後, 可能會散佈在多個新表索引中去. - 當遷移完成後,
ht[0]
指向的舊錶會被釋放, 之後會將新表的持有權轉交給ht[0]
, 再重置ht[1]
指向NULL
- 後續每一次的插入, 替換, 查詢操作, 都插入到
- 一般情況下, 字典
- 這種平滑擴容的優點有兩個:
- 平滑擴容過程中, 所有結點的實際資料, 即
dict->ht[0]->table[rehashindex]->k
與dict->ht[0]->table[rehashindex]->v
分別指向的實際資料, 記憶體地址都不會變化. 沒有發生鍵資料與值資料的拷貝或移動, 擴容整個過程僅是各種指標的操作. 速度非常快 - 擴容操作是步進式的, 這保證任何一次插入操作都是順暢的,
dict
的使用者是無感知的. 若擴容是一次性的, 當新舊bucket容量特別大時, 遷移所有結點必然會導致耗時陡增.
- 平滑擴容過程中, 所有結點的實際資料, 即
除了字典本身的實現外, 其中還順帶實現了一個迭代器, 這個迭代器中有欄位safe
以標示該迭代器是"安全迭代器"還是"非安全迭代器", 所謂的安全與否, 指是的這種場景:
設想在執行迭代器的過程中, 字典正處於平滑擴容的過程中. 在平滑擴容的過程中時, 舊錶一個索引位上的, 由衝突而鏈起來的多個結點, 遷移到新表後, 可能會散佈到新表的多個索引位上. 且新的索引位的值可能比舊的索引位要低.
遍歷操作的重點是, 保證在迭代器遍歷操作開始時, 字典中持有的所有結點, 都會被遍歷到. 而若在遍歷過程中, 一個未遍歷的結點, 從舊錶遷移到新表後, 索引值減小了, 那麼就可能會導致這個結點在遍歷過程中被遺漏.
所以, 所謂的"安全"迭代器, 其在內部實現時: 在迭代過程中, 若字典正處於平滑擴容過程, 則暫停結點遷移, 直至迭代器執行結束. 這樣雖然不能保證在迭代過程中插入的結點會被遍歷到, 但至少保證在迭代起始時, 字典中持有的所有結點都會被遍歷到.
這也是為什麼dict
結構中有一個iterators
欄位的原因: 該欄位記錄了執行於該字典上的安全迭代器的數目. 若該數目不為0, 字典是不會繼續進行結點遷移平滑擴容的.
下面是字典的擴容操作中的核心程式碼, 我們以插入操作引起的擴容為例:
先是插入操作的外部邏輯:
- 如果插入時, 字典正處於平滑擴容過程中, 那麼無論本次插入是否成功, 先遷移一個bucket索引中的結點至新表
- 在計算新插入結點鍵的bucket索引值時, 內部會探測雜湊表是否需要擴容(若當前不在平滑擴容過程中)
int dictAdd(dict *d, void *key, void *val)
{
dictEntry *entry = dictAddRaw(d,key,NULL); // 呼叫dictAddRaw
if (!entry) return DICT_ERR;
dictSetVal(d, entry, val);
return DICT_OK;
}
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
long index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d); // 若在平滑擴容過程中, 先步進遷移一個bucket索引
/* Get the index of the new element, or -1 if
* the element already exists. */
// 在計算鍵在bucket中的索引值時, 內部會檢查是否需要擴容
if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
return NULL;
/* Allocate the memory and store the new entry.
* Insert the element in top, with the assumption that in a database
* system it is more likely that recently added entries are accessed
* more frequently. */
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
entry = zmalloc(sizeof(*entry));
entry->next = ht->table[index];
ht->table[index] = entry;
ht->used++;
/* Set the hash entry fields. */
dictSetKey(d, entry, key);
return entry;
}
下面是計算bucket索引值的函式, 內部會探測該雜湊表是否需要擴容, 如果需要擴容(結點數目與bucket陣列長度比例達到1:1), 就使字典進入平滑擴容過程:
static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{
unsigned long idx, table;
dictEntry *he;
if (existing) *existing = NULL;
/* Expand the hash table if needed */
if (_dictExpandIfNeeded(d) == DICT_ERR) // 探測是否需要擴容, 如果需要, 則開始擴容
return -1;
for (table = 0; table <= 1; table++) {
idx = hash & d->ht[table].sizemask;
/* Search if this slot does not already contain the given key */
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key)) {
if (existing) *existing = he;
return -1;
}
he = he->next;
}
if (!dictIsRehashing(d)) break;
}
return idx;
}
/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
/* Incremental rehashing already in progress. Return. */
if (dictIsRehashing(d)) return DICT_OK; // 如果正在擴容過程中, 則什麼也不做
/* If the hash table is empty expand it to the initial size. */
// 若字典中本無元素, 則初始化字典, 初始化時的bucket陣列長度為4
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
/* If we reached the 1:1 ratio, and we are allowed to resize the hash
* table (global setting) or we should avoid it but the ratio between
* elements/buckets is over the "safe" threshold, we resize doubling
* the number of buckets. */
// 若字典中元素的個數與bucket陣列長度比值大於1:1時, 則呼叫dictExpand進入平滑擴容狀態
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
return dictExpand(d, d->ht[0].used*2);
}
return DICT_OK;
}
int dictExpand(dict *d, unsigned long size)
{
dictht n; /* the new hash table */ // 新建一個dictht結構
unsigned long realsize = _dictNextPower(size);
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
/* Rehashing to the same table size is not useful. */
if (realsize == d->ht[0].size) return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));// 初始化dictht下的table, 即bucket陣列
n.used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
// 若是新字典初始化, 直接把dictht結構掛在ht[0]中
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
// 否則, 把新dictht結構掛在ht[1]中, 並開啟平滑擴容(置rehashidx為0, 字典處於非擴容狀態時, 該欄位值為-1)
/* Prepare a second hash table for incremental rehashing */
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
下面是平滑擴容的實現:
static void _dictRehashStep(dict *d) {
// 若字典上還執行著安全迭代器, 則不遷移結點
// 否則每次遷移一箇舊bucket索引上的所有結點
if (d->iterators == 0) dictRehash(d,1);
}
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned long)d->rehashidx);
// 在舊bucket中, 找到下一個非空的索引位
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
// 取出該索引位上的結點連結串列
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
// 把所有結點遷移到新bucket中去
while(de) {
uint64_t h;
nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* Check if we already rehashed the whole table... */
// 檢查是否舊錶中的所有結點都被遷移到了新表
// 如果是, 則置先釋放原舊bucket陣列, 再置ht[1]為ht[0]
// 最後再置rehashidx=-1, 以示字典不處於平滑擴容狀態
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
return 1;
}
總結:
- 字典的實現很複雜, 主要是實現了平滑擴容邏輯
- 使用者資料均是以指標形式間接由
dictEntry
結構持有, 故在平滑擴容過程中, 不涉及使用者資料的拷貝 - 有安全迭代器可用, 安全迭代器保證, 在迭代起始時, 字典中的所有結點, 都會被迭代到, 即使在迭代過程中對字典有插入操作
- 字典內部使用的預設雜湊函式其實也非常有講究, 不過限於篇幅, 這裡不展開講. 並且字典的實現給了使用者非常大的靈活性(
dictType
結構與dict.privdata
欄位), 對於一些特定場合使用的鍵資料, 使用者可以自行選擇更高效更特定化的雜湊函式
2.4 zskiplist
zskiplist
是Redis實現的一種特殊的跳躍表. 跳躍表是一種基於線性表實現簡單的搜尋結構, 其最大的特點就是: 實現簡單, 效能能逼近各種搜尋樹結構. 血統純正的跳躍表的介紹在維基百科中即可查閱. 在Redis中, 在原版跳躍表的基礎上, 進行了一些小改動, 即是現在要介紹的zskiplis
t結構.
其定義在src/server.h
中, 如下:
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
其記憶體佈局如下圖:
zskiplist
的核心設計要點為:
- 頭結點不持有任何資料, 且其
level[]
的長度為32 - 每個結點, 除了持有資料的
ele
欄位, 還有一個欄位score
, 其標示著結點的得分, 結點之間憑藉得分來判斷先後順序, 跳躍表中的結點按結點的得分升序排列. - 每個結點持有一個
backward
指標, 這是原版跳躍表中所沒有的. 該指標指向結點的前一個緊鄰結點. - 每個結點中最多持有32個
zskiplistLevel
結構. 實際數量在結點建立時, 按冪次定律隨機生成(不超過32). 每個zskiplistLevel
中有兩個欄位.forward
欄位指向比自己得分高的某個結點(不一定是緊鄰的), 並且, 若當前zskiplistLevel
例項在level[]
中的索引為X
, 則其forward
欄位指向的結點, 其level[]
欄位的容量至少是X+1
. 這也是上圖中, 為什麼forward
指標總是畫的水平的原因.span
欄位代表forward
欄位指向的結點, 距離當前結點的距離. 緊鄰的兩個結點之間的距離定義為1.
zskiplist
中持有欄位level
, 用以記錄所有結點(除過頭結點外),level[]
陣列最長的長度.
跳躍表主要用於, 在給定一個分值的情況下, 查詢與該分值最接近的結點. 搜尋時, 虛擬碼如下:
int level = zskiplist->level - 1;
zskiplistNode p = zskiplist->head;
while(1 && p)
{
zskiplistNode q = (p->level)[level]->forward:
if(q->score > 分值)
{
if(level > 0)
{
level--;
}
else
{
return :
q為整個跳躍表中, 分值大於指定分值的第一個結點
q->backward為整個跳躍表中, 分值小於或等於指定分值的最後一個結點
}
}
else
{
p = q;
}
}
跳躍表的實現比較簡單, 最複雜的操作即是插入與刪除結點, 需要仔細處理鄰近結點的所有level[]
中的所有zskiplistLevel
結點中的forward
與span
的值的變更.
另外, 關於新建立的結點, 其level[]
陣列長度的隨機演算法, 在介面zslInsert
的實現中, 核心程式碼片斷如下:
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
//...
level = zslRandomLevel(); // 隨機生成新結點的, level[]陣列的長度
if (level > zsl->level) {
// 若生成的新結點的level[]陣列的長度比當前表中所有結點的level[]的長度都大
// 那麼頭結點中需要新增幾個指向該結點的指標
// 並重新整理ziplist中的level欄位
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
zsl->level = level;
}
x = zslCreateNode(level,score,ele); // 建立新結點
//... 執行插入操作
}
// 按冪次定律生成小於32的隨機數的函式
// 巨集 ZSKIPLIST_MAXLEVEL 的定義為32, 巨集 ZSKIPLIST_P 被設定為 0.25
// 即
// level == 1的概率為 75%
// level == 2的概率為 75% * 25%
// level == 3的概率為 75% * 25% * 25%
// ...
// level == 31的概率為 0.75 * 0.25^30
// 而
// level == 32的概率為 0.75 * sum(i = 31 ~ +INF){ 0.25^i }
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
2.5 intset
這是一個用於儲存在序的整數的資料結構, 也底層資料結構中最簡單的一個, 其定義與實現在src/intest.h
與src/intset.c
中, 關鍵定義如下:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
inset
結構中的encoding
的取值有三個, 分別是巨集INTSET_ENC_INT16
, INTSET_ENC_INT32
, INTSET_ENC_INT64
. length
代表其中儲存的整數的個數, contents
指向實際儲存數值的連續記憶體區域. 其記憶體佈局如下圖所示:
intset
中各欄位, 包括contents
中儲存的數值, 都是以主機序(小端位元組序)儲存的. 這意味著Redis若執行在PPC這樣的大端位元組序的機器上時, 存取資料都會有額外的位元組序轉換開銷- 當
encoding == INTSET_ENC_INT16
時,contents
中以int16_t
的形式儲存著數值. 類似的, 當encoding == INTSET_ENC_INT32
時,contents
中以int32_t
的形式儲存著數值. - 但凡有一個數值元素的值超過了
int32_t
的取值範圍, 整個intset
都要進行升級, 即所有的數值都需要以int64_t
的形式儲存. 顯然升級的開銷是很大的. intset
中的數值是以升序排列儲存的, 插入與刪除的複雜度均為O(n). 查詢使用二分法, 複雜度為O(log_2(n))intset
的程式碼實現中, 不預留空間, 即每一次插入操作都會呼叫zrealloc
介面重新分配記憶體. 每一次刪除也會呼叫zrealloc
介面縮減佔用的記憶體. 省是省了, 但記憶體操作的時間開銷上升了.intset
的編碼方式一經升級, 不會再降級.
總之, intset
適合於如下資料的儲存:
- 所有資料都位於一個穩定的取值範圍中. 比如均位於
int16_t
或int32_t
的取值範圍中 - 資料穩定, 插入刪除操作不頻繁. 能接受O(lgn)級別的查詢開銷
2.6 ziplist
ziplist
是Redis底層資料結構中, 最苟的一個結構. 它的設計宗旨就是: 省記憶體, 從牙縫裡省記憶體. 設計思路和TLV一致, 但為了從牙縫裡節省記憶體, 做了很多額外工作.
ziplist
的記憶體佈局與intset
一樣: 就是一塊連續的記憶體空間. 但區域劃分比較複雜, 概覽如下圖:
- 和
intset
一樣,ziplist
中的所有值都是以小端序儲存的 zlbytes
欄位的型別是uint32_t
, 這個欄位中儲存的是整個ziplist
所佔用的記憶體的位元組數zltail
欄位的型別是uint32_t
, 它指的是ziplist
中最後一個entry
的偏移量. 用於快速定位最後一個entry
, 以快速完成pop
等操作zllen
欄位的型別是uint16_t
, 它指的是整個ziplit
中entry
的數量. 這個值只佔16位, 所以蛋疼的地方就來了: 如果ziplist
中entry
的數目小於65535, 那麼該欄位中儲存的就是實際entry
的值. 若等於或超過65535, 那麼該欄位的值固定為65535, 但實際數量需要一個個entry
的去遍歷所有entry
才能得到.zlend
是一個終止位元組, 其值為全F, 即0xff
.ziplist
保證任何情況下, 一個entry
的首位元組都不會是255
在畫圖展示entry
的記憶體佈局之前, 先講一下entry
中都儲存了哪些資訊:
- 每個
entry
中儲存了它前一個entry
所佔用的位元組數. 這樣支援ziplist
反向遍歷. - 每個
entry
用單獨的一塊區域, 儲存著當前結點的型別: 所謂的型別, 包括當前結點儲存的資料是什麼(二進位制, 還是數值), 如何編碼(如果是數值, 數值如何儲存, 如果是二進位制資料, 二進位制資料的長度) - 最後就是真實的資料了
entry
的記憶體佈局如下所示:
prevlen
即是"前一個entry所佔用的位元組數", 它本身是一個變長欄位, 規約如下:
- 若前一個
entry
佔用的位元組數小於 254, 則prevlen
欄位佔一位元組 - 若前一個
entry
佔用的位元組數等於或大於 254, 則prevlen
欄位佔五位元組: 第一個位元組值為 254, 即0xfe
, 另外四個位元組, 以uint32_t
儲存著值.
encoding
欄位的規約就複雜了許多
- 若資料是二進位制資料, 且二進位制資料長度小於64位元組(不包括64), 那麼
encoding
佔一位元組. 在這一位元組中, 高兩位值固定為0, 低六位值以無符號整數的形式儲存著二進位制資料的長度. 即00xxxxxx
, 其中低六位bitxxxxxx
是用二進位制儲存的資料長度. - 若資料是二進位制資料, 且二進位制資料長度大於或等於64位元組, 但小於16384(不包括16384)位元組, 那麼
encoding
佔用兩個位元組. 在這兩個位元組16位中, 第一個位元組的高兩位固定為01
, 剩餘的14個位, 以小端序無符號整數的形式儲存著二進位制資料的長度, 即01xxxxxx, yyyyyyyy
, 其中yyyyyyyy
是高八位,xxxxxx
是低六位. - 若資料是二進位制資料, 且二進位制資料的長度大於或等於16384位元組, 但小於2^32-1位元組, 則
encoding
佔用五個位元組. 第一個位元組是固定值10000000
, 剩餘四個位元組, 按小端序uint32_t
的形式儲存著二進位制資料的長度. 這也是ziplist
能儲存的二進位制資料的最大長度, 超過2^32-1
位元組的二進位制資料,ziplist
無法儲存. - 若資料是整數值, 則
encoding
和data
的規約如下:- 首先, 所有儲存數值的
entry
, 其encoding
都僅佔用一個位元組. 並且最高兩位均是11
- 若數值取值範圍位於
[0, 12]
中, 則encoding
和data
擠在同一個位元組中. 即為1111 0001
~1111 1101
, 高四位是固定值, 低四位的值從0001
至1101
, 分別代表 0 ~ 12這十五個數值 - 若數值取值範圍位於
[-128, -1] [13, 127]
中, 則encoding == 0b 1111 1110
. 數值儲存在緊鄰的下一個位元組, 以int8_t
形式編碼 - 若數值取值範圍位於
[-32768, -129] [128, 32767]
中, 則encoding == 0b 1100 0000
. 數值儲存在緊鄰的後兩個位元組中, 以小端序int16_t
形式編碼 - 若數值取值範圍位於
[-8388608, -32769] [32768, 8388607]
中, 則encoding == 0b 1111 0000
. 數值儲存在緊鄰的後三個位元組中, 以小端序儲存, 佔用三個位元組. - 若數值取值範圍位於
[-2^31, -8388609] [8388608, 2^31 - 1]
中, 則encoding == 0b 1101 0000.
數值儲存在緊鄰的後四個位元組中, 以小端序int32_t
形式編碼 - 若數值取值均不在上述範圍, 但位於
int64_t
所能表達的範圍內, 則encoding == 0b 1110 0000
, 數值儲存在緊鄰的後八個位元組中, 以小端序int64_t
形式編碼
- 首先, 所有儲存數值的
在大規模數值儲存中, ziplist
幾乎不浪費記憶體空間, 其苟的程式到達了位元組級別, 甚至對於[0, 12]
區間的數值, 連data
裡的那一個位元組也要省下來. 顯然, ziplist
是一種特別節省記憶體的資料結構, 但它的缺點也十分明顯:
- 和
intset
一樣,ziplist
也不預留記憶體空間, 並且在移除結點後, 也是立即縮容, 這代表每次寫操作都會進行記憶體分配操作. ziplist
最蛋疼的一個問題是: 結點如果擴容, 導致結點佔用的記憶體增長, 並且超過254位元組的話, 可能會導致鏈式反應: 其後一個結點的entry.prevlen
需要從一位元組擴容至五位元組. 最壞情況下, 第一個結點的擴容, 會導致整個ziplist
表中的後續所有結點的entry.prevlen
欄位擴容. 雖然這個記憶體重分配的操作依然只會發生一次, 但程式碼中的時間複雜度是o(N)級別, 因為鏈式擴容只能一步一步的計算. 但這種情況的概率十分的小, 一般情況下鏈式擴容能連鎖反映五六次就很不幸了. 之所以說這是一個蛋疼問題, 是因為, 這樣的壞場景下, 其實時間複雜度並不高: 依次計算每個entry
新的空間佔用, 也就是o(N), 總體佔用計算出來後, 只執行一次記憶體重分配, 與對應的memmove
操作, 就可以了. 蛋疼說的是: 程式碼特別難寫, 難讀. 下面放一段處理插入結點時處理鏈式反應的程式碼片斷, 大家自行感受一下:
unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen;
unsigned int prevlensize, prevlen = 0;
size_t offset;
int nextdiff = 0;
unsigned char encoding = 0;
long long value = 123456789; /* initialized to avoid warning. Using a value
that is easy to see if for some reason
we use it uninitialized. */
zlentry tail;
/* Find out prevlen for the entry that is inserted. */
if (p[0] != ZIP_END) {
ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
} else {
unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);
if (ptail[0] != ZIP_END) {
prevlen = zipRawEntryLength(ptail);
}
}
/* See if the entry can be encoded */
if (zipTryEncoding(s,slen,&value,&encoding)) {
/* 'encoding' is set to the appropriate integer encoding */
reqlen = zipIntSize(encoding);
} else {
/* 'encoding' is untouched, however zipStoreEntryEncoding will use the
* string length to figure out how to encode it. */
reqlen = slen;
}
/* We need space for both the length of the previous entry and
* the length of the payload. */
reqlen += zipStorePrevEntryLength(NULL,prevlen);
reqlen += zipStoreEntryEncoding(NULL,encoding,slen);
/* When the insert position is not equal to the tail, we need to
* make sure that the next entry can hold this entry's length in
* its prevlen field. */
int forcelarge = 0;
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
if (nextdiff == -4 && reqlen < 4) {
nextdiff = 0;
forcelarge = 1;
}
/* Store offset because a realloc may change the address of zl. */
offset = p-zl;
zl = ziplistResize(zl,curlen+reqlen+nextdiff);
p = zl+offset;
/* Apply memory move when necessary and update tail offset. */
if (p[0] != ZIP_END) {
/* Subtract one because of the ZIP_END bytes */
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
/* Encode this entry's raw length in the next entry. */
if (forcelarge)
zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
else
zipStorePrevEntryLength(p+reqlen,reqlen);
/* Update offset for tail */
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
/* When the tail contains more than one entry, we need to take
* "nextdiff" in account as well. Otherwise, a change in the
* size of prevlen doesn't have an effect on the *tail* offset. */
zipEntry(p+reqlen, &tail);
if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
} else {
/* This element will be the new tail. */
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
}
/* When nextdiff != 0, the raw length of the next entry has changed, so
* we need to cascade the update throughout the ziplist */
if (nextdiff != 0) {
offset = p-zl;
zl = __ziplistCascadeUpdate(zl,p+reqlen);
p = zl+offset;
}
/* Write the entry */
p += zipStorePrevEntryLength(p,prevlen);
p += zipStoreEntryEncoding(p,encoding,slen);
if (ZIP_IS_STR(encoding)) {
memcpy(p,s,slen);
} else {
zipSaveInteger(p,value,encoding);
}
ZIPLIST_INCR_LENGTH(zl,1);
return zl;
}
unsigned char *__ziplistCascadeUpdate(unsigned char *zl, unsigned char *p) {
size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), rawlen, rawlensize;
size_t offset, noffset, extra;
unsigned char *np;
zlentry cur, next;
while (p[0] != ZIP_END) {
zipEntry(p, &cur);
rawlen = cur.headersize + cur.len;
rawlensize = zipStorePrevEntryLength(NULL,rawlen);
/* Abort if there is no next entry. */
if (p[rawlen] == ZIP_END) break;
zipEntry(p+rawlen, &next);
/* Abort when "prevlen" has not changed. */
if (next.prevrawlen == rawlen) break;
if (next.prevrawlensize < rawlensize) {
/* The "prevlen" field of "next" needs more bytes to hold
* the raw length of "cur". */
offset = p-zl;
extra = rawlensize-next.prevrawlensize;
zl = ziplistResize(zl,curlen+extra);
p = zl+offset;
/* Current pointer and offset for next element. */
np = p+rawlen;
noffset = np-zl;
/* Update tail offset when next element is not the tail element. */
if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
}
/* Move the tail to the back. */
memmove(np+rawlensize,
np+next.prevrawlensize,
curlen-noffset-next.prevrawlensize-1);
zipStorePrevEntryLength(np,rawlen);
/* Advance the cursor */
p += rawlen;
curlen += extra;
} else {
if (next.prevrawlensize > rawlensize) {
/* This would result in shrinking, which we want to avoid.
* So, set "rawlen" in the available bytes. */
zipStorePrevEntryLengthLarge(p+rawlen,rawlen);
} else {
zipStorePrevEntryLength(p+rawlen,rawlen);
}
/* Stop here, as the raw length of "next" has not changed. */
break;
}
}
return zl;
}
這種程式碼的特點就是: 最好由作者去維護, 最好一次性寫對. 因為讀起來真的費勁, 改起來也很費勁.
2.7 quicklist
如果說ziplist
是整個Redis中為了節省記憶體, 而寫的最苟的資料結構, 那麼稱quicklist
就是在最苟的基礎上, 再苟了一層. 這個結構是Redis在3.2版本後新加的, 在3.2版本之前, 我們可以講, dict
是最複雜的底層資料結構, ziplist
是最苟的底層資料結構. 在3.2版本之後, 這兩個記錄被雙雙重新整理了.
這是一種, 以ziplist
為結點的, 雙端連結串列結構. 巨集觀上, quicklist
是一個連結串列, 微觀上, 連結串列中的每個結點都是一個ziplist
.
它的定義與實現分別在src/quicklist.h
與src/quicklist.c
中, 其中關鍵定義如下:
/* Node, quicklist, and Iterator are the only data structures used currently. */
/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
* We use bit fields keep the quicklistNode at 32 bytes.
* count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
* encoding: 2 bits, RAW=1, LZF=2.
* container: 2 bits, NONE=1, ZIPLIST=2.
* recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
* attempted_compress: 1 bit, boolean, used for verifying during testing.
* extra: 12 bits, free for future use; pads out the remainder of 32 bits */
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;
/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
* 'sz' is byte length of 'compressed' field.
* 'compressed' is LZF data with total (compressed) length 'sz'
* NOTE: uncompressed length is stored in quicklistNode->sz.
* When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF;
/* quicklist is a 40 byte struct (on 64-bit systems) describing a quicklist.
* 'count' is the number of total entries.
* 'len' is the number of quicklist nodes.
* 'compress' is: -1 if compression disabled, otherwise it's the number
* of quicklistNodes to leave uncompressed at ends of quicklist.
* 'fill' is the user-requested (or default) fill factor. */
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 : 16; /* fill factor for individual nodes */
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;
typedef struct quicklistIter {
const quicklist *quicklist;
quicklistNode *current;
unsigned char *zi;
long offset; /* offset in current ziplist */
int direction;
} quicklistIter;
typedef struct quicklistEntry {
const quicklist *quicklist;
quicklistNode *node;
unsigned char *zi;
unsigned char *value;
long long longval;
unsigned int sz;
int offset;
} quicklistEntry;
這裡定義了五個結構體:
quicklistNode
, 巨集觀上,quicklist
是一個連結串列, 這個結構描述的就是連結串列中的結點. 它通過zl
欄位持有底層的ziplist
. 簡單來講, 它描述了一個ziplist
例項quicklistLZF
,ziplist
是一段連續的記憶體, 用LZ4演算法壓縮後, 就可以包裝成一個quicklistLZF
結構. 是否壓縮quicklist
中的每個ziplist
例項是一個可配置項. 若這個配置項是開啟的, 那麼quicklistNode.zl
欄位指向的就不是一個ziplist
例項, 而是一個壓縮後的quicklistLZF
例項quicklist
. 這就是一個雙連結串列的定義.head, tail
分別指向頭尾指標.len
代表連結串列中的結點.count
指的是整個quicklist
中的所有ziplist
中的entry
的數目.fill
欄位影響著每個連結串列結點中ziplist
的最大佔用空間,compress
影響著是否要對每個ziplist
以LZ4演算法進行進一步壓縮以更節省記憶體空間.quicklistIter
是一個迭代器quicklistEntry
是對ziplist
中的entry
概念的封裝.quicklist
作為一個封裝良好的資料結構, 不希望使用者感知到其內部的實現, 所以需要把ziplist.entry
的概念重新包裝一下.
quicklist
的記憶體佈局圖如下所示:
下面是有關quicklist
的更多額外資訊:
quicklist.fill
的值影響著每個連結串列結點中,ziplist
的長度.- 當數值為負數時, 代表以位元組數限制單個
ziplist
的最大長度. 具體為:-1
不超過4kb-2
不超過 8kb-3
不超過 16kb-4
不超過 32kb-5
不超過 64kb
- 當數值為正數時, 代表以
entry
數目限制單個ziplist
的長度. 值即為數目. 由於該欄位僅佔16位, 所以以entry
數目限制ziplist
的容量時, 最大值為2^15個
- 當數值為負數時, 代表以位元組數限制單個
quicklist.compress
的值影響著quicklistNode.zl
欄位指向的是原生的ziplist
, 還是經過壓縮包裝後的quicklistLZF
0
表示不壓縮,zl
欄位直接指向ziplist
1
表示quicklist
的連結串列頭尾結點不壓縮, 其餘結點的zl
欄位指向的是經過壓縮後的quicklistLZF
2
表示quicklist
的連結串列頭兩個, 與末兩個結點不壓縮, 其餘結點的zl
欄位指向的是經過壓縮後的quicklistLZF
- 以此類推, 最大值為
2^16
quicklistNode.encoding
欄位, 以指示本連結串列結點所持有的ziplist
是否經過了壓縮.1
代表未壓縮, 持有的是原生的ziplist
,2
代表壓縮過quicklistNode.container
欄位指示的是每個連結串列結點所持有的資料型別是什麼. 預設的實現是ziplist
, 對應的該欄位的值是2
, 目前Redis沒有提供其它實現. 所以實際上, 該欄位的值恆為2quicklistNode.recompress
欄位指示的是當前結點所持有的ziplist
是否經過了解壓. 如果該欄位為1
即代表之前被解壓過, 且需要在下一次操作時重新壓縮.
quicklist
的具體實現程式碼篇幅很長, 這裡就不貼程式碼片斷了, 從記憶體佈局上也能看出來, 由於每個結點持有的ziplist
是有上限長度的, 所以在與操作時要考慮的分支情況比較多. 想想都蛋疼.
quicklist
有自己的優點, 也有缺點, 對於使用者來說, 其使用體驗類似於線性資料結構, list
作為最傳統的雙連結串列, 結點通過指標持有資料, 指標欄位會耗費大量記憶體. ziplist
解決了耗費記憶體這個問題. 但引入了新的問題: 每次寫操作整個ziplist
的記憶體都需要重分配. quicklist
在兩者之間做了一個平衡. 並且使用者可以通過自定義quicklist.fill
, 根據實際業務情況, 經驗主義調參.
2.8 zipmap
dict
作為字典結構, 優點很多, 擴充套件性強悍, 支援平滑擴容等等, 但對於字典中的鍵值均為二進位制資料, 且長度都很小時, dict
的中的一坨指標會浪費不少記憶體, 因此Redis又實現了一個輕量級的字典, 即為zipmap
.
zipmap
適合使用的場合是:
- 鍵值對量不大, 單個鍵, 單個值長度小
- 鍵值均是二進位制資料, 而不是複合結構或複雜結構.
dict
支援各種巢狀, 字典本身並不持有資料, 而僅持有資料的指標. 但zipmap
是直接持有資料的.
zipmap
的定義與實現在src/zipmap.h
與src/zipmap.c
兩個檔案中, 其定義與實現均未定義任何struct結構體, 因為zipmap
的記憶體佈局就是一塊連續的記憶體空間. 其記憶體佈局如下所示:
zipmap
起始的第一個位元組儲存的是zipmap
中鍵值對的個數. 如果鍵值對的個數大於254的話, 那麼這個位元組的值就是固定值254, 真實的鍵值對個數需要遍歷才能獲得.zipmap
的最後一個位元組是固定值0xFF
zipmap
中的每一個鍵值對, 稱為一個entry
, 其記憶體佔用如上圖, 分別六部分:len_of_key
, 一位元組或五位元組. 儲存的是鍵的二進位制長度. 如果長度小於254, 則用1位元組儲存, 否則用五個位元組儲存, 第一個位元組的值固定為0xFE
, 後四個位元組以小端序uint32_t
型別儲存著鍵的二進位制長度.key_data
為鍵的資料len_of_val
, 一位元組或五位元組, 儲存的是值的二進位制長度. 編碼方式同len_of_key
len_of_free
, 固定值1位元組, 儲存的是entry
中未使用的空間的位元組數. 未使用的空間即為圖中的free
, 它一般是由於鍵值對中的值被替換髮生的. 比如, 鍵值對hello <-> word
被修改為hello <-> w
後, 就空了四個位元組的閒置空間val_data
, 為值的資料free
, 為閒置空間. 由於len_of_free
的值最大隻能是254, 所以如果值的變更導致閒置空間大於254的話,zipmap
就會回收記憶體空間.
3. 膠水層 redisObject
銜接底層資料結構, 與五種Value Type之間的橋樑就是redisObject
這個結構. 該結構的關鍵定義如下(位於src/server.h
中):
/*-----------------------------------------------------------------------------
* Data types
*----------------------------------------------------------------------------*/
/* A redis object, that is a type able to hold a string / list / set */
/* The actual Redis Object */
#define OBJ_STRING 0
#define OBJ_LIST 1
#define OBJ_SET 2
#define OBJ_ZSET 3
#define OBJ_HASH 4
/* Objects encoding. Some kind of objects like Strings and Hashes can be
* internally represented in multiple ways. The 'encoding' field of the object
* is set to one of this fields for this object. */
#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 */
#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */
#define LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
#define OBJ_SHARED_REFCOUNT INT_MAX
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
redisObject
的記憶體佈局如下:
從定義上來看, redisObject
有:
- 與Value Type一致的Object Type, 即
type
欄位 - 特定的Object Encoding, 即
encoding
欄位, 表明物件底層使用的資料結構型別 - 記錄最末一次訪問時間的
lru
欄位 - 引用計數
refcount
- 指向底層資料結構例項的
ptr
欄位
redisObject
的通用操作API如下:
API | 功能 |
---|---|
char *strEncoding(int encoding) |
返回各種編碼的可讀字串表達 |
void decrRefCount(robj *o); |
引用計數-1. 若減後引用計數會降為0, 則會自動呼叫 freeXXXObject 函式釋放物件 |
void decrRefCountVoid(void *o); |
功能同decrRefCount , 只不過接收的是void * 型引數 |
void incrRefCount(robj *o); |
引用計數+1 |
robj *makeObjectShared(robj *o); |
將物件置為"全域性共享物件", 所謂的"全域性只讀共享物件", 有以下特徵 0. 內部引用計數為 INT_MAX 0. 引用計數操作函式對其不起作用 0. 多純種共享讀是安全的, 不需要加鎖 0. 禁止寫操作 |
robj *resetRefCount(robj *obj); |
將引用計數置為0, 但不會呼叫freeXXXObject 函式釋放物件 |
robj *createObject(int type, void *ptr); |
建立一個物件, 物件型別由引數指定, 物件底層編碼指定為RAW , 底層資料由引數提供, 物件引用計數為1.並初始化 lru 欄位. 若伺服器採用LRU演算法, 則置該欄位的值為當前分鐘級別的一個時間戳. 若伺服器採用LFU演算法, 則置為一個計數值. |
unsigned long long estimateObjectIdleTime(robj *o) |
獲取一個物件未被訪問的時間, 單位為毫秒. 由於 redisObject 中lru 欄位有24位, 並不是無限長, 所以有迴圈溢位的風險, 當發生迴圈溢位時(即當前LRU時鐘計數比物件中的lru 欄位小), 那麼該函式始終保守認為迴圈溢位只發生了一次 |
3.1 字串物件
字串物件支援三種編碼方式: INT
, RAW
, EMBSTR
, 三種方式的記憶體佈局分別如下:
字串物件的相關介面如下:
分類 | API名 | 功能 |
---|---|---|
建立介面 | robj *createEmbeddedStringObject(const char *ptr,size_t len) |
建立一個編碼為EMBSTR 的字串物件.即底層使用SDS, 且SDS與RedisObject位於同一塊連續記憶體上 |
-- | robj *createRawStringObject(const char *ptr,size_t len) |
建立一個編碼為RAW 的字串物件.即底層使用SDS, 且SDS由RedisObject間接持有 內部是先用入參建立一個SDS, 然後用這個SDS再去呼叫 createObject |
-- | robj *createStringObject(const char *ptr,size_t len) |
建立一個字串物件. 當 len 引數的值小於或等於OBJ_ENCODING_EMBSTR_SIZE_LIMIT 時, 編碼方式為EMBSTR , 否則為RAW 內部是通過呼叫 createRawStringObject 與createEmbeddedStringObject 來建立不同編碼的字串物件的 |
-- | robj *createStringObjectFromLongLong(long long value) |
根據整數值, 建立一個字串物件. 若可複用全域性共享字串物件池中的物件, 則會盡量複用. 否則以最節省記憶體的原則, 來決定物件的編碼 |
-- | robj *createStringObjectFromLongDouble(long double value,int humanfriendly) |
根據浮點數值, 建立一個字串物件 其中引數 humanfriendly 不為0, 則字串以小數形式表達. 否則以exp計數法表達.根據字串表達的長短, 編碼可能是RAW , 或EMBSTR |
釋放介面 | void freeStringObject(robj *o) |
釋放字串物件. 若字串物件底層使用SDS, 則呼叫 sdsfree 釋放這個SDS.否則什麼也不做 |
讀寫介面 | robj *dupStringObject(const robj *o) |
建立一個字串物件的深拷貝副本. 不影響原字串物件的引用計數. 建立的副本與原字串毫無關聯 |
-- | int isSdsRepresentableAsLongLong(sds s,long long *llval) |
判斷SDS字串是否是一個取值在long long 數值範圍內的數值的字串表達. 如果是, 就把相應的數值置在出參中內部呼叫的是 string2ll 來判斷嚴格來講這不應該算是 RedisObject 的介面函式, 而應當算是SDS 的介面函式" |
-- | int isObjectRepresentableAsLongLong(robj *o,long long *llval) |
判斷字串物件是否是一個取值在long long 數值範圍內的數值的字串表達. 如果是, 就把相應的數值置在出參中. |
-- | robj *tryObjectEncoding(robj *o) |
嘗試縮減這個字串物件的記憶體佔用. 策略為: 如果字串物件代表的是一個位於 long 取值範圍內的數值, 則嘗試返回全域性共享字串物件池裡的等價物件. 若由於伺服器配置等原因不成功, 則嘗試將物件編碼改為INT 如果以上都不成功, 則嘗試將物件的編碼改為 EMBSTR 若以上都不成功, 則在物件的編碼為 RAW 的狀態下, 至少呼叫sdsRemoveFreeSpace 來移除掉內部SDS中, 閒置的記憶體空間 |
-- | robj *getDecodedObject(robj *o) |
返回字串物件的一個淺拷貝. 在編碼為 RAW 或EMBSTR 時, 底層資料引用計數+1, 返回一個共享控制程式碼在編碼為 INT 時, 返回一個編碼為RAW 或EMBSTR 的新副本的控制程式碼. 新舊物件之間無關 |
-- | size_t stringObjectLen(robj *o) |
返回字串物件中的字元個數 |
-- | int getDoubleFromObject(const robj *o,double *target) |
從字串物件中解析出數值, 相容整數值 |
-- | int getLongLongFromObject(robj *o,long long *target) |
從字串物件中解析出整數值, 不相容浮點數值 |
-- | int getLongDoubleFromObject(robj *o,long double *target) |
從字串物件中解析出數值, 相容整數值 |
-- | int compareStringObjects(robj *a, robj *b) |
二進位制比較兩個字串物件. 若有字串物件使用的是INT 編碼, 則先會把ptr中的數值轉化為字串表達, 然後再去比較 |
-- | int collateStringObjects(robj *a, robj *b) |
底層呼叫strcoll去比較兩個字串物件. 比較的大小結果受LC_LOCALE 的影響 |
-- | int equalStringObjects(robj *a, robj *b) |
字串判等 |
-- | #define sdsEncodedObject(objptr) |
巨集, 判斷字串物件的內部是否為SDS實現. 即編碼為RAW 或EMBSTR |
3.2 雜湊物件
雜湊物件的底層實現有兩種, 一種是dict
, 一種是ziplist
. 分別對應編碼HT
與ZIPLIST
. 而之前介紹的zipmap
這種結構, 雖然也是一種輕量級的字典結構, 且縱使在原始碼中有相應的編碼巨集值, 但遺憾的是, 至Redis 4.0.10, 目前雜湊物件的底層編碼仍然只有ziplist
與dict
兩種
dict
自不必說, 本身就是字典型別, 儲存鍵值對的. 用ziplist
作為底層資料結構時, 是將鍵值對以<key1><value1><key2><value2>...<keyn><valuen>
這樣的形式儲存在ziplist
中的. 兩種編碼記憶體佈局分別如下:
上圖中不嚴謹的地方有:
ziplist
中每個entry, 除了鍵與值本身的二進位制資料, 還包括其它欄位, 圖中沒有畫出來dict
底層可能持有兩個dictht
例項- 沒有畫出
dict
的雜湊衝突
需要注意的是: 當採用HT
編碼, 即使用dict
作為雜湊物件的底層資料結構時, 鍵與值均是以sds的形式儲存的.
雜湊物件的相關介面如下:
分類 | API名 | 功能 |
---|---|---|
建立介面 | robj *createHashObject(void) |
建立一個空雜湊物件 底層編碼使用 ZIPLIST , 即底層使用ziplist |
釋放介面 | void freeHashObject(robj *o) |
釋放雜湊物件 若雜湊物件底層使用的是dict, 則呼叫 dictRelease 釋放這個dict若雜湊物件底層使用的是ziplist, 則直接釋放掉這個ziplist佔用的連續記憶體空間 |
編碼轉換介面 | void hashTypeConvertZiplist(robj *o, int enc) |
將雜湊物件的編碼從ZIPLIST 轉換為HT , 即底層實現從ziplist轉為dict |
-- | void hashTypeConvert(robj *o, int enc) |
轉換雜湊物件的編碼. 雖然介面設計的好像可以在底層編碼之間互相轉換, 但實際上這個介面的實現, 目前僅支援從 ZIPLIST 轉向HT |
-- | void hashTypeTryConversion(robj *o,robj **argv,int start,int end) |
o 是一個雜湊物件. argv 是其它物件的陣列.(最好是字串物件, 且為SDS實現)這個函式會檢查 argv 陣列中, 從start 到end 之間的所有物件, 如果這些物件中, 但凡有一個物件是字串物件, 且長度超過了用ziplist實現雜湊物件時, ziplist的限長那麼 o 這個雜湊物件的編碼就會從ZIPLIST 轉為HT |
讀寫介面 | int hashTypeSet(robj *o,sds field,sds value,int flags) |
向雜湊物件寫入一個鍵值對. 在底層編碼為 HT 時, flag 將影響插入鍵值對時的具體行為. flag 可有標誌位 HASH_SET_TAKE_VALUE 與HASH_SET_TAKE_FIELD , 若對應位置1, 代表鍵與值直接引用引數值. 否則代表要呼叫sdsdup 介面拷貝鍵與值.在底層編碼為 ZIPLIST 時, 鍵與值必然會被拷貝 |
-- | int hashTypeExists(robj *o, sds field) |
查詢指定鍵在雜湊物件中是否存在 |
-- | unsigned long hashTypeLength(const robj *o) |
查詢雜湊物件中的鍵值對總數 |
-- | int hashTypeGetFromZiplist(robj *o, sds field,unsigned char **vstr,unsigned int *vlen,long long *vll) |
從編碼為ZIPLIST 的雜湊物件中, 取出一個鍵對應的值. 鍵從 field 傳入, 當值為數值型別時, 值以*vll 傳出, 當值為二進位制型別時, 值以*vstr 與*vlen 傳出 |
-- | sds hashTypeGetFromHashTable(robj *o, sds field) |
從編碼為HT 的雜湊物件中, 取出一個鍵對應的值.鍵從 field 傳入, 值以返回值傳出. 若值不存在, 返回NULL" |
-- | "int hashTypeGetValue(robj *o,sds field,unsigned char **vstr,unsigned int *vlen,long long *vll) |
取出雜湊物件中指定鍵對應的值. 若值是數值型別, 則以*vll 傳出, 否則以*vstr 與*vlen 傳出 |
-- | robj *hashTypeGetValueObject(robj *o, sds field) |
取出雜湊物件中指定鍵對應的值, 幷包裝成RedisObject 返回. 返回的物件為字串物件 |
-- | size_t hashTypeGetValueLength(robj *o, sds field) |
取出雜湊物件中指定鍵對應的值的長度 |
-- | int hashTypeDelete(robj *o, sds field) |
刪除雜湊物件中的一個鍵值對. 鍵不存在時返回0, 成功刪除返回1 |
迭代器介面 | hashTypeIterator *hashTypeInitIterator(robj *subject) |
在指定雜湊物件上建立一個迭代器 |
-- | void hashTypeReleaseIterator(hashTypeIterator *hi) |
釋放雜湊物件的迭代器 |
-- | int hashTypeNext(hashTypeIterator *hi) |
讓雜湊迭代器步進一步 |
-- | void hashTypeCurrentFromZiplist(hashTypeIterator *hi,int what,unsigned char **vstr,unsigned int *vlen,long long *vll) |
取出雜湊物件迭代器當前指向的鍵 或值. 當what 傳入OBJ_HASH_KEY 時, 取的是鍵, 否則取的是值.注意, 該函式僅在雜湊物件的編碼為 ZIPLIST 時才能正確執行 |
-- | sds hashTypeCurrentFromHashTable(hashTypeIterator *hi,int what) |
取出雜湊物件迭代器當前指向的鍵 或值. 當what 傳入OBJ_HASH_KEY 時, 取的是鍵, 否則取的是值.注意, 該函式僅在雜湊物件的編碼為 HT 時才能正確執行 |
-- | void hashTypeCurrentObject(hashTypeIterator *hi,int what,unsigned char **vstr,unsigned int *vlen,long long *vll) |
取出雜湊物件迭代器當前指向的鍵或值. 當what 傳入OBJ_HASH_KEY 時, 取的是鍵, 否則取的是值. |
-- | sds hashTypeCurrentObjectNewSds(hashTypeIterator *hi,int what) |
取出雜湊物件迭代器當前指向的鍵或值. 且把鍵或值以一個全新的SDS字串返回. 當what 傳入OBJ_HASH_KEY 時, 取的是鍵, 否則取的是值. |
3.3 列表物件
列表物件的底層實現, 歷史上是有兩種的, 分別是ziplist
與list
, 但截止Redis 4.0.10版本, 所有的列表物件API都不再支援除去quicklist
之外的任何底層實現. 也就是說, 目前(Redis 4.0.10), 列表物件支援的底層實現實質上只有一種, 即是quicklist
.
列表物件的建立API依然支援從ziplist
的例項建立一個列表物件, 即你可以建立一個底層編碼為ZIPLIST
的列表物件, 但如果用該列表物件去呼叫任何其它列表物件的API, 都會導致panic. 在使用之前, 你只能再次呼叫相關的底層編碼轉換介面, 將這個列表物件的底層編碼轉換為QUICKLIST
.
並且遺憾的是, LINKEDLIST
這種編碼, 即底層為list
的列表, 被徹底淘汰了. 也就是說, 截止目前(Redis 4.0.10), Redis定義的10個物件編碼方式巨集名中, 有兩個被完全閒置了, 分別是: OBJ_ENCODING_ZIPMAP
與OBJ_ENCODING_LINKEDLIST
. 從Redis的演進歷史上來看, 前者是後續可能會得到支援的編碼值, 後者則應該是被徹底淘汰了.
列表物件的記憶體佈局如下圖所示:
列表物件的API介面如下:
分類 | API名 | 功能 |
---|---|---|
建立介面 | robj *createQuicklistObject(void) |
建立一個列表物件. 內部編碼為QUICKLIST 即內部使用quicklist實現的列表物件 |
-- | robj *createZiplistObject(void) |
建立一個列表物件. 內部編碼為ZIPLIST 即內部使用ziplist實現的列表物件 |
釋放介面 | void freeListObject(robj *o) |
釋放一個列表物件 |
編碼轉換介面 | void listTypeConvert(robj *subject, int enc) |
轉換列表物件的內部編碼. 雖然介面設計的好你可以在底層編碼之間互相轉換, 但實際上這個介面的實現, 目前僅支援從 ZIPLIST 轉換為QUICKLIST 並且蛋疼的是, 4.0.10這個版本中, 所有的列表物件操作API內部實現都僅支援編碼方式為 QUICKLIST 的列表物件, 其它編碼方式會panic.所以目前為止, 這個API的唯一作用, 就是配合 createZiplistObject 介面, 來使用一個ziplist建立一個內部編碼為QUICKLIST 的列表物件. |
讀寫介面 | void listTypePush(robj *subject,robj *value,int where) |
向列表物件中新增一個資料. 由 where 引數的值控制是在頭部新增, 還是尾部新增.where 可選的值為LIST_HEAD , LIST_TAIL |
-- | robj *listTypePop(robj *subject,int where) |
從列表物件的頭部或尾部取出一個資料. 取出的資料通過被包裝成字串物件後返回. 具體取出位置通過引數 where 控制 |
-- | unsigned long listTypeLength(const robj *subject) |
獲取列表物件中儲存的資料的個數 |
-- | void listTypeInsert(listTypeEntry *entry,robj *value, int where) |
將字串物件中的資料插入到列表物件的頭部或尾部. 插入過程中不會拷貝字串物件持有的資料本身. 但會縮減字串物件的引用計數. |
-- | int listTypeEqual(listTypeEntry *entry, robj *o) |
判斷字串物件o 與列表物件中指定位置上儲存的資料是否相同. |
-- | robj *listTypeGet(listTypeEntry *entry) |
獲取列表物件中指定位置的資料. 位置資訊通過 entry 傳入, 這是一個入參. 資料將拷貝一份後通過SDS形式返回 |
迭代器介面 | listTypeIterator *listTypeInitIterator(robj *subject,long index,unsigned char direction) |
建立一個列表物件迭代器 |
-- | void listTypeReleaseIterator(listTypeIterator *li) |
釋放一個列表物件迭代器 |
-- | int listTypeNext( listTypeIterator *li, listTypeEntry *entry) |
讓列表物件迭代器步進一步, 並將步進之前迭代器所指向的資料儲存在entry 中 |
-- | void listTypeDelete( listTypeIterator *iter, listTypeEntry *entry) |
刪除列表迭代器當前指向的列表物件中儲存的資料. 被刪除的資料通過 entry 返回 |
3.4 集合物件
集合物件的底層實現有兩種, 分別是intset
和dict
. 分別對應編碼巨集中的INTSET
和HT
. 顯然當使用intset
作為底層實現的資料結構時, 集合中儲存的只能是數值資料, 且必須是整數. 而當使用dict
作為集合物件的底層實現時, 是將資料全部儲存於dict
的鍵中, 值欄位閒置不用.
集合物件的記憶體佈局如下圖所示:
集合物件的API介面如下:
分類 | API名 | 功能 |
---|---|---|
建立介面 | robj *createSetObject(void) |
建立一個空集合物件. 底層編碼使用 HT , 即底層使用dict |
-- | robj *createIntsetObject(void) |
建立一個空集合物件. 底層編碼使用 INTSET , 即底層使用intset |
-- | robj *setTypeCreate(sds value) |
建立一個空集合物件. 注意入參雖然攜帶了一個資料, 但這個資料並不會儲存在集合中 這個資料只起到決定編碼方式的作用, 若這個資料是數值的字串表達, 則底層編碼則為 INTSET , 否則為HT |
釋放介面 | void freeSetObject(robj *o) |
釋放集合物件. 若集合物件底層使用的是dict, 則呼叫 dictRelease 釋放這個dict若集合物件底層使用的是intset, 則直接釋放這個intset佔用的連續記憶體 |
編碼轉換介面 | void setTypeConvert(robj *setobj, int enc) |
轉換集合物件的內部編碼 雖然介面設計的好你可以在底層編碼之間互相轉換, 但實際上這個介面的實現, 目前僅支援從 INTSET 轉換為HT |
讀寫介面 | int setTypeAdd(robj *subject, sds value) |
向集合物件中寫入一個資料 |
-- | int setTypeRemove(robj *setobj, sds value) |
刪除集合物件中的一個資料 |
-- | int setTypeIsMember(robj *subject, sds value) |
判斷指定資料是否在集合物件中 |
-- | int setTypeRandomElement(robj *setobj, sds *sdsele, int64_t *llele) |
從集合物件中, 隨機選出一個資料, 將其資料通過出參返回. 若資料是數值型別, 則從 *llele 返回, 否則, 從*sdsele 返回.注意該介面若取得二進位制資料, 則 *sdsele 是直接引用集合內的資料, 而不是拷貝一份 |
-- | unsigned long setTypeSize(const robj *subject) |
返回集合中資料的個數 |
迭代器介面 | setTypeIterator *setTypeInitIterator(robj *subject) |
建立一個集合物件迭代器 |
-- | void setTypeReleaseIterator(setTypeIterator *si) |
釋放集合物件迭代器 |
-- | int setTypeNext( setTypeIterator *si, sds *sdsele, int64_t *llele) |
讓集合迭代器步進一步, 並從出參中返回步進前迭代器所指向的資料. 若資料是數值型別, 則從 *llele 返回, 否則, 從*sdsele 返回注意該介面若取得二進位制資料, 則 *sdsele 是直接引用集合內的資料, 而不是拷貝一份 |
-- | sds setTypeNextObject(setTypeIterator *si) |
讓集合迭代器步進一步, 並把步進前所指向的資料, 拷貝一份, 構造成一個新的SDS, 作為返回值返回 |
3.5 有序集合物件
有序集合的底層實現依然有兩種, 一種是使用ziplist
作為底層實現, 另外一種比較特殊, 底層使用了兩種資料結構: dict
與skiplist
. 前者對應的編碼值巨集為ZIPLIST
, 後者對應的編碼值巨集為SKIPLIST
使用ziplist
來實現在序集合很容易理解, 只需要在ziplist
這個資料結構的基礎上做好排序與去重就可以了. 使用zskiplist
來實現有序集合也很容易理解, Redis中實現的這個跳躍表似乎天然就是為了實現有序集合物件而實現的, 那麼為什麼還要輔助一個dict
例項呢? 我們先看來有序集合物件在這兩種編碼方式下的記憶體佈局, 然後再做解釋:
首先是編碼為ZIPLIST
時, 有序集合的記憶體佈局如下:
然後是編碼為SKIPLIST
時, 有序集合的記憶體佈局如下:
在使用dict
與skiplist
實現有序集合時, 跳躍表負責按分數索引, 字典負責按資料索引. 跳躍表按分數來索引, 查詢時間複雜度為O(lgn). 字典按資料索引時, 查詢時間複雜度為O(1). 設想如果沒有字典, 如果想按資料查分數, 就必須進行遍歷. 兩套底層資料結構均只作為索引使用, 即不直接持有資料本身. 資料被封裝在SDS中, 由跳躍表與字典共同持有. 而資料的分數則由跳躍表結點直接持有(double型別資料), 由字典間接持有.
有序集合物件的API介面如下:
分類 | API名 | 功能 |
---|---|---|
建立介面 | robj *createZsetObject(void) |
建立一個有序集合物件 預設內部編碼為 SKIPLIST , 即內部使用zskiplist與dict來實現有序集合 |
-- | robj *createZsetZiplistObject(void) |
建立一個有序集合物件 指定內部編碼為 ZIPLIST , 即內部使用ziplist來實現有序集合 |
釋放介面 | void freeZsetObject(robj *o) |
釋放一個有序集合物件 |
編碼轉換介面 | void zsetConvert(robj *zobj, int encoding) |
轉換有序集合物件的內部編碼 可以在 ZIPLIST 與SKIPLIST 兩種編碼間轉換 |
-- | void zsetConvertToZiplistIfNeeded(robj *zobj, size_t maxelelen) |
判斷當前有序集合物件是否有必要將底層編碼轉換為ZIPLIST , 如果有必要, 就執行轉換 |
讀寫介面 | int zsetScore(robj *zobj, sds member, double *score) |
獲取有序集合中, 指定資料的得分. 資料由 member 引數攜帶, 通過二進位制判等的方式匹配 |
-- | int zsetAdd( robj *zobj, double score, sds ele, int *flags, double *newscore) |
向有序集合中新增資料, 或更新已存在的資料的得分.flag 是一個in-out引數, 其作為入參, 控制函式的具體行為, 其作為出參, 報告函式執行的結果.作為入參時, *flags 的語義如下:ZADD_INCR 遞增已存在的資料的得分. 如果資料不存在, 則新增資料, 並設定得分. 且若newscore != NULL , 執行操作後, 資料的得分還會賦值給*newscore ZADD_NX 僅當資料不存在時, 執行新增資料並設定得分, 否則什麼也不做ZADD_XX 僅當資料存在時, 執行重置資料得分. 否則什麼也不做作為出參, *flags 的語義如下:ZADD_NAN 資料的得分不是一個數值, 代表內部出現的異常ZADD_ADDED 新資料已經新增至集合中ZADD_UPDATED 資料的得分已經更新ZADD_NOP 函式什麼也沒做 |
-- | int zsetDel(robj *zobj, sds ele) |
從有序集合中移除一個資料 |
-- | long zsetRank(robj *zobj, sds ele, int reverse) |
獲取有序集合中, 指定資料的排名. 若 reverse==0 , 排名以得分升序排列. 否則排名以得分降序排列.第一個資料的排名為0, 而不是1 |
-- | unsigned int zsetLength(const robj *zobj) |
獲取有序集合物件中儲存的資料個數 |