Redis6系列2-底层数据结构(SDS、整数集合、字典)

389 阅读18分钟

欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

Redis之所以性能这么快,一部分得益于优秀的底层数据结构,本篇文章将重点讲解Redis6的底层数据结构。

1. Redis源码目录

从redis官网下载源码(中文网英文网),我下载的redis-6.2.6版本的源码,源码目录/src,核心分类如下:

  1. Redis基本的数据结构(骨架)
    • 简单动态字符串sds.c
    • 整数集合intset.c
    • 压缩列表ziplist.c
    • 快速链表quicklist.c
    • 字典dict.c
    • Stream的底层实现结构listpack.c 和rax.c (一般不用)
  2. Redis数据类型的底层实现
    • redis对象object.c
    • 字符串t_string.c
    • 列表t_list.c
    • 字典t_hash.c
    • 集合和有序集合t_set.c和t_zset.c
    • 数据流t_stream.c
  3. Redis的数据库实现
    • 数据库的底层实现db.c
    • 持久化rdb.c和aof.c
  4. Redis服务端和客户端实现
    • 事件驱动ae.c和ae_epoll.c
    • 网络连接anet.c和networking.c
    • 服务端程序server.c
    • 客户端程序redis-cli.c
  5. 其他
    • 主从复制replication.c
    • 哨兵sentinel.c
    • 集群cluster.c
    • 其他数据结构,如hypelong.c、geo.c
    • 其他功能,如pub/sub、Lua脚本

2. 简单动态字符串(SDS)

2.1 数据结构

sds.h 文件:

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;  //当前字符数组的长度,已用的字节长度 
    uint8_t alloc; // 当前字符数组总共分配的内存大小,字符串最大字节长度
    unsigned char flags; // 表示sds的类型,是sdshdr8,还是sdshdr16。
    char buf[]; // 字符串真正的值,长度由alloc控制
};

解释说明:

  1. Redis中字符串的实现,SDS有多种结构(sds.h):
    • sdshdr5 (2^5=32byte) 
    • sdshdr8 (2 ^ 8=256byte) 
    • sdshdr16 (2 ^ 16=65536byte=64KB) 
    • sdshdr32 (2 ^ 32byte=4GB) 
    • sdshdr64,2的64次方byte=17179869184G用于存储不同的长度的字符串。 
  2. len 表示 SDS 的长度,使我们在获取字符串长度的时候可以在 O(1)情况下拿到,而不是像 C 那样需要遍历一遍字符串。 
  3. alloc 可以用来计算 free 就是字符串已经分配的未使用的空间,有了这个值就可以引入预分配空间的算法了,而不用去考虑内存分配的问题。 
  4. buf 表示字符串数组,真存数据的。
  5. SDS遵循C字符串以\0结尾的惯例,存储在buf中(不同于nginx的底层实现,nginx实现时不保存最后一个\0),但是不计算最后一个字符的长度到len中
  6. 保留c风格buf的好处是可以重用一部分c函数库的函数 注:Redis 规定了字符串的长度不得超过 512 MB

示例图如下:

2.2 SDS创建

以下为 SDS 的创建函数,主要实现是 sdsnewlen 函数:

/* Create an empty (zero length) sds string. Even in this case the string
 * always has an implicit null term. */
sds sdsempty(void) {
    return sdsnewlen("",0);
}

/* Create a new sds string starting from a null terminated C string. */
sds sdsnew(const char *init) {
    size_t initlen = (init == NULL) ? 0 : strlen(init);
    return sdsnewlen(init, initlen);
}

/* Duplicate an sds string. */
sds sdsdup(const sds s) {
    return sdsnewlen(s, sdslen(s));
}

sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;
    sds s;
    char type = sdsReqType(initlen);    //根据字符串长度获取SDS结构的类型
    /* Empty strings are usually created in order to append. Use type 8
     * since type 5 is not good at this. */
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);      //获取SDS结构体的长度
    unsigned char *fp; /* flags pointer. */

    sh = s_malloc(hdrlen+initlen+1);    //申请一个完整SDS的内存空间
    if (sh == NULL) return NULL;
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
        memset(sh, 0, hdrlen+initlen+1);
    s = (char*)sh+hdrlen;           //s指向SDS结构的buf
    fp = ((unsigned char*)s)-1;     //地址后退1位,则fp指向flags
    switch(type) {
        case SDS_TYPE_5: {
            *fp = type | (initlen << SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);       //将sh这个指针转化为 struct sdshdr8 指针
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
    }
    if (initlen && init)
        memcpy(s, init, initlen);   //将字符串拷贝到buf
    s[initlen] = '\0';              //结束符
    return s;   //注意这里返回的是s而不是sh
}

2.3 SDS扩容与缩容

SDS 既然被称为动态字符串,那么就有扩容和缩容的功能,见以下源码:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);     //获取剩余可用空间:字段alloc减去字段len所得的值
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK; // s[-1]就是获取flag字段
    int hdrlen;

    /* Return ASAP if there is enough space left. */
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);  //指针回退到SDS结构的起始位置
    newlen = (len+addlen);
    if (newlen < SDS_MAX_PREALLOC)  //SDS_MAX_PREALLOC的值是 1MB,扩容后字符串串长度如
        newlen *= 2;                //果小于 1MB则长度翻倍,否则每次增加 1M
    else
        newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen);

    /* Don't use type 5: the user is appending to the string and type 5 is
     * not able to remember empty space, so sdsMakeRoomFor() must be called
     * at every appending operation. */
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);
    if (oldtype==type) {    //类型不变,直接往后增加内存空间
        newsh = s_realloc(sh, hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {                //否则重新创建字符串,析构旧字符串
        /* Since the header size changes, need to move the string forward,
         * and can't use realloc */
        newsh = s_malloc(hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        sdssetlen(s, len);  //设置字符串已用长度(给len字段赋值)
    }
    sdssetalloc(s, newlen); //设置字符串总长度(给alloc字段赋值)
    return s;
}

/* Reallocate the sds string so that it has no free space at the end. The
 * contained string remains not altered, but next concatenation operations
 * will require a reallocation.
 *
 * After the call, the passed sds string is no longer valid and all the
 * references must be substituted with the new pointer returned by the call. */
sds sdsRemoveFreeSpace(sds s) {
    void *sh, *newsh;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen, oldhdrlen = sdsHdrSize(oldtype);
    size_t len = sdslen(s);
    size_t avail = sdsavail(s);
    sh = (char*)s-oldhdrlen;

    /* Return ASAP if there is no space left. */
    if (avail == 0) return s;   //没有多余空间,直接返回原字符串

    /* Check what would be the minimum SDS header that is just good enough to
     * fit this string. */
    type = sdsReqType(len);     //通过实际使用的长度计算出SDS结构类型
    hdrlen = sdsHdrSize(type);  //通过SDS结构类型,得到该结构的sizeof

    /* If the type is the same, or at least a large enough type is still
     * required, we just realloc(), letting the allocator to do the copy
     * only if really needed. Otherwise if the change is huge, we manually
     * reallocate the string to use the different header type. */
    if (oldtype==type || type > SDS_TYPE_8) {   //类型没有改变,修改分配的内存大小
        newsh = s_realloc(sh, oldhdrlen+len+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+oldhdrlen;
    } else {    //否则重新创建新的SDS,释放旧SDS
        newsh = s_malloc(hdrlen+len+1);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        sdssetlen(s, len);
    }
    sdssetalloc(s, len);
    return s;
}

2.4 SDS与字符串区别

C语言没有Java里面的String类型 ,只能是靠自己的char[]来实现,字符串在 C 语言中的存储方式,想要获取 「Redis」的长度,需要从头开始遍历,直到遇到 '\0' 为止。所以,Redis 没有直接使用 C 语言传统的字符串标识,而是自己构建了一种名为简单动态字符串 SDS(simple dynamic string)的抽象类型,并将 SDS 作为 Redis 的默认字符串。

C语言String字符串SDS
获取字符串长度需要从头开始遍历,直到遇到 '\0' 为止,时间复杂度O(N)记录当前字符串的长度,直接读取即可,时间复杂度 O(1)
内存重新分配分配内存空间超过后,会导致数组下标越级或者内存分配溢出空间预分配:SDS 修改后,len 长度小于 1M,那么将会额外分配与 len 相同长度的未使用空间。如果修改后长度大于 1M,那么将分配1M的使用空间。惰性空间释放 :有空间分配对应的就有空间释放。SDS 缩短时并不会回收多余的内存空间,而是使用 free 字段将多出来的空间记录下来。如果后续有变更操作,直接使用 free 中记录的空间,减少了内存的分配。
二进制安全二进制数据并不是规则的字符串格式,可能会包含一些特殊的字符,比如 '\0' 等。前面提到过,C中字符串遇到 '\0' 会结束,那 '\0' 之后的数据就读取不上了根据 len 长度来判断字符串结束的,二进制安全的问题就解决了
缓冲区问题API是不安全的,字符串拼接时可能会造成缓冲区溢出API是安全的,拼接时会先检查给定的SDS空间是否足够,不会造成缓冲区溢出
数据类型只能保存文本数据可以保存文本和二进制数据
库函数可以使用所有<string.h>库中的函数可以使用一部分<string.h>库中的函数

SDS相比较于字符串的优势如下:

  • 获取字符串的长度时间复杂度由O(N)降到O(1)
  • 避免缓冲区溢出
  • 减少修改字符串时带来的内存重分配次数。内存分配会涉及复杂算法,且可能需要系统调用,非常耗时。
  • 二进制安全:c语言的结束符限制了它只能保存文本数据,不能保存图片,音频等二进制数据

3. 整数集合(intset)

3.1 数据结构

当一个集合只包含整数值元素,且数量不多时,会使用整数集合作为底层实现;数据结构如下:

typedef struct intset {
   uint32_t encoding; // 编码方式,INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64等
   uint32_t length; // 长度
   int8_t contents[];  // 内容,数组内容类型取决于encoding属性,并不是int8_t。按照大小排序,没有重复
} intset;
  • encoding 字段用于保存元素使用的类型
  • length 字段表示元素的个数,也即是 contents 数组的长度。
  • contents 字段是实际保存元素的地方,数组中的元素有以下两个特性:
    • 元素不重复
    • 元素在数组中由小到大排列


如下:

3.2 升级和降级

当我们要将一个新元素添加到整数集合里,并且新元素的类型比整数集合现有所有的元素类型都要长时,集合要先进行升级才能添加新数据,升级步骤如下:

  • 根据类型,扩展大小,分配空间
  • 将底层数组数据都转换成新的类型,并放置到正确位置
  • 新元素添加到底层数组里面 Redis 中整数集合的升级是在函数 intsetUpgradeAndAdd 中完成的,我们对照着源码看看具体是如何实现的::
/* Return the required encoding for the provided value. */
// 判断新元素什么类型的编码
static uint8_t _intsetValueEncoding(int64_t v) {
    if (v < INT32_MIN || v > INT32_MAX)
        return INTSET_ENC_INT64;
    else if (v < INT16_MIN || v > INT16_MAX)
        return INTSET_ENC_INT32;
    else
        return INTSET_ENC_INT16;
}

static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
    //获取整数集合当前的编码类型,存到变量 curenc
    uint8_t curenc = intrev32ifbe(is->encoding);
    //获取新增元素的编码类型,存到变量 newenc
    uint8_t newenc = _intsetValueEncoding(value);       //升级步骤一
    //获取整数集合元素数量
    int length = intrev32ifbe(is->length);
    //如果新增元素小于0,则将新增元素添加在数组前面
    //需要注意的是:当前函数是已经确定整数集合要升级的,那么新增的元素要么最大,要么最小
    int prepend = value < 0 ? 1 : 0;

    /* First set new encoding and resize */
    //整数集合的编码方式设置为新的编码方式
    is->encoding = intrev32ifbe(newenc);                //升级步骤二
    //重新分配内存
    is = intsetResize(is,intrev32ifbe(is->length)+1);   //升级步骤二

    /* Upgrade back-to-front so we don't overwrite values.
     * Note that the "prepend" variable is used to make sure we have an empty
     * space at either the beginning or the end of the intset. */
    //_intsetGetEncoded 函数是获取旧编码方式时第 length 个元素的值
    //如果新增元素插在数组开头,则prepend的值为1,即原 第length个元素现在插入在length+1的位置
    //_intsetSet函数是以整数集合记录的编码方式在contents数组的某个位置保存值
    while(length--)                                     //升级步骤三
        _intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));

    /* Set the value at the beginning or the end. */
    if (prepend)
        //将新增元素存放在数组开头
        _intsetSet(is,0,value); 
    else
        //将新增元素存放在数组结尾
        _intsetSet(is,intrev32ifbe(is->length),value);
    //整数集合元素加1
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
    return is;
}

注意

  1. 从较短整数到较长整数的转换,并不会更改元素的值;
  2. 集合编码元素的方式,由数组中长度最大的那个元素来决定。
  3. 添加元素可能导致升级,所以添加新元素的时间复杂度为O(N)
  4. 不支持降级,升级后将一直保持新的数据类型

升级的好处:

  • 提高灵活性:整数集合可以通过自动升级底层数组来适应新的元素,我们可以将int16,int32,int64类型的整数添加到集合中,不必担心类型错误
  • 节约内存,可以同时保持int16,int32,int64类型的数值,又可以确保升级操作只会在需要的时候进行,尽量节省内存空间

3.3 总结

  • 整数集合是集合键的底层实现之一
  • 整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,该表这个数组的类型
  • 升级操作作为整数集合带来的灵活性,并且尽可能节约了内存
  • 整数集合只支持升级操作,不支持降级操作

4. 字典(dict)

Redis 的字典使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

4.1 数据结构

在 Redis 的 src/dict.h 文件中,可看到哈希表的定义:

typedef struct dictht {
   dictEntry **table;          //哈希表数组
   unsigned long size;         //哈希表大小,即table数组的大小
   unsigned long sizemask;     //哈希表大小掩码,用于计算索引值,总是等于 size-1
   unsigned long used;         //该哈希表已有节点的数量
} dictht;

哈希表节点使用 dictEntry 结构表示,其定义也在 src/dict.h 文件中,定义如下:

typedef struct dictEntry {
    void *key;      //键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;            //值
    struct dictEntry *next; //指向下一个节点
} dictEntry;

以上定义可看出哈希表节点保存着一个键值对,其中用一个联合体定义,可以是一个指针,或者一个有/无符号的64位整形,或者是一个双精度浮点数。next 字段是指向另一个哈希表节点的指针,这个指针可以将斗个哈希值相同的键值对连在一起,作用是为了解决键冲突的问题。
接下来,我们看看字典 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 dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;
  • type 和 privdata 字段是针对不同类型的键值对,为创建多态字典而设计的。
  • type 字段是一个指向 dictType 结构的指针,每个 dictType 结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数。
  • privdata 字段则保存了需要传给那些类型特定函数的可选参数。
  • ht 字段是一个包含两个项的数组,数组中的每个项都是一个 dictht 哈希表,一般情况下字典只使用 ht[0] 这个哈希表,ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
  • rehashidx 字段也是和 rehash 有关,用于记录目前 rehash 的进度,如果没有 rehash,值就是 -1。
  • iterators 字段记录了当前字典正在进行中的迭代器数量。 结构图如下所示:

字典的创建源码如下:

static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

/* Create a new hash table */
dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));

    _dictInit(d,type,privDataPtr);
    return d;
}

/* Initialize the hash table */
int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);
    d->type = type;
    d->privdata = privDataPtr;
    d->rehashidx = -1;
    d->iterators = 0;
    return DICT_OK;
}

4.2 哈希算法

  • redis使用MurmurHash2算法计算键的hash值,MurmurHash2算法优点为即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且计算速度也是很快;

  • 哈希值与sizemask取与,得到哈希索引

  • 哈希冲突(两个或以上数量键被分配到哈希表数组同一个索引上):链地址法解决冲突,因为dictEntry节点组成的链表没有指向链表表尾的考虑,为了速度考虑,总是将新的节点添加到链表的表头位置(复杂度O(1)) Redis 计算哈希值和索引值的方法如下:

//使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);

//使用哈希表的 sizemask 属性和哈希值,计算出索引值
//根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;

Redis 目前使用了两种不同的哈希算法:

  1. MurmurHash2 算法,这种算法最初是由 Austin Appleby 于 2008 年发明,优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。MurmurHash2 算法具体可参考其主页: code.google.com/p/smhasher/
  2. 基于 djb 算法实现的一个大小写无关的散列算法:具体信息可参考 www.cse.yorku.ca/~oz/hash.ht… 。

使用哪种算法取决于具体应用所处理的数据:

  • 命令表及 Lua 脚本缓存都用到了算法 2。
  • 算法 1 的应用则更加广泛:数据库、集群、哈希键、阻塞操作等功能都用到了这个算法。

4.3 rehash

对哈希表进行扩展或收缩,以使哈希表的负载因子维持在一个合理范围之内,负载因子 = 保存的节点数(used)/ 哈希表大小(size)。

扩展和收缩哈希表的工作可以通过执行 rehash(重新散列) 操作来完成,Redis 对字典的哈希表执行 rehash 的步骤如下:

  1. 为字典的 ht[1] 哈希表分配空间,这个空间大小取决于要执行的操作 :
  2. 如果执行的是扩展操作,则 ht[1] 的大小为第一个大于等于 ht[0].used*2的 [公式] ;
  3. 如果执行的收缩操作,则 ht[1] 的大小为第一个大于等于 ht[0].used 的 [公式] ;
  4. 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1] 的指定位置上。
  5. 当 ht[0] 包含的所有键值对都迁移到 ht[1] 之后,释放 ht[0] ,将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空白哈希表,为下一次 rehash 做准备。 哈希表的负载因子计算公式为:
// 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

哈希表扩展的触发条件:

  1. 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1
  2. 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5

对应的源码如下:

static unsigned int dict_force_resize_ratio = 5;

/* 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;
}

哈希表收缩的触发条件:哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作。 对应的源码如下:

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));
}

/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL
 * we resize the hash table to save memory */
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);
}

/* 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)
{
    unsigned long 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);
}

​ 注:哈希表不管是扩展还是收缩,其最终大小都是 2 的 n 次幂,其计算源码如下:

/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
    //当扩展时,size 参数的值为 ht[0].used * 2
    //当收缩时,size 参数的值为 max(ht[0].used, 4)
    unsigned long i = DICT_HT_INITIAL_SIZE;

    if (size >= LONG_MAX) return LONG_MAX + 1LU;
    while(1) {
        if (i >= size)
            return i;
        i *= 2;
    }
}

4.4 渐进式rehash

扩展和收缩哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面,但是,rehash 动作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。原因是当哈希表里存放的键值对数量太多时,一次性将这些键值对全部 rehash 到 ht[1] 的话,庞大的计算量可能会导致服务器在一段时间内停止服务。

渐进式 rehash 的步骤如下:

  1. 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
  2. 在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为 0,表示 rehash 工作正式开始。
  3. 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当 rehash 工作完成之后,程序将 rehashidx 属性的值增一。
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehash 至 ht[1],这是程序将 rehashidx 属性的值设为 -1,表示 rehash 操作已经完成。 对应的源码如下:
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;
}

/* 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;
}

/* 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 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 而带来的庞大计算量。

参考文档:
Redis 设计与实现(第一版)
Redis3.20阅读-SDS实现
Redis(二):基础之五种常见数据结构与使用方法
redis 系列,要懂redis,首先得看懂sds(全网最细节的sds讲解)
# redis-6.06 底层数据结构系列