咱們知道一個大型的公司每每都具備複雜的組織結構,成百上千號員工,要作到大而不亂,就必須依靠合理的組織結構來優化內部的交流成本。Redis 內部也有組織結構,不一樣的是這個組織結構要維繫上億的對象,而不是幾百幾千。今天我來向你們呈現 Redis 如何來管理這上億的對象而不會混亂的。java
Redis 的對象不少,可是對象的種類倒是有限的,目前一共只有7種對象。python
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
#define OBJ_HASH 4 /* Hash object. */
#define OBJ_MODULE 5 /* Module object. */
#define OBJ_STREAM 6 /* Stream object. */
複製代碼
看到這裏,確定會要不少人要舉手表示抗議!老錢啊,你這不對啊,HyperLogLog 哪裏去了?Geo 哪裏去了?golang
這個問題提的很是棒!其實這個問題是我在寫這篇文章的時候本身向本身提出的,我在問這個問題的時候,我也不知道爲何,我只是隱約以爲上面這三種高級數據結構在Redis內部應該是混合使用了上面的基礎數據結構,也就是說他們是複合數據結構。可是我須要求證,因而我閱讀了一下源碼證明了個人猜想。redis
HyperLogLog 和 Bitmap 同樣,使用的是一個普通的動態字符串,而 Geo 使用的是 zset。還有一個奇妙的地方就是當你使用 pfadd 構造出來的計數器對象能夠直接使用字符串命令將它的內部所有顯示出來。算法
127.0.0.1:6379> pfadd codehole python java golang
(integer) 1
127.0.0.1:6379> get codehole
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80C\x03\x84MK\x80P\xb8\x80^\xf3"
複製代碼
一樣你也可使用 zset 相關的指令將 geo 的內容顯示出來數組
127.0.0.1:6379> GEOADD city 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"
(integer) 2
127.0.0.1:6379> zrange city 0 -1 withscores
1) "Palermo"
2) "3479099956230698"
3) "Catania"
4) "3479447370796909"
複製代碼
這個問題算是回答完了,接下來我再提出一個問題,平時咱們聽的「跳躍列表 skiplist」,「壓縮列表 ziplist」、「快速列表 quicklist」跟對象類型什麼關係?bash
爲了回答這個問題,接下來要引入 Redis 的對象結構。Redis 全部的對象都有一個相同的「頭結構」,頭部結構中有一個指針指向各自不一樣的「體結構」。微信
typedef struct redisObject {
unsigned type:4; // 對象類型
unsigned encoding:4; // 對象編碼
unsigned lru:24; // LRU時間戳
int refcount; // 引用計數
void *ptr; // 指向體結構的指針
} robj;
複製代碼
咱們注意到 type 字段只有 4bit,最多隻能表示 16 個對象類型,這大概是爲何對象類型要省着用的緣由,太浪費了之後就很差擴展了。數據結構
咱們還注意到有一個 encoding 字段,它也是 4 個位,它表明的是對象的內部結構類型。Redis 爲了節約內存,在集合對象比較小時,採用特殊結構進行存儲。好比hash對象在內部 key 不多 (size<512) 而且 value 值較短 (len<64) 的時候採用 ziplist 進行存儲,超過了這個數量就使用標準的 hashtable 存儲。app
Type是對外統一接口是形象,Encoding是對內具體實現是骨肉。
咱們翻翻源碼來看看 encoding 都有哪些
#define OBJ_ENCODING_RAW 0 // 可修改的長字符串
#define OBJ_ENCODING_INT 1 // 整型字符串
#define OBJ_ENCODING_HT 2 // hashtable
#define OBJ_ENCODING_ZIPMAP 3 // 壓縮map,已經廢棄不用,改用ziplist
#define OBJ_ENCODING_LINKEDLIST 4 // 雙向鏈表,已廢棄不用,改用quicklist
#define OBJ_ENCODING_ZIPLIST 5 // 壓縮列表
#define OBJ_ENCODING_INTSET 6 // 整數集合,個數少全是整數的set
#define OBJ_ENCODING_SKIPLIST 7 // 跳躍列表,zset的標準內部結構
#define OBJ_ENCODING_EMBSTR 8 // 只讀短字符串
#define OBJ_ENCODING_QUICKLIST 9 // 快速列表,存儲list
#define OBJ_ENCODING_STREAM 10 // 流
複製代碼
看到這裏我開始有點小心,encoding 只有 4bit,可是已經用掉了 11 個值,之後要是擴展改怎麼辦?這個問題這裏就很差回答了,你們能夠本身討論。
Type和Encoding的對應關係以下
1. string ==> raw|embstr|int
2. list ==> quicklist
3. hash ==> ziplist|hashtable
4. set ==> intset|hashtable
5. zset ==> ziplist|skiplist
6. stream => stream
複製代碼
接下來咱們要開始深刻內部結構了,將每個結構都過一遍,限於篇幅,不能講的太詳細。
第一個咱們要講的是字典,由於它過重要了,Redis 對象樹的主幹就是字典結構,key 是對象的名稱,value 是各類不一樣的對象,全部的對象都掛在一棵字典上。除了容納全部對象的主幹字典外,還有容納全部帶過時時間的對象的過時主幹字典,它的 key 是對象的名稱,value 是對象的過時時間戳。
typedef struct redisDb {
dict *dict;
dict *expires;
...
} redisDb;
複製代碼
字典的 value 呈現出了多態性,它能夠是一個單純的整數或者浮點數,也能夠是一個對象,會有一個統一的對象頭,也就是前面的 redisObject 結構體,會根據 type 字段和 encoding 字段來決定 ptr 字段指向的具體數據結構。咱們來看一下字典的結構體代碼定義
// dict
typedef struct dict {
dictType *type; // 字典的接口實現,爲字典帶來多態性
void *privdata; // 存儲字典的附加信息
dictht ht[2]; // 注意這裏不是指向指針的數組,爲何?
long rehashidx; // 漸進式rehash時記錄當前rehash的位置
unsigned long iterators;
} dict;
// dict hashtable
typedef struct dictht {
dictEntry **table; // 指向第一維數組
unsigned long size; // 數組的長度
unsigned long sizemask; // 用於快速hash定位 sizemask = size - 1
unsigned long used; // 數組中的元素個數
} dictht;
// 定義了字典功能的接口
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;
// key-value wrapper
typedef struct dictEntry {
void *key;
union {
void *val; // sds|set|dict|zset|quicklist
uint64_t u64; // 用於過時字典,val存儲過時時間戳
int64_t s64; // Don't watch me!
double d; // 用於zset,存儲score值
} v;
struct dictEntry *next;
} dictEntry;
複製代碼
字典結構的內部實現是兩個 hashtable,爲何是兩個 hashtable 呢,這個涉及到字典的漸進式擴容和所容,咱們後再講。一般狀況下,咱們只會使用到ht[0],一個單純的 hashtable
咱們看看 hashtable 的內部結構。hashtable 的結構和 Java 語言的 HashMap 初級版是同樣的,爲何說初級版本呢,由於 Java8 對 HashMap 作了改造,在 hash 不均勻的時候作了複雜的優化處理,至於具體的優化方法,這裏我就不作詳細解釋了,感興趣能夠搜索相關資料。
咱們看下字典的內部結構,它是一個二維的
查找過程以下,爲了方便閱讀,我仔細去掉了額外的須要考慮「漸進式遷移」的部分代碼
dictEntry *dictFind(dict *d, const void *key) {
dictEntry *he;
uint64_t h, idx, table;
if (d->ht[0].used == 0) return NULL; /* dict is empty */
h = dictHashKey(d, key); // 計算hash值
idx = h & d->ht[0].sizemask; // 定位數組位置
he = d->ht[0].table[idx]; // 獲取鏈表表頭
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key))
return he; // 找到了就返回
he = he->next; // 找不到繼續遍歷
}
return NULL;
}
複製代碼
其中 dictHashKey 和 dictCompareKeys 會分別調用相應字典的多態函數
#define dictHashKey(d, key) (d)->type->hashFunction(key)
#define dictCompareKeys(d, key1, key2) \ (((d)->type->keyCompare) ? \ (d)->type->keyCompare((d)->privdata, key1, key2) : \ (key1) == (key2))
複製代碼
須要注意到定位數組用的是按位操做,這是由於字典的第一維數組的長度都會 2^n 。對於 2^n 長度的數組來講,對數組長度的取模操做等價於按位操做
sizemask = size - 1;
idx = h & d->ht[0].sizemask ==> idx = h % d->ht[0].size
複製代碼
咱們在使用 Java 的 HashMap 時會小心若是對象的 hashcode 不均勻,會致使鏈表長度差異較大,個別鏈表會特別長,對性能就會產生較大影響。因此 Java8 對 HashMap 的鏈表進行了適當的改造,若是鏈表的長度超過 8,就會轉變成一顆紅黑樹,用於提高查找效率。
那爲何 Redis 不須要考慮這點呢?
這是由於 Java 的 HashMap 容納的 key 對象是不可控的,它能夠是任意對象,若是對象的 hashCode 方法返回的數值不均勻就會帶來性能問題。
可是 Redis 的字典容納的 key 都是 sds 動態字符串,它的 hashCode 是均勻的可控的,Redis的內置 hash(siphash) 算法能夠保證字符串的 hash 值很是均勻。
接下來咱們談談字典的擴容。
在 Java 的 HashMap 裏面,擴容是申請一個新的數組,這個數組是舊數組的兩倍大小,而後一次性將舊數組下面掛接的全部元素一次性所有遷移到新數組中。若是字典中元素特別多,擴容會比較消耗計算資源,也就是一般所說的「卡頓」。
Redis 內存裏能夠容納的對象會上億,這些對象是使用字典組織起來的。若是 Redis 字典的擴容策略和 Java 的 HashMap 同樣,這樣龐大的字典確定也會遭遇「卡頓問題」。
Redis 爲了解決這個問題,它使用了漸進式遷移策略。當字典須要擴容時,它會申請一個新的 hashtable 放在字典的 ht[1] 中,在遷移完成以前新舊兩個 hashtable 將會共存,也就是 ht[1] 和 ht[0] 兩個字段值同時存在。
int dictExpand(dict *d, unsigned long size) {
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
dictht n;
unsigned long realsize = _dictNextPower(size);
if (realsize == d->ht[0].size) return DICT_ERR;
// 分配一個新的hashtable
n.size = realsize;
n.sizemask = realsize - 1;
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
// 若是是空字典的第一次擴容,那就掛到ht[0]上
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
// 掛在ht[1]上,準備進行漸進式遷移
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
複製代碼
在後續該字典的每一個指令中,Redis都會將舊 hashtable 的一部分鍵值對遷移到新的 hashtable 中。目前漸進式遷移每次遷移 10 個槽位,也就是最多 10 個鏈表,平均一個鏈表的長度大約是 1。看到這裏我不由要小心一個大型的字典須要漸進式遷移多少次才能完成。若是沒有了後續的讀寫操做,是否是就永遠沒法遷移完成了呢?這個讀者能夠繼續思考。
當 Redis 中積累了上億個對象時,這顆對象樹的主幹是一個字典,這個字典是很是大的,它也須要擴容。若是這個漸進式擴容的時間比較漫長,Redis 的每一個指令都須要進行漸進式遷移,勢必會持續影響總體的性能,並且內存會長期處於一個比較高的冗餘狀態。
因此 Redis 對於這個主幹字典採起了按期主動遷移法,每隔 1ms 都會執行漸進式遷移,每次遷移不超過 1ms,以避免致使正常的指令卡頓。
// 漸進式rehash,最多持續時間ms
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
// 每次執行100步(每步10個槽位),停下來看看時間,若是超出時間就中斷
while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
// 對指定db進行漸進式rehash
// 優先遷移全部對象的主幹字典,再考慮過時對象字典
int incrementallyRehash(int dbid) {
if (dictIsRehashing(server.db[dbid].dict)) {
dictRehashMilliseconds(server.db[dbid].dict,1);
return 1;
}
if (dictIsRehashing(server.db[dbid].expires)) {
dictRehashMilliseconds(server.db[dbid].expires,1);
return 1;
}
return 0;
}
複製代碼
同時若是 Redis 正在進行 bgsave 或者 bgaofrewrite 開啓子進程來執行持久化操做時,須要遍歷整顆對象樹。爲了不父子進程過多的頁面分離出來拉高總體內存佔用,在這兩條指令執行時,儘可能不執行字典的擴容 dict_can_resize = false,除非字典已經特別擁擠,這個擁擠程度的閾值默認是 dict_force_resize_ratio = 5,也就是字典元素的個數相對第一維數組的長度的比例。
static int _dictExpandIfNeeded(dict *d)
{
// 若是正在執行漸進式rehash,那就暫時不要擴容
if (dictIsRehashing(d)) return DICT_OK;
// 若是是空字典,那就進行第一次擴容
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
// 綜合考慮字典的擁擠程度以及實例是否處於bgsave/bgaofrewrite
// 來決定是否進行擴容
if(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;
}
複製代碼
有關字典的內容就講到這裏,下一篇咱們繼續看看字典裏面容納的 key 。字典的 key 放的都是字符串,因此下一篇咱們要講的內容是字符串的內部結構,敬請期待。
本文節選之掘金在線技術小冊《Redis 深度歷險》,對 Redis 感興趣請點擊鏈接深刻閱讀《Redis 深度歷險》
閱讀更多深度技術文章,掃一掃上面的二維碼關注微信公衆號「碼洞」