redis源码解析-基础数据-dict

太长不看版java

  • redis字典底层使用哈希表实现
  • 使用除留余数法进行散列,用到了SipHash算法
  • 使用单独链表法解决冲突
  • 经过扩张(长度变动为首个>= 2 * used的2^n)与收缩(长度变动为首个 >= used的2^n)哈希表维持载荷因子大小合理。(used为目前已有键值对个数)
  • 有持久化子进程时因子>=5 扩张,不能收缩。无持久化进程时,因子 >= 1扩张, < 0.1收缩。
  • rehash操做是渐进处理的,分散在触发后对当前字典的每一个增删改查操做中。

本篇解析基于redis 5.0.0版本,本篇涉及源码文件为dict.c, dict.h, siphash.c。python

dict全称dictionary,使用键-值(key-value)存储,具备极快的查找速度。常见的高级语言中都有对应的内置数据类型,python中为dict,java/c++中为map。c++

没接触太高级语言?不要紧,往下看,看完本身写一个!git

dict相关结构定义

// 字典定义
typedef struct dict {
    // 类型信息 是一个针对某类型的字典操做函数的集合
    dictType *type;
    // 保存须要传给那些类型特定函数的可选参数,例如复制键/复制值等操做函数
    void *privdata;
    // 一个长度为2的dict_hast_table数组
    dictht ht[2];
    // rehash标记
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    // 键值对个数
    unsigned long iterators; /* number of iterators currently running */
} dict;

// 字典类型数据定义
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;

// 哈希表定义
typedef struct dictht {
    // dictEntry* 类型数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 始终等于size - 1, 进行散列时有用到
    // 为何单独一个字段存储: 只在增删的时候修改,频繁操做下减小计算(读多写少)
    unsigned long sizemask;
    // 目前已有键值对数量
    unsigned long used;
} dictht;

// 哈希节点定义
typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    // 用来解决hash冲突
    struct dictEntry *next;
} dictEntry;
复制代码

从上述定义能够看出,redis实现的dict使用哈希表实现。众所周知,影响哈希表查找效率有如下三个因素:github

1.散列函数是否均匀;redis

2.处理冲突的方法;算法

3.散列表的载荷因子(英语:load factor)。编程

不周知的同窗戳维基百科周知一下数组

因而就引出了三个问题:ruby

1.redis的哈希表是如何进行散列?

2.redis的哈希表如何解决冲突?

3.redis是如何保证哈希表的载荷因子处于合理区间?

redis的哈希表是如何进行散列

/* Returns the index of a free slot that can be populated with * an hash entry for the given 'key'. * If the key already exists, -1 is returned. */
static int _dictKeyIndex(dict *ht, const void *key) {
    unsigned int h;
    dictEntry *he;

    /* Expand the hashtable if needed */
    if (_dictExpandIfNeeded(ht) == DICT_ERR)
        return -1;
    /* Compute the key hash value */
    // 计算hash值后与sizemask取余得到散列地址
    h = dictHashKey(ht, key) & ht->sizemask;
    /* Search if this slot does not already contain the given key */
    he = ht->table[h];
    while(he) {
        if (dictCompareHashKeys(ht, key, he->key))
            return -1;
        he = he->next;
    }
    return h;
}
复制代码

散列函数通常有6种方法: 直接定址法、数字分析法、平方取中法、折叠法、随机数法、除留余数法。redis内部实现采用了除留余数法。

除留余数法

取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。

除留余数法中的p, redis使用SipHash算法来进行计算,从而减小哈希冲突。值得一提的是python、perl、ruby等编程语言也使用SipHash做为哈希算法。

/* The default hashing function uses SipHash implementation * in siphash.c. */

uint64_t siphash(const uint8_t *in, const size_t inlen, const uint8_t *k);
uint64_t siphash_nocase(const uint8_t *in, const size_t inlen, const uint8_t *k);

uint64_t dictGenHashFunction(const void *key, int len) {
    return siphash(key,len,dict_hash_function_seed);
}

uint64_t dictGenCaseHashFunction(const unsigned char *buf, int len) {
    return siphash_nocase(buf,len,dict_hash_function_seed);
}
复制代码

redis的哈希表如何解决冲突

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing) {
    long index;
    dictEntry *entry;
    dictht *ht;
    
    // ...
    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;
}
复制代码

处理哈希冲突方法有: 线性探测法、平方探测法、伪随机探测法、单独链表法、双散列法和再散列法。从上述代码中能够看出,redis采用了单独链表法,在出现冲突时,将新加入节点放在链表头节点(由于是单向链表,获取尾部节点须要O(n)复杂度)。

redis是如何保证哈希表的载荷因子处于合理区间

载荷因子 = 填入表中的元素个数 / 哈希表的长度

考虑如下三种状况:

  1. 载荷因子等于表中键值对个数, 即哈希表长度为1,此时哈希表退化为一个单向链表,查找元素的复杂度为O(n)。
  2. 载荷因子为0.1, 即表中键值个数为哈希表长度的1/10,此时查找元素复杂度为O(1)。可是有个问题,内存的利用率过低了。
  3. 载荷因子为1,即元素个数等于哈希表长度,此时是理想状态,能够快速查找,同时100%利用率,很少很多刚恰好。

经过上述分析,咱们能够看到,载荷过高很差,影响效率,过低也很差,内存利用率过低,不划算。最好是始终保持载荷为1,可是显然不现实,因此只能是动态的检测,高了就把哈希表扩张下,低了就把哈希表收缩下,始终将载荷因子维持一个合理的区间。

扩张与收缩策略

// 哈希表扩张函数(包含收缩)
int dictExpand(dict *d, unsigned long size)
{
    // ...
    dictht n; /* the new hash table */
    // 实际扩张或缩小后的大小
    // 2的次方中第一个大于等于size的数
    unsigned long realsize = _dictNextPower(size);
    // ...
}

static unsigned long _dictNextPower(unsigned long size) {
    unsigned long i = DICT_HT_INITIAL_SIZE;

    if (size >= LONG_MAX) return LONG_MAX;
    while(1) {
        if (i >= size)
            return i;
        i *= 2;
    }
}
复制代码

扩张

void updateDictResizePolicy(void) {
    // 若是不存在rdb或aof文件变动子进程,resize标记为1
    if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
        // dict_can_resize = 1;
        dictEnableResize();
    // 不然resize标记为0
    else
        // dict_can_resize = 0;
        dictDisableResize();
}

/* 若是须要进行哈希扩张 */
static int _dictExpandIfNeeded(dict *d)
{
    // ...
    // 若是已存在键值对数量大于哈希表大小(载荷因子大于1) 且resize标记为1可进行扩张
    // static unsigned int dict_force_resize_ratio = 5;
    // 若是 resize标记为0,则载荷因子大于5 可进行扩张
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
    {
        // 哈希表长度扩张为 2的次方中第一个大于等于已有键值对数量两倍
        return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

复制代码

当不存在持久化子进程时,载荷因子>=1时扩张,扩张后长度为2的次方中首个>= used(已有键值个数) * 2的数。例如: 本来哈希表长度是5,有10个键值对。扩张后长度是32。2 4 8 16 32...中第一个大于10 * 2的是32。

而存在持久化子进程时载荷因子>=5才能够扩张,这是为了不子进程写时复制致使的没必要要的内存分配。

收缩

#define HASHTABLE_MIN_FILL 10 /* Minimal hash table fill 10% */

int htNeedsResize(dict *dict) {
    long long size, used;

    size = dictSlots(dict);
    used = dictSize(dict);
    // 负载因子小于 0.1则进行收缩
    return (size > DICT_HT_INITIAL_SIZE &&
            (used*100/size < HASHTABLE_MIN_FILL));
}

/* 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;
    // 只有resize标记为1且当前不处于rehash状态时能够进行resize操做
    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    minimal = d->ht[0].used;
    // #define DICT_HT_INITIAL_SIZE 4
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    // 哈希表长度缩小为 2的次方中第一个大于等于 4与当前已拥有键值对数量中的较小值
    return dictExpand(d, minimal);
}
复制代码

载荷因子< 0.1时收缩,收缩后哈希表长度为 4与used(已拥有键值对个数)中的较小值,这个动做只有不存在持久化子进程且不处于rehash状态时进行。后者好理解,可是有子进程时为啥扩张的时候只是调高了执行条件,收缩的时候直接就不让执行了?

由于写时复制只要是父进程的内存发生变化,子进程就会进行内存分配。而前面说了,须要扩张是由于查询效率过低了,性能的下降对于redis是不能接受的。而须要收缩时,仅仅只是浪费了一点内存没有释放,短期内是能够接受的。

rehash如何执行

分析完了rehash中的收缩和扩张的策略,咱们再来看下rehash具体是怎么执行的。 前边咱们说了dict结构有两个哈希表,多出来的那个哈希表就是用来rehash中临时使用的。 具体步骤以下:

  1. 根据前边所说策略触发哈希表扩张/收缩动做,为备胎d->ht[1]分配调整以后长度的内存。将rehash标记rehashidx置为0表示rehash开始(初始为-1表示当前未进行rehash)。
int dictExpand(dict *d, unsigned long size) {
    // ...
    /* Prepare a second hash table for incremental rehashing */
    d->ht[1] = n;
    d->rehashidx = 0;
    return DICT_OK;
}
复制代码
  1. 当rehashidx不为-1时,该字典每次进行增删改查是都会执行rehash一步,执行完以后对rehashidx加1。
// 执行一步rehash, 迁移d->ht[0]中rehashidx对应索引以后第一个非空元素(多是一个链表)到备胎上
static void _dictRehashStep(dict *d) {
    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);
        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++;
    }
    // ...

    /* More to rehash... */
    return 1;
}
复制代码
  1. 最终在某一时间点d->ht[0]的全部键值对都被迁移到备胎d->ht[1]上,此时会将d->ht[0]内存释放,从备胎手里抢回全部数据,而后卸磨杀驴把备胎打回原形(null指针)。最后把rehashidx置为-1,告诉全部人rehash结束了。
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;

    // ...

    /* 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;
}
复制代码

rehash为何要搞成渐进处理?

当字典数据量小的时候,rehash一次性搞定很快很方便,感受如今的这种处理方法不少余很繁琐,可是若是数据量比较大的时候,几百万甚至几千万条数据时,只是算个hash值就须要庞大的计算量,若是要一次性搞定服务器就没法正常工做了,即使不gg也会对服务性能形成很大的影响。因此redis采用了愚公移山的办法,一点一点的处理。

而在rehash处理过程当中,删改查等操做查找key都是先找d->ht[0],没找到再找备胎d->ht[1]。以查找key为例:

dictEntry *dictFind(dict *d, const void *key) {
    dictEntry *he;
    uint64_t h, idx, table;

    if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
    // 执行了一步rehash
    if (dictIsRehashing(d)) _dictRehashStep(d);
    // 计算hash值
    h = dictHashKey(d, key);
    // 两个哈希表进行遍历
    for (table = 0; table <= 1; table++) {
        // 取余求索引
        idx = h & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key))
                return he;
            he = he->next;
        }
        // 没有进行rehash时,只查询d->ht[0]
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}
复制代码
相关文章
相关标签/搜索