redis底层数据结构系列 - 字典

255 阅读7分钟

青春因磨砺而出彩,人生因奋斗而升华

redis字典简介

关联数组或者映射,是一种用于保存键值对的抽象数据结构;在字典中,一个key和一个value进行关联,这些关联就成为键值对;字典中每一个键都是唯一的,程序可以在字典中根据键查找与之关联的值,或通过键来更新和删除值等;(redis自己构建了字典);

用途

除了hash结构的数据会用到字典外,整个redis数据库的所有key和value业组成了一个全局字典,还有带过期时间的key集合也是一个字典。zset集合中存储的value和score值的映射关系也是通过dict结构实现的。
redis使用hash表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个节点就保存了字典中的一个键值对;

redis字典的结构

typedef struct dict {
      // 类型特定函数
      dictType *type;
      // 私有数据
      void *privdata;
      // 哈希表 两张哈希表 后续字典扩展做rehash用
      dictht ht[2];
      // rehash索引;当rehash没有进行时,值为-1;否则表示rehash进行到的索引位置;
      long rehashidx;
      // 当前运行迭代器数
      unsigned long iterators; 
}dict

字段介绍:

  • type: 指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。

  • privdata:保存了需要传给哪些类型特定函数的可选参数 

  • ht: 包含两个数组,数组中每个项都是一个dictht哈希表,字典只使用ht[0]哈希表,h[1]哈希表只会在对ht[0]哈希表进行rehash时使用。

  • rehashidx: 记录rehash目前进度,如果没有rehash值为-1;

子结构

typedef struct dictType {
      // 计算hash值的函数
      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 **table;    // 哈希数组
    unsigned long size;    // 哈希表大小
    unsigned long sizemask;    // 哈希表大小掩码,用于计算索引值,总是等于size-1
    unsigned long used;    // 该哈希表已有节点的数量
}dictht;

字段介绍:

  • table: 一个数组,数组中每一个元素都指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对。

  • size: 记录了哈希表的大小,也即table数组的大小

  • used: 记录了哈希表目前已有节点(键值对)的数量。

  • sizemask:属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面;

节点结构

typedef struct dictEntry {
    void *key;   // 键
    union {
        void *val;     // 自定义类型
        uint64_t u64;  // 无符号整形
        int64_t s64;   // 有符号整形
        double d;      // 浮点型
    } v;       // 值
    struct dictEntry *next;  //指向下个哈希节点,形成链表
} dictEntry;

每个dictEntry都保存着一个键值对

字段介绍

  • key: 保存键值对中的键

  • v: 保存键值对中的值,其值可以是一个指针、uint64_t整数,或一个int64_t整数;

  • next: 指向下一个哈希节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突问题;

hash

hash算法

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出hash值,然后再将hash值与哈希表的sizemask属性做位与,得到索引值哈希数组的index;然后将数据放入哈希数组指定索引上面;

hash冲突

当有两个或以上数据分配到hash数组的同一个索引上面时,这些键就发生了冲突,redis的哈希表使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单项链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突;
由于节点组成的链表不包含指向链表表尾指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置;

如冲突图

字典整体存储架构图

哈希表的扩展与收缩

扩展条件

  • 负载因子 = 哈希表已保存节点数量 / 哈希表大小;

  • 空hash表(初始化扩容4)

  • 服务器目前没有执行bgsave或bgrewriteaof命令,并且哈希负载因子 >= 1时,扩容为原数组大小的2倍

  • 如果服务器正在执行bgsave或bgrewriteaof,并且哈希表的负载因子 >= 5时,扩容为原数组大小的2倍

缩容条件

    当hash表中元素逐渐删除的越来越稀疏时,redis会对hash进行缩容来减少hash表空间占用。

  • 条件:元素个数 < 数组长度的10%,hash表就会缩容。所容不考虑redis是否在做bgsave

渐进式rehash

为什么渐进式rehash?

如果ht[0]保存键值对较少,那么服务器可以瞬间将这些键值对全部rehash到ht[1];但是如果哈希表里保存的键值对数量很大,百万、千万甚至亿级个键值对,那么要一次性将这些键值对全部rehash,可能会导致服务器在一段时间停止服务;
所以:作为单线程的redis采用渐进式rehash小步搬迁。

步骤:

  • 判断负载因子,如果达到条件,则进行rehash操作

  • 为ht[1]分配空间,让字典同时持有ht[0] ht[1]两个哈希表

  • 在字典中维持一个索引计数器变量rehashidx, 并将他们的值设置为0,表示rehash正式开始进行;

  • 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作外,顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash完成之后,将rehashidx+1

  • ht[0]所有键值都被rehash到ht[1],将rehashidx设置为-1,表示操作完成

渐进式rehash执行期间的hash操作

因为在进行渐进式rehash的过程,字典会同时使用ht[0] ht[1]两个hash表,所以在渐进式rehash进行期间,字典的删除、查找、更新等操作会在两个哈希表上进行;

且新添加到字典的键值对一律保存到ht[1]里面,ht[0]不进行任何添加操作;保证ht[0]包含的键值对数量只减不增;

渐进式rehash带来的问题

渐进式hash避免了redis阻塞,但由于rehash时,需要分配一个新的hash表,在rehash期间,同时有两个hash作用,会使得redis内存使用量瞬间突增,在redis满容状态下rehash可能会导致大量key驱逐;

思考

从结构来看每个字典中都包含两个hashtable。那么为什么一个字典需要两个hashtable?

首先redis在正常读写时会用到一个hashtable, 而另一个hashtable的作用仅作为字典进行rehash时的一个临时载体。
即redis开始只会用一个hashtable去读写,如果这个hashtable的数量增加或缩减到某个值,到达rehash的条件,redis便会开始根据数量和桶的个数初始化那个备用的hashtable,来使这个hashtable从容量上满足后续的使用,并开始把之前的hashtable数据迁移到这个新的hashtable上来,等全部迁移完成,再进行一次hashtable地址更名,把备用的hashtable作为正式的,同时清空另一个hashtable以供下一次rehash;