【Redis源码系列】Redis6.0 DB结构以及渐进式rehash超详细源码解读

955 阅读6分钟

前言

上篇文章我们研究了Redis6的多线程机制, Redis通过多线程处理数据包的读写逻辑, 主线程处理命令的数据库执行过程的形态来提供服务。那么命令的执行流程具体是什么样的呢?了解命令的执行流程之前首先要了解Redis server的整体结构, 本期我们将围绕以下问题进行源码探究:

  • redis源码中key数据结构如何存储?
  • redis如何处理hash冲突?
  • 渐进式Rehash流程是怎么运行的?

Redis多进程IO回顾: Redis6.0 超详细多线程IO源码分析

Redis数据库核心数据结构关系总览

image.png

redis源码中key数据结构如何存储?

首先在Redis server对象中有redisDb *db字段, 默认为16个db长度, 可以根据需要配置。redisDb 类型中有一个dict *dict 字段, dict是redis中非常重要的一个结构体, 绝大部分的key空间都基于结构来构建, 源结构如下:

image.png

dictht ht[2]是一个长度为2的数组, 正常运行过程中所有的key操作在bucket中进行, rehash期间会同时操作两个bucket, 数组中每一个元素都是 dictEntry 类型的结构, dictEntry结构如下:

image.png

其中key字段可以是一个sds结构体或者redisObject 结构体, 联合体中的*val字段是一个redisObject结构, redisObject 存储标识val真实的数据类型, 以及指向数据存储的地址, 其结构如下:

image.png

redis中不同的key对象不会使用固定一种编码, 在某种特定的情况下, value 会进行编码的转化, 其中不同的数据类型可以使用的编码结构如下表所示:

image.png

redis如何处理hash冲突?

目前业界比较成熟的的处理hash冲突的理论和方法有以下几种:

开放定址法

这种方法也称再散列法,其基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。这种方法有一个通用的再散列函数形式:

Hi=(H(key)+di)% m i=12,…,n

其中H(key)为哈希函数,m 为表长,di称为增量序列。增量序列的取值方式不同,相应的再散列方式也不同。主要有以下三种:

  • 线性探测再散列

    dii=1,2,3,…,m-1。这种方法的特点是:冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。

  • 二次探测再散列

    di=12,-12,22,-22,…,k2,-k2 ( k<=m/2 )。这种方法的特点是:冲突发生时,在表的左右进行跳跃式探测,比较灵活。

  • 伪随机探测再散列

    di=伪随机数序列。具体实现时,应建立一个伪随机数发生器,(如i=(i+p) % m),并给定一个随机数做起点。

链接法

即当发生hash冲突时, 以当前hash bucket元素地址为起始点, 将冲突元素加入到当前元素之前或者之后, 当查找时出现冲突在遍历链表即可。

再次hash法

即当发生hash冲突时, 再次使用不同的hash函数对元素进行进hash值计算, 知道计算值可以找到一个可用地址为之

公共溢出区

将hash表分为两部分, 正常区域和溢出区域, 发生hash冲突的元素置入溢出区域。

以上方法各有优缺点, 链接法在各大语言语言中使用比较多, 如java, php处理冲突均是使用的链表进行处理, redis同样也是用链表处理hash冲突, 通过 dictEntry 结构的 next 字段构建冲突链表, 添加元素源码:

图示 1-1: 增加hash操作
图示 1-1: 增加hash操作image.png

添加元素的时候并没有直接检测冲突, 而是直接将当前entry.next置为当前table列表的index, 当没有冲突时, entry.next为Null, 有冲突时则是发生冲突的最新一个元素, 依次构成链表。删除entry时会显示检查冲突和key比计较, 源码如下:

图示 1-2: 删除hash操作
image.png

redis渐进式Rehash

hashtable初始化

通过以上源码探究我们得知redis某一个db中的所有数据存放在redisDb.dict.dictht[0]字段中, 在 server.c中通过如下调用链进行初始化:

  1. server.c:2919:
    server.db[j].dict = dictCreate(&dbDictType,NULL);
    
  2. dict.c:111:
    dict *d = zmalloc(sizeof(*d));
    _dictInit(d,type,privDataPtr);
    
  3. dict.c:121:
    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. dict.c:102:
    static void _dictReset(dictht *ht)
    {
        ht->table = NULL;
        ht->size = 0;
        ht->sizemask = 0;
        ht->used = 0;
    }
    

hashtable扩容策略

可以看到初始化时并没有给table字段分配内存空间, 随着我们存储key容量的增加, 势必要对table字段进行扩容, 一般hash表的扩容都会有自己设定的负载因子, 如java hashmap默认的loadfactor0.75, 当我们需要扩容或者缩容时, 根据相应的策略可以分配出当前需要的内存, 同理, redis也有自己的负载因子和分配策略, 具体源码如下:

  • 负载因子定义为5, dict.c:63

    static unsigned int dict_force_resize_ratio = 5;
    
  • 判断是否需要扩容, dict.c:953

    static int _dictExpandIfNeeded(dict *d)
    {
        // 以上省略...
        // 1. size已被使用完 or
        // 2. 字典可以扩容 or
        // 3. 已用/size > 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))
        {
            return dictExpand(d, d->ht[0].used*2);
        }
        return DICT_OK;
    }
    
  • 计算扩容所需size, dict.c:975

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

    从源码可以看出下一次所需的size值为当前 size * 2

rehash过程

通过扩容策略计算出所需要的size之后, 便是给size分配内存空间, 源码如下:

image.png

可以看出redis并没有ht[0]字段中的所有值赋值到新的变量n中, 并且替换ht[0],类似于这样的操作(伪代码):

image.png

原因是redis作为一个高性能的内存服务器, 当前一个字段上保存的值可能会超过上百万key,数十GB内存, 如果进行重新计算hash并且再次赋值, 在数据量比较大的情况下势必会造成大量的计算工作导致服务延时, 同时产生过多的内存碎片, 而各种语言中可以基于此来实现扩容是因为满足的是变量层面的需求, 当然不合理的使用导致变量存储过多元素同样会产生类似问题, 所以redis使用渐进式rehash解决此类问题, 具体做法是:

  1. 分配ht[1]内存, 此时数据表有ht[0]ht[1]两个table列表;
  2. 设置dict.rehashidx = 0, 标识开始rehash操作, 默认值为-1;
  3. rehash期间所有的增删改查操作都会检查是处于rehash状态, 除了完成对应操作之外, 还会将ht[0]上的元素迁移至ht[1], 然后dict.rehashidx自增1, 直到某一个时间点ht[0]上的数据都被迁移至ht[1], 然后执行替换操作, 并且将rehashidx置为-1:
    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;
    }
    
  4. 在rehash期间, 元素的添加操作直接保存到ht[1]上, 见 图1-1, delete, find, update操作通过for循环在两张表中查找,见图1-2

渐进式的rehash的过程具体如图所示(注: 图示非原创,引自: redis渐进式rehash):

1. 开始准备rehash
image.png
2. rehash索引0上的键值对
image.png
3. rehash索引1上的键值对
image.png
4. rehash索引2上的键值对
image.png
5. rehash索引3上的键值对
image.png
6. rehash索引4上的键值对,rehash完毕
image.png

至此redis数据存储的整体结构, 流程, 以及渐进式rehash分析总结完毕, 看完的小伙伴别忘了三连点赞, 给予我们彼此一起学习和前进的动力, 下篇计划分析redis中我感兴趣的数据结构和命令, 欢迎各位看客评论互动 :)