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

太长不看版

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

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

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

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使用哈希表实现。众所周知,影响哈希表查找效率有以下三个因素:

  • 散列函数是否均匀;
  • 处理冲突的方法;
  • 散列表的载荷因子。

于是就引出了三个问题:

  • redis的哈希表是如何进行散列?
  • redis的哈希表如何解决冲突?
  • 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;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值