Redis源码之字典

字典


介绍:

  字典,又称为符号表(symbol table )、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。

  在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。

  字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对,等等。

  字典经常作为一种数据结构内置在很多高级编程语言里面,但Redis所使用的C语言并没有内置这种数据结构,因此Redis构建了自己的字典实现。


在Redis中的应用:

  • Redis的数据库就是基于字典作为底层实现的.对数据库的增,删,改,查操作也是构建在对字典的操作至上.
  • 哈希键

字典的实现

  • 哈希表:

        /*
         * 哈希表
         *
         * 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
         */
        typedef struct dictht {
            
            // 哈希表数组
            dictEntry **table;
        
            // 哈希表大小 
            // 大小总是2的n次方
            unsigned long size;
            
            // 哈希表大小掩码,用于计算索引值
            // 总是等于 size - 1
            unsigned long sizemask;
        
            // 该哈希表已有节点的数量
            unsigned long used;
        
        } dictht;
    
    
    • 属性解析:
      • table : table属性是一个数组.数组中的每个元素都是一个指向dict.h/dictEntry结构体的指针,每个dictEntry结构保存着一个键值对.
      • size : 记录了哈希表的大小,也就是table数组中的大小. 这个值一直为2的n次方
      • sizemask : 这个值的大小一直为size-1; 他的作用是与哈希值做"与"运算,从而决定一个键应该被放到table数组中的哪个索引上去.
      • used : 记录了哈希表目前已有键值对的数量,
  • 哈希表节点:

        /*
         * 哈希表节点
         */
        typedef struct dictEntry {
            
            // 键
            void *key;
        
            // 值
            union {
                void *val;
                uint64_t u64;
                int64_t s64;
            } v;
        
            // 指向下个哈希表节点,形成链表
            struct dictEntry *next;
        
        } dictEntry;
    
    

    Redis源码之字典_第1张图片

    • 属性解析:
      • key : 保存着键值对中的键.
      • v : 保存着键值对中的值.其中键值对中的值可以是:
        • 一个指针.(*val).
        • 一个uint64_t整数.
        • int64_t整数
      • next : next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision )的问题。如上图中的键K1和键K0.
  • 字典:

        /*
         * 字典
         */
        typedef struct dict {
        
            // 类型特定函数
            dictType *type;
        
            // 私有数据
            void *privdata;
        
            // 哈希表
            dictht ht[2];
        
            // rehash 索引
            // 当 rehash 不在进行时,值为 -1
            int rehashidx; /* rehashing not in progress if rehashidx == -1 */
        
            // 目前正在运行的安全迭代器的数量
            int iterators; /* number of iterators currently running */
        
        } dict;
    
    • type属性: 是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
    • privdata: 属性则保存了需要传给那些类型特定函数的可选参数.
    • ht属性: 是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表, ht[1]哈希表只会在对ht [0]哈希表进行rehash时使用.
    • rehashidx属性: 也是和字典的rehash有关,它记录了rehash目前的进度,如果当前没有在进行rehash,那么它的值为 -1.
  • 一个普通状态下(没有进行rehash)的字典
    Redis源码之字典_第2张图片

    • 根据上图可以看到:
      • ht[1] 哈希表是没有在使用的,换句话说: ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用.
      • rehashidx属性此刻的值是-1.这个可以作为字典是否在进行rehash的判断指标.
      • size 为4,代表table数组的大小,这个size的值取值为2^n
      • sizemask 的值为3 ,为size-1. 后面会讲到如何使用sizemask.
      • used 的值为2, 代表table中插入的元素有2个.

将新的键值对添加到字典里面.

  • 新键值对添加步骤
    • 将一个新的键值对添加到字典中时,Redis会先根据键值对中的键计算出哈希值,
    • 将哈希值与前面说到的sizemask属性进行 运算 得到的结果作为索引值
    • 根据索引将包含新键值对的哈希表节点(dictEntry)放到哈希表数组(table)的指定索引上.(0-size-1).
    • 新的键值对添加完成.
  • 步骤解析: 现在我们有一个需求,将一个键值对k0,v0添加到字典中
    1. 首先有个空的字典
      Redis源码之字典_第3张图片
    2. 根据k0计算出其哈希值
        // 计算给定键的哈希值
        // Redis 使用 MurmurHash2算法来计算键的哈希值
        // 这种算法的优点在于: 即使输人的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快
         h = dictHashKey(d, key);
        #define dictHashKey(d, key) (d)->type->hashFunction(key)    
    
    1. 假设经过计算得到的hash = 8. 下一步进行将 hash & sizemask
         // 计算索引值 
        idx = h & d->ht[table].sizemask; >> 8 & 3 = 0
    
    1. 计算出得到的索引值为 0,所以该键值对会被插入到哈希表数组(table)中的索引为0的位置上.
      Redis源码之字典_第4张图片
    2. 新键值对添加完成

键冲突

  • 键冲突:
    • 当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision )。
  • Redis 解决键冲突的方法:
    • 链地址法: 即每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。
  • 图例:
    1. 现在有一个包含两个键值对的字典
      Redis源码之字典_第5张图片
    2. 现在有一个键值对k2,v2将要插入到哈希表中.并且经过计算得到的索引值为2,如上图所示,就和键值对k1发生了冲突,所以按照链地址法解决冲突,就是使用next指针将k2和k1所在的节点连接起来
      Redis源码之字典_第6张图片
    3. 因为哈希节点的next链表并没有指向链表表尾的指针,所以为了速度考虑,Redis总是将新节点添加到链表的表头.入上图所示:新增的键值对k2排在已有键值对k1的前面.

重新散列 (rehash)

  • 为何要有rehash操作

    • 随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少
    • 为了让哈希表的负载因子( load factor)维持在一个合理的范围之内
    • 当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩
    • 扩展和收缩哈希表的工作就是rehash.
  • 哈希表执行rehash的步骤:

    1. 为字典的ht[1] 哈希表分配空间,如何分配取决于是执行扩展操作还是收缩操作.
      • 扩展操作: ht[1]的值为第一个大于等于ht[0].used * 2 的2^n (2的n次方)
        • 解释: 假如ht[0].used 的值为5,那么第一个大于等于 5*2 的 2^n 为 (2^4) = 16,所以ht[1]的哈希表大小(size) 会被设置为16.
      • 收缩操作: ht[1]的值为第一个大于等于 ht[0].used的 2^n
        • 解释: 假如ht[0].used 的值为5,那么第一个大于等于5的2^n 为 (2^3) = 8,所以ht[1]的哈希表大小(size) 会被设置为8.
    2. 将保存在ht[0]上面的所有键值对重新散列到ht[1]上面.即重新按照新的rehashidx计算索引值.然后按照索引值将键值对放置到指定的位置上.
    3. 当ht [0]包含的所有键值对都迁移到了ht [1]之后,这时候ht[0]会变为空表,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备.
  • 图例:

    1. 要将下图中的字典进行rehash(扩展操作)
      Redis源码之字典_第7张图片
    2. 当前ht[0].used 的值为4, 第一个大于等于4*2的 2^n为 (2^3) = 8,所以ht[1].size会被设置为8.则新的ht[1]在分配了空间之后为下图所示.
      Redis源码之字典_第8张图片
    3. 渐进的将ht[0]中包含的键值对都rehash到ht[1]上,如下图
      Redis源码之字典_第9张图片
    4. 释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]重新分配一个新的空白哈希表,为下一次的rehash做准备.
      Redis源码之字典_第10张图片
    5. 至此,对哈希表的扩展操作执行完成,程序成功的将哈希表的size从4扩展成了8.
  • 什么时候需要对哈希表进行扩展与收缩.

    • 扩展: 当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作.
      • 服务器目前没有在执行BGSAVE命令或者BGREWRTEAOF命令(下拉至底部查看这两个命令的解释),并且哈希表的负载因子大于等于1.
      • 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5.
    • 收缩:
      • 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
  • 哈希表的负载因子计算公式:

    #负载因子-哈希表已保存节点数量/哈希表大小

    load factor = ht [0].used / ht [0].size


渐进式rehash

  • 介绍

    • 扩展或收缩哈希表需要将ht [0]里面的所有键值对rehash到ht [1]里面,但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。
  • 为何要渐进式的rehash

    • 如果ht[0]里面保存了仅仅有限的几个键值对时,那么服务器可以在瞬间就将这些键值对全部rehash到ht [1];
    • 但是Redis保存的键值对大多数都是成千万,上亿个键值对那么要一次性将这些键值对全部rehash到ht [1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。
    • 为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht [1],而是分多次、渐进式地将ht [0]里面的键值对慢慢地rehash到ht[1].将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量
  • 渐进式rehash的步骤(带图例)

    1. 即将开始rehash扩展,为ht (1]分配空间,让字典同时持有nt [0]和ht [1]两个哈希表。(如下图),这时rehashidx = -1
      Redis源码之字典_第11张图片
    2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始,这时rehashidx = 0
      Redis源码之字典_第12张图片
    3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht [0]哈希表在rehashidx索引上的所有键值对rehash到ht [1],当rehash工作完成之后,程序将rehashidx属性的值+1,下面三步每执行步长为1的rehash时,rehashidx都会+1.
      Redis源码之字典_第13张图片
      Redis源码之字典_第14张图片Redis源码之字典_第15张图片
    4. 随着字典操作的不断执行,最终在某个时间点上, ht [0]的所有键值对都会被rehash至ht [1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。
      Redis源码之字典_第16张图片
  • 渐进式rehash方法:

    /* 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.
     *
     * 执行 N 步渐进式 rehash 。
     *
     * 返回 1 表示仍有键需要从 0 号哈希表移动到 1 号哈希表,
     * 返回 0 则表示所有键都已经迁移完毕。
     *
     * 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.
     *
     * 注意,每步 rehash 都是以一个哈希表索引(桶)作为单位的,
     * 一个桶里可能会有多个节点,
     * 被 rehash 的桶里的所有节点都会被移动到新哈希表。
     *
     * T = O(N)
     */
    int dictRehash(dict *d, int n) {
    
        // 只可以在 rehash 进行中时执行
        if (!dictIsRehashing(d)) return 0;
    
        // 进行 N 步迁移
        // T = O(N)
        while(n--) {
            dictEntry *de, *nextde;
    
            /* Check if we already rehashed the whole table... */
            // 如果 0 号哈希表为空,那么表示 rehash 执行完毕
            // T = O(1)
            if (d->ht[0].used == 0) {
                // 释放 0 号哈希表
                zfree(d->ht[0].table);
                // 将原来的 1 号哈希表设置为新的 0 号哈希表
                d->ht[0] = d->ht[1];
                // 重置旧的 1 号哈希表
                _dictReset(&d->ht[1]);
                // 关闭 rehash 标识
                d->rehashidx = -1;
                // 返回 0 ,向调用者表示 rehash 已经完成
                return 0;
            }
    
            /* Note that rehashidx can't overflow as we are sure there are more
             * elements because ht[0].used != 0 */
            // 确保 rehashidx 没有越界
            assert(d->ht[0].size > (unsigned)d->rehashidx);
    
            // 略过数组中为空的索引,找到下一个非空索引
            while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
    
            // 指向该索引的链表表头节点
            de = d->ht[0].table[d->rehashidx];
            /* Move all the keys in this bucket from the old to the new hash HT */
            // 将链表中的所有节点迁移到新哈希表
            // T = O(1)
            while(de) {
                unsigned int 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;
            // 更新 rehash 索引
            d->rehashidx++;
        }
    
        return 1;
    }

渐进式rehash执行期间的哈希表操作

  • 因为在进行渐进式rehash的过程中,字典会同时使用ht [0]和ht [1]两个哈希表
  • 所以在渐进式rehash进行期间,字典的删除(delete)、查找(find )、更新( update )等操作会在两个哈希表上进行.
    • 例如,要在字典里面查找一个键的话,程序会先在ht [0]里面进行查找,如果没找到的话,就会继续到ht [1]里面进行查找,诸如此类。
  • 另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而 t [0]则不再进行任何添加操作,这一措施保证了ht [0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

关于

  • BGSAVE 命令
    • 时间复杂度: O(N)
    • 功能: 在后台异步(Asynchronously)保存当前数据库的数据到磁盘。
    • 具体步骤:
      • BGSAVE 命令执行之后立即返回 OK
      • 然后 Redis fork 出一个新子进程
      • 原来的 Redis 进程(父进程)继续处理客户端请求,而子进程则负责将数据保存到磁盘
      • 然后退出。
    • 客户端可以通过 LASTSAVE 命令查看相关信息,判断 BGSAVE 命令是否执行成功。
  • BGREWRITEAOF命令
    • 时间复杂度: O(N)
    • 功能:
      • 执行一个 AOF文件 重写操作。重写会创建一个当前 AOF 文件的体积优化版本。
      • 即使 BGREWRITEAOF 执行失败,也不会有任何数据丢失,因为旧的 AOF 文件在 BGREWRITEAOF 成功之前不会被修改。
    • 具体步骤: 重写操作只会在没有其他持久化工作在后台执行时被触发
      • 如果 Redis 的子进程正在执行快照的保存工作,那么 AOF 重写的操作会被预定(scheduled),等到保存工作完成之后再执行 AOF 重写。在这种情况下, BGREWRITEAOF 的返回值仍然是 OK ,但还会加上一条额外的信息,说明 BGREWRITEAOF 要等到保存操作完成之后才能执行.
      • 如果已经有别的 AOF 文件重写在执行,那么 BGREWRITEAOF 返回一个错误,并且这个新的 BGREWRITEAOF 请求也不会被预定到下次执行。

推荐:
服务器最低86元/年

拼团链接: 阿里云点击进入

你可能感兴趣的:(Redis)