Redis 之字典

在前面 淺談 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 實現字典的完整遍歷呢,同時結合此函數會發現迭代會遇到以下三種情況

  1. 迭代期間字典沒有擴容或縮容,代碼參照 if (!dictIsRehashing(d))
  2. 兩次迭代的間隙字典完成了擴容或縮容,代碼參照 de = t0->table[v & m0] 裏的 v & m0,這是爲了防止縮容後 v 值大於哈希表的長度而導致數組溢出
  3. 迭代過程中出現擴容或縮容
/* 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設計與源碼分析

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章