在前面 淺談 Redis 簡單介紹過字典,是用來存儲數據庫所有 key-value 的,同時如果指定 key 爲 哈希時,字典也是其 value 的底層實現之一,今天就來詳細聊聊。
字典的數據結構主要由三部分組成:dict(字典)、dictht(哈希表)、dictEntry(哈希表節點)。
先來介紹下後兩個結構 dictht 和 dictEntry。
/* 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; /* 哈希表大小掩碼,總是等於size-1,主要用於計算索引 */
unsigned long used; /* 已使用節點數,即已使用鍵值對數 */
} dictht;
typedef struct dictEntry {
void *key; /* 鍵 */
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;/* 關聯的值 */
struct dictEntry *next; /* 採用鏈表法解決鍵衝突, next 指向下一個衝突的鍵 */
} dictEntry;
dictht 爲哈希表,採用數組加鏈表來存儲數據並解決哈希衝突。table 爲哈希節點存放的數組。size 爲數組長度,默認爲 4。used 爲當前已存放的節點數,至於 size 和 used 之間的關係在介紹 dict 結構時詳談,也就是擴容和縮容。
這裏詳細介紹下 sizemask, 在 Redis 中表示哈希表大小掩碼,長度爲 size-1,是用來計算哈希節點的索引值的。在 Redis 中哈希表的長度都是 2 的倍數,因此 sizemask 用二進制表示時每位都是 1,比如,默認 size 爲 4,那麼 sizemask 爲 3,用二進制表示爲 11,當一個 key 經過哈希函數後會得到一個 uint64_t 類型值,再和 sizemask 做與運算【之所以做與運算而不是求與,是因爲在計算機中位運算可比與運算快很多】,會得到一個 0-3 的索引值。
d->ht[0].sizemask = d->ht[0].size - 1;
uint64_t h = x(d)->type->hashFunction(key)
idx = h & d->ht[0].sizemask ==> idx = h % d->ht[0].size
目前 Redis 5 版本中的哈希函數採用的是開源的 siphash,一個 key 經過計算後會得到一個 uint64_t 值。但哈希函數再怎麼設計,也擋不住出現碰撞的概率,也就是兩個完全不同的 key 經過計算後出現同一個哈希值的,這時就需要使用 dictEntry 中 next 字段了。由於 Redis 採用的是單鏈表存儲衝突鍵,那麼就用頭插法來存儲衝突的鍵了。比如,name 和 age 兩個鍵經過哈希計算後得到同一個值,name 已經存儲在了 dictEntry 中,那麼新插入的 age 的 next 則存儲 name 所在 dictEntry 中的指針。
dictEntry 節點裏的 *key 存儲着鍵,v 存儲着值,但由於在 Redis 中有五個常用的值類型,因此 v 是呈多態性的,需要一個 redisObject 結構體來指定具體的 type 是什麼 、encoding 是什麼 、以及 ptr 對應的底層數據結構。
redis 127.0.0.1:6379> set name molaifeng
OK
如執行上面的一個 set 命令,*key 爲 name,v 指向 redisObject,redisObject 的 type 爲 0 表示是一個字符串類型的值,encoding 爲 8 表示底層採用 OBJ_ENCODING_EMBSTR 存儲, ptr 就是其具體的存儲方式了。
// dict.h
typedef struct dict {
dictType *type; /* 包含了自定義的函數,比如計算 key 的哈希值 */
void *privdata; /* 私有數據,供 dictType 參數用 */
dictht ht[2]; /* 兩張哈希表,ht[0] 存數據,ht[1] 供 rehash 用 */
long rehashidx; /* rehash 標識,默認爲 -1 表示當前字典是沒有進行 rehash 操作;
不爲 -1 時,代表正在 rehash,存儲的是當前哈希表正在 rehash 的 ht[0] 的索引值 */
unsigned long iterators; /* 當前字典目前正在運行的安全迭代器的數量 */
} dict;
typedef struct dictType {
unsigned int (*hashFunction)(const void *key); /* 計算 hash 值的函數 */
void *(*keyDup)(void *privdata, const void *key); /* 複製 key 的函數 */
void *(*valDup)(void *privdata, const void *obj); /* 複製 value 的函數 */
int (*keyCompare)(void *privdata, const void *key1, const void *key2); /* 比較 key 的函數 */
void (*keyDestructor)(void *privdata, void *key); /* 銷燬 key 的析構函數 */
void (*valDestructor)(void *privdata, void *obj); /* 銷燬 val 的析構函數 */
} dictType;
其實 dict 是用來統籌 dictht 和 dictEntry 的,規定了 dictht 數組的長度(默認爲 4)。
// dict.h
/* This is the initial size of every hash table */
#define DICT_HT_INITIAL_SIZE 4
什麼時候擴容?主要是執行下面的 _dictExpandIfNeeded 方法。
// dict.c
/* ------------------------- private functions ------------------------------ */
/* 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. */
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. */
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;
}
- 如果當前字典正在 rehash 時,那麼不擴容
// dict.h
#define dictIsRehashing(d) ((d)->rehashidx != -1)
-
如果 d->ht[0] 數組長度爲 0 時那麼就執行擴容,其實就是初始化,默認長度爲 4。
-
如果 d->ht[0] 已存的元素超過了 d->ht[0] 數組的大小,並且當下面兩條滿足其中一條時擴容
-
如果 dict_can_resize 爲 1 時(此值默認爲 1),通過追蹤調用棧發現 updateDictResizePolicy 此方法是來控制此值的
// server.c
static int dict_can_resize = 1;
/* This function is called once a background process of some kind terminates,
* as we want to avoid resizing the hash tables when there is a child in order
* to play well with copy-on-write (otherwise when a resize happens lots of
* memory pages are copied). The goal of this function is to update the ability
* for dict.c to resize the hash tables accordingly to the fact we have o not
* running childs. */
void updateDictResizePolicy(void) {
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
dictEnableResize();
else
dictDisableResize();
}
// dict.c
void dictEnableResize(void) {
dict_can_resize = 1;
}
void dictDisableResize(void) {
dict_can_resize = 0;
}
也就是如果當前 Redis 沒有子進程在執行 AOF 文件重寫或者生成 RDB 文件時就把 dict_can_resize 置爲 1 並擴容,否則置爲 0。
- 如果 d->ht[0] 已存的元素和 d->ht[0] 數組的大小的比值大於閾值 dict_force_resize_ratio(默認爲 5)時則擴容
// server.c
static unsigned int dict_force_resize_ratio = 5;
有擴容,當然就有縮容了
// dict.h
#define dictSlots(d) ((d)->ht[0].size+(d)->ht[1].size)
#define dictSize(d) ((d)->ht[0].used+(d)->ht[1].used)
// server.h
#define HASHTABLE_MIN_FILL 10 /* Minimal hash table fill 10% */
// server.c
int htNeedsResize(dict *dict) {
long long size, used;
size = dictSlots(dict);
used = dictSize(dict);
return (size > DICT_HT_INITIAL_SIZE &&
(used*100/size < HASHTABLE_MIN_FILL));
}
兩個條件,當 ht[0] 元素超過 4 個時,並且負載因子小於 10% 。再來深究下其調用棧
// server.h
#define CRON_DBS_PER_CALL 16
// server.c
void tryResizeHashTables(int dbid) {
if (htNeedsResize(server.db[dbid].dict))
dictResize(server.db[dbid].dict);
if (htNeedsResize(server.db[dbid].expires))
dictResize(server.db[dbid].expires);
}
void databasesCron(void) {
/* Expire keys by random sampling. Not required for slaves
* as master will synthesize DELs for us. */
if (server.active_expire_enabled) {
if (server.masterhost == NULL) {
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
} else {
expireSlaveKeys();
}
}
/* Defrag keys gradually. */
if (server.active_defrag_enabled)
activeDefragCycle();
/* Perform hash tables rehashing if needed, but only if there are no
* other processes saving the DB on disk. Otherwise rehashing is bad
* as will cause a lot of copy-on-write of memory pages. */
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {
/* We use global counters so if we stop the computation at a given
* DB we'll be able to start from the successive in the next
* cron loop iteration. */
static unsigned int resize_db = 0;
static unsigned int rehash_db = 0;
int dbs_per_call = CRON_DBS_PER_CALL;
int j;
/* Don't test more DBs than we have. */
if (dbs_per_call > server.dbnum) dbs_per_call = server.dbnum;
/* Resize */
for (j = 0; j < dbs_per_call; j++) {
tryResizeHashTables(resize_db % server.dbnum);
resize_db++;
}
/* Rehash */
if (server.activerehashing) {
for (j = 0; j < dbs_per_call; j++) {
int work_done = incrementallyRehash(rehash_db);
if (work_done) {
/* If the function did some work, stop here, we'll do
* more at the next cron loop. */
break;
} else {
/* If this db didn't need rehash, we'll try the next one. */
rehash_db++;
rehash_db %= server.dbnum;
}
}
}
}
}
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
databaseCron();
...
server.cronloops++;
return 1000/server.hz;
}
void initServer(void) {
...
/* Create the timer callback, this is our way to process many background
* operations incrementally, like clients timeout, eviction of unaccessed
* expired keys and so forth. */
if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
serverPanic("Can't create event loop timers.");
exit(1);
}
...
}
int main(int argc, char **argv) {
...
initServer();
...
}
發現調用棧爲 main(系統主函數) --> initServer(服務器初始化函數)–> 調用 aeCreateTimeEvent 將 serverCron 做爲 callback 註冊到全局的 eventLoop 結構當中,每隔 1000/server.hz 毫秒執行一次。
// redis.conf
# Redis calls an internal function to perform many background tasks, like
# closing connections of clients in timeout, purging expired keys that are
# never requested, and so forth.
#
# Not all tasks are performed with the same frequency, but Redis checks for
# tasks to perform according to the specified "hz" value.
#
# By default "hz" is set to 10. Raising the value will use more CPU when
# Redis is idle, but at the same time will make Redis more responsive when
# there are many keys expiring at the same time, and timeouts may be
# handled with more precision.
#
# The range is between 1 and 500, however a value over 100 is usually not
# a good idea. Most users should use the default of 10 and raise this up to
# 100 only in environments where very low latency is required.
hz 10
每次執行 serverCron 時,也會執行函數裏的 databasesCron 函數,而此函數就則會調用 tryResizeHashTables 檢查是否需要縮容。
/* Resize the table to the minimal size that contains all the elements,
* but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{
int minimal;
if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
minimal = d->ht[0].used;
if (minimal < DICT_HT_INITIAL_SIZE)
minimal = DICT_HT_INITIAL_SIZE;
return dictExpand(d, minimal);
}
當滿足 htNeedsResize 函數裏的兩個條件時,則會執行 dictResize 函數。該函數很簡單,主要是先判斷 dict_can_resize 爲 1 或者當前字典沒在進行 rehash,接着就是確定縮容後的數組長度了,最小爲默認的 4,和執行擴容的方法一樣,最後都會調用 dictExpand 函數。
// dict.c
/* Expand or create the hash table */
int dictExpand(dict *d, unsigned long 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;
dictht n; /* the new hash table */
unsigned long realsize = _dictNextPower(size);
/* 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*));
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. */
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
unsigned long i = DICT_HT_INITIAL_SIZE;
if (size >= LONG_MAX) return LONG_MAX + 1LU;
while(1) {
if (i >= size)
return i;
i *= 2;
}
}
看了 _dictNextPower 後發現,不論擴容還是縮容時後,字典的 d->ht[0] 數組的長度都是 2 的倍數。至於 dictExpand 此函數就是爲漸進式 rehash 做準備的,初始化 d->ht[1],當然了長度就是剛剛提到的 _dictNextPower 重新計算的長度,並把 d->rehashidx 置爲 0,表明此字典可以進行漸進式 rehash 了。
Redis 之所以選擇漸進式 rehash,是因爲其作爲高性能內存數據庫,當某個字典的 key-value 達到 百萬、千萬甚至億級時,如果直接一次性 rehash,那麼過程就會很緩慢,同時提供服務的 Redis 在一段時間內就有可能歇菜了,如果是集羣,就會引起雪崩效應。漸進式則不同,採取的是分而治之的策略,把一次性操作平攤到對字典進行增、刪、改、查上,從而在某個時間點,d->ht[0] 上的所有 key-value 都會到 d->ht[1] 上,然後清空 d->ht[0],對調兩者的值,並把 d->rehashidx 重新置爲 -1,從而完成漸進式 rehash。
先來看看常用的增、刪、改、查操作
// dict.c
/* Add an element to the target hash table */
int dictAdd(dict *d, void *key, void *val)
{
dictEntry *entry = dictAddRaw(d,key,NULL);
if (!entry) return DICT_ERR;
dictSetVal(d, entry, val);
return DICT_OK;
}
/* Low level add or find:
* This function adds the entry but instead of setting a value returns the
* dictEntry structure to the user, that will make sure to fill the value
* field as he wishes.
*
* This function is also directly exposed to the user API to be called
* mainly in order to store non-pointers inside the hash value, example:
*
* entry = dictAddRaw(dict,mykey,NULL);
* if (entry != NULL) dictSetSignedIntegerVal(entry,1000);
*
* Return values:
*
* If key already exists NULL is returned, and "*existing" is populated
* with the existing entry if existing is not NULL.
*
* If key was added, the hash entry is returned to be manipulated by the caller.
*/
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
long index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d);
/* Get the index of the new element, or -1 if
* the element already exists. */
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;
}
/* This function performs just a step of rehashing, and only if there are
* no safe iterators bound to our hash table. When we have iterators in the
* middle of a rehashing we can't mess with the two hash tables otherwise
* some element can be missed or duplicated.
*
* This function is called by common lookup or update operations in the
* dictionary so that the hash table automatically migrates from H1 to H2
* while it is actively used. */
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
/* Performs N steps of incremental rehashing. Returns 1 if there are still
* keys to move from the old to the new hash table, otherwise 0 is returned.
*
* Note that a rehashing step consists in moving a bucket (that may have more
* than one key as we use chaining) from the old to the new hash table, however
* since part of the hash table may be composed of empty spaces, it is not
* guaranteed that this function will rehash even a single bucket, since it
* will visit at max N*10 empty buckets in total, otherwise the amount of
* work it does would be unbound and the function may block for a long time. */
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);
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 */
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... */
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;
}
看了字典的增加操作調用鏈,dictAdd --> dictAddRaw --> _dictRehashStep --> dictRehash,進一步發現,原來在漸進式 rehash 時,每次添加 key-value 時,都會進行一次 rehash 操作,此操作完成後,再進行正常的添加操作。其實其他三個操作也是如此,就不一一細看了。但光靠這四個操作執行各一次的 rehash 也不行吶,這得多久,還得有其他的機制一起來加速 rehash。這個機制就在之前提到 databasesCron 函數,裏面會執行 incrementallyRehash 批量 rehash。
// server.c
/* Our hash table implementation performs rehashing incrementally while
* we write/read from the hash table. Still if the server is idle, the hash
* table will use two tables for a long time. So we try to use 1 millisecond
* of CPU time at every call of this function to perform some rehahsing.
*
* The function returns 1 if some rehashing was performed, otherwise 0
* is returned. */
int incrementallyRehash(int dbid) {
/* Keys dictionary */
if (dictIsRehashing(server.db[dbid].dict)) {
dictRehashMilliseconds(server.db[dbid].dict,1);
return 1; /* already used our millisecond for this loop... */
}
/* Expires */
if (dictIsRehashing(server.db[dbid].expires)) {
dictRehashMilliseconds(server.db[dbid].expires,1);
return 1; /* already used our millisecond for this loop... */
}
return 0;
}
// dict.c
/* Rehash for an amount of time between ms milliseconds and ms+1 milliseconds */
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
看了這兩個函數再結合前面提到的 serverCron 每隔 1000/server->hz 毫秒的執行頻率,按照配置文件默認的 10,那麼也就是每隔 100 毫秒會批量執行 100 個數組長度的字典 rehash。如此一來,單步配合批量就協同完成了漸進式 rehash 了。
最後來說說迭代器。
/* 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;
整個結構佔 48 個字節,其中 *d 爲當前迭代的字典,index 爲當前讀取到的哈希表中具體的索引值,table 爲具體的某張表(有 ht[0] 和 ht[1] 兩張表),safe 表示當前迭代器是否爲安全模式,*entry 和 *nextEntry 則分別爲當前節點和下一個節點,fingerprint 爲在 safe 爲 0 也就是不安全模式下的整個字典指紋。
/* A fingerprint is a 64 bit number that represents the state of the dictionary
* at a given time, it's just a few dict properties xored together.
* When an unsafe iterator is initialized, we get the dict fingerprint, and check
* the fingerprint again when the iterator is released.
* If the two fingerprints are different it means that the user of the iterator
* performed forbidden operations against the dictionary while iterating. */
long long dictFingerprint(dict *d) {
long long integers[6], hash = 0;
int j;
integers[0] = (long) d->ht[0].table;
integers[1] = d->ht[0].size;
integers[2] = d->ht[0].used;
integers[3] = (long) d->ht[1].table;
integers[4] = d->ht[1].size;
integers[5] = d->ht[1].used;
/* We hash N integers by summing every successive integer with the integer
* hashing of the previous sum. Basically:
*
* Result = hash(hash(hash(int1)+int2)+int3) ...
*
* This way the same set of integers in a different order will (likely) hash
* to a different number. */
for (j = 0; j < 6; j++) {
hash += integers[j];
/* For the hashing step we use Tomas Wang's 64 bit integer hash. */
hash = (~hash) + (hash << 21); // hash = (hash << 21) - hash - 1;
hash = hash ^ (hash >> 24);
hash = (hash + (hash << 3)) + (hash << 8); // hash * 265
hash = hash ^ (hash >> 14);
hash = (hash + (hash << 2)) + (hash << 4); // hash * 21
hash = hash ^ (hash >> 28);
hash = hash + (hash << 31);
}
return hash;
}
這裏簡要的說下 fingerprint 這個字段,當迭代器爲非安全模式時,會在首次迭代時算下整個 dict 的指紋,看上面的代碼也就是把 ht[0] 及 ht[1] 兩張表的 used、size 和 table 組合並生成 64 位的哈希值,並存在 fingerprint 字段裏,在迭代結束時再對比下,如果迭代過程中只要字典有變化,那麼整個迭代失敗。
依據迭代器的 safe 取值不同,分爲兩種迭代器,當值爲 0 時,是非安全也即普通迭代器,爲 1 時爲安全迭代器,下面就來介紹下這兩種迭代器。
兩種迭代器主要有四個相關的迭代 API 函數。
// dict.h
dictIterator *dictGetIterator(dict *d); /* 初始化普通迭代器 */
dictIterator *dictGetSafeIterator(dict *d); /* 初始化安全迭代器 */
dictEntry *dictNext(dictIterator *iter); /* 具體的迭代函數 */
void dictReleaseIterator(dictIterator *iter); /* 釋放迭代器 */
再看下具體實現
// dict.c
dictIterator *dictGetIterator(dict *d)
{
dictIterator *iter = zmalloc(sizeof(*iter));
iter->d = d;
iter->table = 0;
iter->index = -1;
iter->safe = 0;
iter->entry = NULL;
iter->nextEntry = NULL;
return iter;
}
dictIterator *dictGetSafeIterator(dict *d) {
dictIterator *i = dictGetIterator(d);
i->safe = 1;
return i;
}
dictEntry *dictNext(dictIterator *iter)
{
while (1) {
if (iter->entry == NULL) {
dictht *ht = &iter->d->ht[iter->table];
if (iter->index == -1 && iter->table == 0) {
if (iter->safe)
iter->d->iterators++;
else
iter->fingerprint = dictFingerprint(iter->d);
}
iter->index++;
if (iter->index >= (long) ht->size) {
if (dictIsRehashing(iter->d) && iter->table == 0) {
iter->table++;
iter->index = 0;
ht = &iter->d->ht[1];
} else {
break;
}
}
iter->entry = ht->table[iter->index];
} else {
iter->entry = iter->nextEntry;
}
if (iter->entry) {
/* We need to save the 'next' here, the iterator user
* may delete the entry we are returning. */
iter->nextEntry = iter->entry->next;
return iter->entry;
}
}
return NULL;
}
void dictReleaseIterator(dictIterator *iter)
{
if (!(iter->index == -1 && iter->table == 0)) {
if (iter->safe)
iter->d->iterators--;
else
assert(iter->fingerprint == dictFingerprint(iter->d));
}
zfree(iter);
}
首先普通迭代器調用 dictGetIterator 初始化迭代器,安全迭代器多了個步驟的,先調用 dictGetIterator 初始化後,把 safe 字段置爲 1。
然後迭代的時候調用 *dictNext 依次取出對應節點的值。在普通迭代器模式下,和上面介紹的一樣在首次迭代時計算 dict 的 fingerprint ,來保證迭代過程中此 dict 不發生任何變化,而安全迭代器則把 dict 的 iterators 值加 1。之後便分別遍歷 ht[0] 和 ht[1] 表的節點元素,同時爲了防止遍歷時用戶刪除了當前遍歷的節點,於是使用變量 nextEntry 存儲了當前節點的下一個節點。
最後遍歷結束時,便調用 dictReleaseIterator 釋放掉迭代器:普通迭代器會比較一開始的 dictFingerprint 和 釋放時的 dictFingerprint 是否一致,不一致則報異常,由此來保證迭代數據的準確性;安全迭代器則會把 dict 的 iterators 值減一,也就是把此字典的當前運行的迭代器數量減 1。
依據上面的說明,可以推出:普通迭代器適用於只讀的場景,畢竟一旦字典數據有變動就前功盡棄了;而安全迭代器則不在乎這些,那麼安全迭代器是如何保證在迭代過程中數據的準確性呢?
// dict.c
/* This function performs just a step of rehashing, and only if there are
* no safe iterators bound to our hash table. When we have iterators in the
* middle of a rehashing we can't mess with the two hash tables otherwise
* some element can be missed or duplicated.
*
* This function is called by common lookup or update operations in the
* dictionary so that the hash table automatically migrates from H1 to H2
* while it is actively used. */
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
在前面提到漸進式 rehash 時說過,在字典的增刪改查中,會進行一次的 rehash,但沒提到的是這裏有個前提的,那就是當前字典沒有運行的迭代器,也就是 d->iterators 爲 0 時才進行,而在安全迭代器首次迭代時會把 d->iterators 加 1 的,也就是安全迭代器是通過禁止 rehash 來保證數據的準確性,一旦字典沒有了迭代器,那麼就可以 rehash 了。
費勁巴拉的介紹了兩種迭代器,那 Redis 中的哪些場景使用呢?
127.0.0.1:7002> lpush today_cost 30 1.5 10 8
-> Redirected to slot [12435] located at 127.0.0.1:7003
(integer) 4
127.0.0.1:7003> sort today_cost
1) "1.5"
2) "8"
3) "10"
4) "30"
127.0.0.1:7003> sort today_cost desc
1) "30"
2) "10"
3) "8"
4) "1.5"
127.0.0.1:7003>
sort 命令主要是用來排序的,在底層調用的就是普通迭代器。
127.0.0.1:7003> keys *
1) "today_cost"
keys 命令用於查找所有符合給定模式 pattern 的 key,同時查找過程中會刪除遇到過期的 key,,在底層調用的就是安全迭代器,當然了,生產環境中還是屏蔽掉此命令爲好,畢竟隱患太多,要是執行 keys * 那就又歇菜了。
keys 命令太危險,畢竟是整個庫遍歷否則模式的,於是 Redis 在 2.8 版本現在了 scan 命令,通過指定 cursor(遊標)來分批遍歷了,這個和漸進式 rehash 的思想一致,分而治之,保持 Redis 的高性能。但分批的遍歷時是可以 rehash 的,那麼 Redis 是如何保證 rehash 過程中準確而又不重複遍歷獲取數據呢?
不管是 scan、sscan、hscan 還是 zscan,最後調用的都是 dictScan。
unsigned long dictScan(dict *d,
unsigned long v,
dictScanFunction *fn,
dictScanBucketFunction* bucketfn,
void *privdata)
{
dictht *t0, *t1; /* 定文兩個哈希表變量 */
const dictEntry *de, *next; /* 定文兩個哈希節點變量 */
unsigned long m0, m1; /* 定義兩個無符號長整型變量 */
if (dictSize(d) == 0) return 0; /* 如果當前字典兩個哈希表的存儲元索都爲空則返回 0 */
if (!dictIsRehashing(d)) { /* 如果當前字典沒有在 rehash, 說明操作都是在 ht[0] 進行 */
t0 = &(d->ht[0]); /* t0 存儲 d->ht[0] 地址 */
m0 = t0->sizemask; /* m0 存儲 t0 的掩碼,爲了計算索引用 */
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]); /* 如果傳了bucketfn 參數那麼就回調此函數 */
de = t0->table[v & m0]; /* de 爲t0 表中具體某個哈希節點,V & mO 是爲了防止縮容導致索引溢出 */
while (de) { /* 如果哈希節點不爲 NULL */
next = de->next; /* next 存儲單錘表的下一個節點 */
fn(privdata, de); /* 回調 fn 函教 */
de = next; /* 把 next 賦值給 de,一旦 next 爲 NULL,while 循環結束 */
}
/* Set unmasked bits so incrementing the reversed cursor
* operates on the masked bits */
v |= ~m0; /* 掩碼按位取反,遊標再和其進行或運算 */
/* Increment the reverse cursor */
v = rev(v); /* 二進制逆轉 */
v++; /* 加 1 */
v = rev(v); /* 再進行二進制逆轉 */
} else { /* 如果當前正在進行漸進式 rehash */
t0 = &d->ht[0]; /* 將 d->ht[0] 地址 t0 變量 */
t1 = &d->ht[1]; /* 將 d->ht[1] 地址 t1 變量 */
/* Make sure t0 is the smaller and t1 is the bigger table */
if (t0->size > t1->size) { /* t0 爲小的哈希表,t1 爲大的哈希表 */
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask; /* m0 爲小的哈希表的掩碼 */
m1 = t1->sizemask; /* m1 爲大的哈希表的掩碼 */
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);/* 此處參照上面的 if 裏的邏輯 */
de = t0->table[v & m0];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
/* Iterate over indices in larger table that are the expansion
* of the index pointed to by the cursor in the smaller table */
do { /* 循環處理完小的哈希表,再循環大的哈希表,下面代碼還是和 if 裏的一樣,其實這裏有三處一樣的代碼,可以抽出來封裝成一個函數優化的 */
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
de = t1->table[v & m1];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
/* Increment the reverse cursor not covered by the smaller mask.*/
v |= ~m1;
v = rev(v);
v++;
v = rev(v);
/* Continue while bits covered by mask difference is non-zero */
} while (v & (m0 ^ m1));
}
return v; /* 返回新的遊標,相對上一個遊標加1,這樣就能遍歷完此次的批量送代了 */
}
先來說下四個參數:d 爲當前正在迭代的字典;v 爲開始的遊標,dictScan 就是依靠處理遊標來實現批量迭代的,具體算法見下文;fn 是函數指針,每遍歷一個哈希節點就調用此函數;bucketfn 函數是整理碎片時使用,看了下調用鏈發現這個參數是可選的,不處理時可傳 NULL;privdata 爲 fn 函數的參數, void *privdata 前面的 void 表明傳什麼類型的參數都行,但前提必須是指針型的。
使用過 scan 命令後會發現,遊標傳值是從 0 開始,下一次遍歷是依據服務端返回的遊標爲起始遊標,一旦服務端返回 0 遊標,則標識着遍歷結束。
127.0.0.1:6379> scan 0
1) "0"
2) 1) "name"
2) "age"
3) "sex"
那 dictScan 是如何做到從 0 到 m0 實現字典的完整遍歷呢,同時結合此函數會發現迭代會遇到以下三種情況
- 迭代期間字典沒有擴容或縮容,代碼參照 if (!dictIsRehashing(d))
- 兩次迭代的間隙字典完成了擴容或縮容,代碼參照 de = t0->table[v & m0] 裏的 v & m0,這是爲了防止縮容後 v 值大於哈希表的長度而導致數組溢出
- 迭代過程中出現擴容或縮容
/* Set unmasked bits so incrementing the reversed cursor
* operates on the masked bits */
v |= ~m0; /* 掩碼按位取反,遊標再和其進行或運算 */
/* Increment the reverse cursor */
v = rev(v); /* 二進制逆轉 */
v++; /* 加 1 */
v = rev(v); /* 再進行二進制逆轉 */
答案正是這四行核心代碼,讓無限的可能圈定在既定的規則內,生生不息。正如一週有七天,讓無限的時間週而復始的落在此規則內徐徐運轉。下面來詳細介紹下此算法,讓看客知其然並致其所以然。
#include <stdio.h>
#include <string.h>
#include <assert.h>
static unsigned long rev(unsigned long v)
{
unsigned long s = 8 * sizeof(v); // bit size; must be power of 2
unsigned long mask = ~0;
while ((s >>= 1) > 0) {
mask ^= (mask << s);
v = ((v >> s) & mask) | ((v << s) & ~mask);
}
return v;
}
int main(int argc, char **argv)
{
unsigned long size;
assert(argc > 1);
size = atoi(argv[1]);
unsigned long m0 = size - 1;
unsigned long v = 0;
unsigned long i = 0;
for (; i<size; ++i) {
v |= ~m0;
v = rev(v);
v++;
v = rev(v);
printf("%d\r\n", (v));
}
return 0;
}
這裏把核心算法摘取出來並測試下 Redis 的遊標是如何迭代的。
[root@fjr-ofckv-73-94 html]# ./cursor 4
2
1
3
0
看到沒有,在數組長度爲 4 的條件下,一開始的遊標爲 0,迭代過程中游標依次爲 2、1、3、0,最後的結束條件也是 0。
再對照着上面的表格,以二進制的位運算來推導,結果也是 2、1、3、0。也就是在命令行輸入 scan 0,服務端拿到遊標 0 後,推導返回遊標爲 2,然後下一次迭代的遊標爲 2 再推導返回遊標爲 1,如此反覆,直至爲 0,scan 結束。這是迭代的第一種場景,也就是迭代沒有遇到字典擴容或縮容。
接下來看看第二種場景,迭代間隙字典完成了擴容或縮容。
先來說下擴容的情況。
第三次迭代時,數組從 4 擴容到了 8,開始的遊標爲上圖第二次返回的遊標 1。
擴容後,又依次迭代了 1、5、3、7 四次,加上之前的 2 次,共六次。看看第二張圖的遊標,發現 4 和 6 這兩個遊標沒有遍歷到,但再仔細看擴容前的那張圖,已經迭代了 0 和 2 遊標,之後由 4 擴容到了 8,那麼擴容後,原表裏的索引 0 在擴容後就會落到 0 或 4 位置上,2 在擴容後就會落到 2 或 6 位置上,這也是爲什麼擴容後沒有迭代這兩個遊標的原因。
再來看下縮容的情況。
第四次迭代後縮容了,從 8 縮容到了 4。
縮容後,迭代了兩次,但是 0 和 2 遊標沒有迭代,再結合上面擴容講到這兩個遊標會落到 0|4、2|6 上,而看看縮容前迭代的那副圖已經把 0、4、2、6 迭代了,因此縮容後不用再迭代 0 和 2 了,否則數據就重複了。
但是在縮容的情況下是有重複的情況的,比如第三次迭代後縮容了,那麼此時遊標爲 6,但數組只有四個值,因此 t0->table[v & m0] 就真正起作用了,0110 & 0011 = 0010 也就是從 2 開始遍歷,但是縮容前的 2 已經遍歷過,因此出現重複數據,但是不會遺漏數據。
最後來說說迭代過程中遇到擴容或縮容,也就是遇到 rehash 的情況。
前面提到過,rehash 過程中字典的兩張表 ht[0] 和 ht[1] 都會有數據,且趨勢是 ht[0] 到 ht[1],依據迭代過程的不同,兩張表的大小在不同時間段內也不同,也就是 ht[0] 表從大到小,而 ht[1] 從小到大,直至完成 rehash。
t0 = &d->ht[0]; /* 將 d->ht[0] 地址 t0 變量 */
t1 = &d->ht[1]; /* 將 d->ht[1] 地址 t1 變量 */
/* Make sure t0 is the smaller and t1 is the bigger table */
if (t0->size > t1->size) { /* t0 爲小的哈希表,t1 爲大的哈希表 */
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask; /* m0 爲小的哈希表的掩碼 */
m1 = t1->sizemask; /* m1 爲大的哈希表的掩碼 */
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);/* 此處參照上面的 if 裏的邏輯 */
de = t0->table[v & m0];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
/* Iterate over indices in larger table that are the expansion
* of the index pointed to by the cursor in the smaller table */
do { /* 循環處理完小的哈希表,再循環大的哈希表,下面代碼還是和 if 裏的一樣,其實這裏有三處一樣的代碼,可以抽出來封裝成一個函數優化的 */
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
de = t1->table[v & m1];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
/* Increment the reverse cursor not covered by the smaller mask.*/
v |= ~m1;
v = rev(v);
v++;
v = rev(v);
/* Continue while bits covered by mask difference is non-zero */
} while (v & (m0 ^ m1));
再貼下對應的代碼,其邏輯是先遍歷小表,再遍歷大表,這樣就能保證在 rehash 過程中不遺落數據了。這部分代碼也是最難理解的,下面結合圖例來詳細分析下,以達到徹底弄懂。
前兩種情況時提到,數組爲 4 時遊標的迭代依次爲 0、2、1、3,擴容到 8 時遊標的迭代爲 0、4、2、6、1、5、3、7,咱們把其轉換爲二進制再對照表格來看,就一目瞭然了。
上面這張圖分三部分來講:先來看看左右兩邊的擴容前後的遊標,發現,0 和 4、2 和 6、1 和 5、3 和 7 分別對應了擴容前的 0、2、1、3,這也印證了第二種情況擴容兩迭代了 0、2 遊標,第三次迭代間隙完成了擴容,再迭代時分別爲 1、5、3、7;再來看看二進制中標紅的低進制位,都是一樣的,換算的話就是擴容前的 0、2、1、3;最後看看標藍的高進制位,換算後發現就是 0+4 = 4、2+4=6、1+4=5、3+4=7 。
綜合上述三點,發現先遍歷小表,比如從 0 開始迭代,先遍歷小表,然後進入 do while 裏遍歷大表,然後重新計算遊標得出 4,再判斷 v & (m0 ^ m1) 是否爲 0,m0 和 m1 分別爲小表的掩碼 3 和大表的掩碼 7,兩者二進制位都是 1,做異或運算後把相同的地位置爲 0,留下高位的 1,也就是 0100,再與 v 做與運算,也就是 0100 & 0100 不爲 0,說明還有高位沒有迭代,那麼再進入 do 語句塊中遍歷大表,計算新的遊標爲 2,再到 while 裏判斷, 0100 & 0010 結果爲 0,結束遍歷,返回遊標 2。這樣就能在 Redis 進行漸進 rehash 時也能把對應的哈希節點數據做到遍歷而且不遺漏。
【注】 此博文中的 Redis 版本爲 5.0。
參考書籍 :
【1】Redis 5設計與源碼分析