Redis基础系列(四)——哈希对象

389 阅读13分钟

这是我参与更文挑战的第8天,活动详情查看:更文挑战

1、哈希对象简介

  在Redis中, 哈希类型是指键值本身又是一个键值对结构, 形如 value={{field1, value1}, ...{fieldN, valueN}}。Redis中,每个哈希键可以存储键值对数量为 22312^{23}-1 个。

哈希对象常用命令

HDEL key field2 [field2] //删除一个或多个哈希表字段
HEXISTS key field //查看哈希表 key 中,指定的字段是否存在。
HGET key field //获取存储在哈希表中指定字段的值/td>
HGETALL key //获取在哈希表中指定 key 的所有字段和值
HINCRBY key field increment //为哈希表 key 中的指定字段的整数值加上增量 increment 。
HINCRBYFLOAT key field increment //为哈希表 key 中的指定字段的浮点数值加上增量 increment 。
HKEYS key //获取所有哈希表中的字段
HLEN key //获取哈希表中字段的数量
HMGET key field1 [field2] //获取所有给定字段的值
HMSET key field1 value1 [field2 value2 ] //同时将多个 field-value (域-值)对设置到哈希表 key 中。
HSET key field value //将哈希表 key 中的字段 field 的值设为 value 。
HSETNX key field value //只有在字段 field 不存在时,设置哈希表字段的值。
HVALS key //获取哈希表中所有值
HSCAN key cursor [MATCH pattern] [COUNT count] //迭代哈希表中的键值对。

2、哈希对象编码

  哈希对象内部编码有两种: ziplisthashtable

  • ziplist(压缩列表):当哈希对象元素个数小于 hash-max-ziplist-entries配置(默认为512个),同时所有值的长度小于 hash-max-ziplist-value 配置(默认为64字节)时,Redis内部会使用ziplist编码存储哈希对象。每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾。
    • 保存了同一键值对的两个节点总是紧挨在一起, 保存键的节点在前, 保存值的节点在后;
    • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
  • hashtable(字典) : 当哈希类型无法满足ziplist的条件时, Redis会使用hashtable作为哈希的内部实现, 因为此时ziplist的读写效率会下降, 而hashtable的读写时间复杂度为O(1) 。

3、压缩列表

  压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型数据结构。

3.1 压缩列表构成

  一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。如下,展示了压缩列表的各个组成部分。

ziplist.png

属性类型长度用途
zlbytesuint32_t4 字节记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
zltailuint32_t4 字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。
zllenuint16_t2 字节记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX65535)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。
entryX列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlenduint8_t1 字节特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

3.2 压缩列表节点的构成

  每个压缩列表节点可以保存一个字节数组或者一个整数值

字节数组长度可以有以下3种

  • 长度小于等于 632612^{6}-1)字节的字节数组;
  • 长度小于等于 1638321412^{14}-1)字节的字节数组;
  • 长度小于等于 429496729523212^{32}-1)字节的字节数组;

整数值则可以有以下6种长度

  • 4 位长,介于 012 之间的无符号整数;
  • 1 字节长的有符号整数;
  • 3 字节长的有符号整数;
  • int16_t 类型整数;
  • int32_t 类型整数;
  • int64_t 类型整数。

  每个压缩列表节点都由 previous_entry_lengthencodingcontent 三个部分组成。

3.2.1 previos_entry_length属性

  previos_entry_length 以字节为单位,记录压缩列表中前一个节点的长度,previos_entry_length属性长度可以是1字节或5字节

  • 如果前一个节点长度小于254字节,previos_entry_length长度为1字节
  • 如果前一个节点长度大于等于254字节,previos_entry_length长度为5字节

  通过previos_entry_length属性可以实现ziplist从表尾向表头遍历。

3.2.2 encoding 属性

  节点的 encoding 属性记录了节点的 content 属性所保存数据的类型以及长度:

  • 一字节、两字节或者五字节长, 值的最高位为 0001 或者 10 的是字节数组编码: 这种编码表示节点的 content 属性保存着字节数组, 数组的长度由编码除去最高两位之后的其他位记录;

  • 一字节长, 值的最高位以 11 开头的是整数编码: 这种编码表示节点的 content 属性保存着整数值, 整数值的类型和长度由编码除去最高两位之后的其他位记录;

    编码编码长度content 属性保存的值
    110000001 字节int16_t 类型的整数。
    110100001 字节int32_t 类型的整数。
    111000001 字节int64_t 类型的整数。
    111100001 字节24 位有符号整数。
    111111101 字节8 位有符号整数。
    1111xxxx1 字节使用这一编码的节点没有相应的 content 属性, xxxx 保存 content

3.2.3 content属性

  节点的 content 属性负责保存节点的值, 节点值可以是一个字节数组或者整数, 值的类型和长度由节点的 encoding 属性决定。

3.3 连锁更新

  在一个压缩列表中, 有多个连续的、长度介于 250 字节到 253 字节之间的节点 e1eN 。因为 e1eN 的所有节点的长度都小于 254 字节, 所以记录这些节点的长度只需要 1 字节长的 previous_entry_length 属性, 换句话说, e1eN 的所有节点的 previous_entry_length 属性都是 1 字节长的。

  如果我们将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的表头节点, 那么 new 将成为 e1 的前置节点,因为 e1previous_entry_length 属性仅长 1 字节, 它没办法保存新节点 new 的长度, 所以程序将对压缩列表执行空间重分配操作, 并将 e1 节点的 previous_entry_length 属性从原来的 1 字节长扩展为 5 字节长。

  现在, 麻烦的事情来了 —— e1 原本的长度介于 250 字节至 253 字节之间, 在为 previous_entry_length 属性新增四个字节的空间之后, e1 的长度就变成了介于 254 字节至 257 字节之间, 而这种长度使用 1 字节长的 previous_entry_length 属性是没办法保存的。

  因此, 为了让 e2previous_entry_length 属性可以记录下 e1 的长度, 程序需要再次对压缩列表执行空间重分配操作, 并将 e2 节点的 previous_entry_length 属性从原来的 1 字节长扩展为 5 字节长。

  正如扩展 e1 引发了对 e2 的扩展一样, 扩展 e2 也会引发对 e3 的扩展, 而扩展 e3 又会引发对 e4 的扩展……为了让每个节点的 previous_entry_length 属性都符合压缩列表对节点的要求, 程序需要不断地对压缩列表执行空间重分配操作, 直到 eN 为止。

  这就是连锁更新,因为一次操作,导致连续多次的空间扩展操作。除了添加节点会引发连锁更新,删除节点也是有可能引起连锁更新的。因为连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配操作, 而每次空间重分配的最坏复杂度为 O(N) , 所以连锁更新的最坏复杂度为 O(N^2) 。

要注意的是, 尽管连锁更新的复杂度较高, 但它真正造成性能问题的几率是很低的:

  • 首先, 压缩列表里要恰好有多个连续的、长度介于 250 字节至 253 字节之间的节点, 连锁更新才有可能被引发, 在实际中, 这种情况并不多见;
  • 其次, 即使出现连锁更新, 但只要被更新的节点数量不多, 就不会对性能造成任何影响: 比如说, 对三五个节点进行连锁更新是绝对不会影响性能的;

4、字典

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

//哈希表
typedef struct dictht{
  //哈希表数组
  dictEntry **table;

  //哈希表大小
  unsigned long size;

  //哈希表大小掩码,用于计算索引值,总是等于size-1
  unsigned long sizemask;

  //哈希表中已有节点的数量
  unsigned long used;
}dictht;

//哈希节点
typedef struct dictEntry{
  //键
  void *key;

  //值
  union{
    void *val;
    uint64_tu64;
    int64_ts64;
  }v;

  //指向下一个哈希表节点
  struct dictEntry *next;
}dictEntry;

//字典
typedef struct dict{
  //类型特定函数,为创建多态字典而设置的
  dictType *type;

  //私有数据
  void *privdata;

  //哈希表,同时保存两个哈希表结构,当进行rehash时,将h[0]的复制到h[1]中;结束rehash时,将h[0]指向h[1]再将h[1]置空
  dictht ht[2];

  //rehash索引,当rehash不再进行是,值为-1
  int rehashidx;
}dict;

typedef struct dictType{
  //计算哈希值的函数
  unsigned int (*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;

4.1 哈希算法

  Redis计算哈希值和索引值的方法如下:

  • 使用字典设置的哈希函数,计算键key的哈希值
  • hash = dict -> type -> hashFunction(key)
  • 使用哈希表的sizemask属性和哈希值,计算出索引值(sizemask为哈希表大小掩码,用于计算索引值,总是等于size-1)
  • 根据情况不同,ht[x]可以是ht[0]或者ht[1]
  • index = hash & dict -> ht[x].sizemask

4.2 解决键冲突

  当有两个或两个以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。

  • 解决哈希冲突的四种方法
    • 开放地址法
      • 线性探测法:在冲突的值上加上一个单位的值,直至不冲突。
      • 再平方探测法:在冲突的值上加上1的平方个单位,如果冲突则减去1的平方个单位;接着2的平方、3的平方,直至不冲突。
      • 伪随机法:在冲突的值上加上一个随机数,直至不冲突。
    • 链式地址法:对冲突的的值使用功能链表方式存储
      • 优点:处理方式简单,不会产生堆积现象,平均查找长度较短。
      • 链表节点可以随意扩展,适合无法确定长度的情况。
      • 相较于开放地址法,链式地址法更节省空间。
    • 建立公共溢出区:建立公共溢出区存储所有冲突的值。
    • 再哈希法:对于冲突的值再次使用哈希算法,直至不发生冲突。

  Redis使用联地址法解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置。

4.3 rehash

  为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的健值对数量过多或过少时,需要对哈希表进行扩展或者收缩。

  • rehash步骤
    • 为字典的ht[1]哈希表分配空间
      • 如果执行扩展操作,则ht[1]的长度大于等于ht[0].used * 2 的 2的n次方
      • 如果执行的是收缩操作,则ht[1]的大小为大于等于ht[0].used 的 2的n次方。
    • 将ht[0]的健值对rehash到ht[1]上面。
    • ht[0]的键值对全部rehash到ht[1]时,释放ht[0],将ht[1]设置为ht[0],并为ht[1]新创建一个空的哈希表。
  • 哈希表的扩展条件
    • 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
    • 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
  • 哈希表的收缩条件
    • 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

4.4 渐进式rehash

当哈希表的键值对数量太多时,一次性将所有键值对进行rehash会对服务器造成压力,甚至导致服务器停止服务。为了保证服务器性能不受影响,采用多次、渐进式地进行rehash

  • 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
  • 将rehashidx设置为0,表示rehash工作正式开始。
  • 在rehash期间,每次对字典执行添加、删除、查找或者更新的操作时,都会将所操作的健值对进行rehash,并将rehashidx加1。当进行更新、删除、查找操作时,都会在ht[0]和ht[1]上执行,添加键值对时只对ht[1]操作,保证ht[0]只减不增。
  • 当ht[0]所有键值对都完成rehash时,将rehashidx设置为-1,rehash结束。