(Redis 理论与实践学习)一、Redis的数据结构:3.Hash类型


简介

Redis中的Hash类型是一种键值对(Key-Value)的集合映射表,其特别适合用于存储对象,因此在各种场景的业务中都有着广泛应用。

127.0.0.1:6379> HMSET student name "xiaohei" age 20
OK
127.0.0.1:6379> HGETALL student
1) "name"
2) "xiaohei"
3) "age"
4) "20"

在上述的命令中,我们成功的存储了一个对象,其key值为student,包含了nameage两个字段,最终存入到Redis中。在前面的学习中,我们介绍了String类型也可以用来存储对象(详情见此),那么两者在实际业务中存储对象的区别是什么样的呢?


可以看到,使用String的话需要额外的一个序列化,而Hash的话则是按照字段统一存储到Redis中。

常用命令

接下来将罗列出Hash在Redis的一些常用命令

# 将哈希表key中的字段 filed 的值设为 value
HSET key field value

# 同时将多个field-value对设置到哈希表key中
HMSET key field1 value1 [filed2 value2 ...]

# 只有在字段field不存在时,设置哈希表字段的值
HSETNX key field value

# 获取存储在哈希表中指定字段的值
HGET key field

# 获取存储在哈希表中所有字段的值
HGETALL key

# 获取所有给定字段的值
HMGET key field1 [field2 ...]

# 删除一个或多个Hash表字段
HDEL key field1 [field2 ...]

# 查看Hash表key中,指定字段是否存在 
HEXISTS key field

# 为哈希表key中的指定字段的整数值自增increment
HINCRBY key field increment

# 为哈希表key中的指定字段的浮点数值自增increment
HINCRBYFLOAT key field increment

# 获取哈希表中所有字段
HKEYS key

# 获取哈希表中字段的数量
HLEN key

# 获取哈希表中所有字段的值
HVALS key

# 迭代哈希表中的键值对
HSCAN key cursor [MATCH pattern] [COUNT count]

应用场景

缓存对象

Hash类型中的(keyfiledvalue)三者的关系和对象中的(对象id字段属性字段值)相对应,因此比较适合用来存储对象。
假设存储的对象为用户,用户标识为uid —> key, 每个用户的信息用以对应不同的字段

uidnamegenderage
001xiaominmale21
002xiaofengfemale19

上述表格表示该数据在关系型数据库中的信息,想要将用户信息存储Redis中,可以执行如下命令

127.0.0.1:6379> HMSET uid:001 name xiaohei gender male age 21
OK
127.0.0.1:6379> HMSET uid:002 name xiaofeng gender female age 19
OK
127.0.0.1:6379> HGETALL uid:001
1) "name"
2) "xiaohei"
3) "gender"
4) "male"
5) "age"
6) "21"

此时,Redis中Hash的存储内容如下图所示:

购物车

购物车业务可以理解成用户和商品之间的关系,下表为每个业务关键字与Redis中存储关键字的对应:

Keyfiledvalue
用户id商品id商品数量

根据上述的关键字对应,我们可以将一个购物车的部分功能使用Redis中Hash的命令来实现:

当前场景中仅仅是将商品ID存储到了Redis 中,在前端回显商品具体信息前,还会使用商品 id 查询数据库,最终获取完整的商品的信息。


内部实现

Hash类型的底层结构实现是由压缩列表哈希表组成,并且它的需求的条件和List类型中的要求相对类似,下图为Redis不同版本下,不同情况下的内部实现判断条件:

Redis7.0中废弃了压缩列表,所以会选择listpack数据类型。在此之前,判断Redis中底层数据类型的使用和List类型中类似,同样以元素数量(hash-max-ziplist-entries配置)和元素大小(hash-max-ziplist-value配置)作为判断依据。

压缩列表

压缩列表在List类型的章节已经提过,这里的描述与上个章节相同,如果你以及看过,可以跳转到下一小节

压缩列表(ziplist)是列表键哈希键的底层实现之一,当一个列表键只包含少量列表项,并且每个列表项中的元素属于小值(小整数或短字符串等),此时Redis将会选择使用压缩列表来做哈希键的底层实现。

压缩列表的构成

压缩列表的设计就是为了节约内存,是由一系列特殊编码的连续内存块实现组成的顺序型数据机构,一个压缩列表包含任意多个entry节点,每个节点可以保存一个字节数组或者一个整数数组,其结构如图所示:

可以从图中看到,压缩列表主要有三个大部分组成,其中主要作用及功能如下表所示:

属性类型长度用途使用时机
zlbytesuint32_t4字节记录整个压缩占用内存的字节数当压缩列表进行内存重分配或计算zlend值时使用
zltailuint32_t4字节记录压缩列表表尾节点距离距离起始地址有多少字节,即列表尾的偏移量通过zltail可以无需遍历列表时就得到表尾节点的位置
zllenuint16_t2字节记录压缩列表包含的字节数量:当小于uint16_Max时值就是压缩列表对应的节点数量,一旦超过就需要重新计数了获取列表元素数量时调用
entryX列表节点不定压缩列表中的节点,节点的长度由节点保存的内容决定
zlenduint8_t1字节固定值 0xFF (十进制255)用以表示压缩列表的结束

可以看到,在压缩列表中,当我们想要查找定位第一个元素和最后一个元素时,可以通过表头前三个字段查找,此时的时间复杂度为O(1),但是要查找其他元素时,就要逐个遍历才能查询到对应的元素,此时的时间复杂度为O(n)。

压缩列表节点的构成

每个压缩列表节点可以保存一个字节数组或者一个整数值,其结构如下图所示:

每个压缩列表结点都是由previous_entry_lengthencodingcontent三个部分组成,其作用分别时记录上一个节点的长度、记录当前节点实际数据的类型和长度、记录当前节点的实际数据,详细介绍如下

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

  • 如果前一个节点的长度小于 254 字节,那么 previous_entry_length 属性需要用 1 字节的空间来保存这个长度值
  • 如果前一个节点的长度大于等于 254 字节,那么 previous_entry_length 属性需要用 5 字节的空间来保存这个长度值

如何通过previous_entry_length获取上一个节点的地址呢?

举个例子,存在当前节点起始地址的指针c,那么就只需要通过c - c中记录previous_entry_length得到前一个节点的地址


如果想要实现向前遍历,只需要不断的执行这一操作直到越过节点地址。

encoding

节点的encoding属性是为了记录节点的content属性所保存数据的类型以及长度。对于不同情况下的值设计,表格总结如下

字节数组编码
编码编码长度content属性保存的值
00bbbbbb1字节长度小于等于63字节的字节数组
01bbbbbb xxxxxxxx (14bit表示content长度)2字节长度小于等于16383(214 - 1)字节的字节数组
10_ _ _ _ _ _ aaaaaaaa bbbbbbbb cccccccc dddddddd 32 bit 表示content长度5字节长度小于等于4294967295 (232-1)的字节数组
整数编码
编码编码长度content属性保存的值
11 00 00001字节int16_t类型的整数
11 01 00001字节int32_t类型的整数
11 10 00001字节int64_t类型的整数
11 11 00001字节24位整数
11 11 11101字节8位整数
11 11 xxxx1字节此时无content字段,用xxxx表示0~12的整数

content

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

连锁更新问题

在之前我们了解到了previous_entry_length的长度由前一个节点的长度决定的。

节点大小previous_entry_length 长度
小于254字节1字节
大于等于2545字节

特殊情况下会出现一件很麻烦的事情,就是当插入一个较大的元素时,可能会导致后续节点的长度随之增大,进而导致连续的后续节点增大形成连锁反应,导致每个元素的空间都需要重新分配,造成访问压缩列表的性能出现下降。

现在,考虑这样的一种情况:一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN,如下图所示


此时这些节点的长度值都小于254字节,所以previous_entry_length属性的长度都是1字节。这时,如果将一个长度大于254字节的新节点加入到压缩列表中,比如将e0节点加入e1节点之前,那么e1节点的前节点就是e0节点,因为e0节点的长度大于254字节,所以e1节点中的previous_entry_length属性就要变成5个字节长度。


按照这个逻辑,整个列表将会出现这样的改变:

除了添加节点会导致更新,删除节点也同样会导致这种异常。比如下图的情形:


这是e1原本记录的前节点是小节点,随着删除small节点,反而让前节点变成大节点了,同样出现了连锁更新的问题。

因为连锁更新在最坏情况下需对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度是O(N),所以连锁更新的最坏复杂度为O(N2)。

为了避免连锁更新带来的性能降低,Hash表会在数据量少的时候使用压缩列表,因为此时使用压缩列表可以很好的减少内存的占用,其次就算有连锁更新问题,少量的节点也并不会有太大的影响。如果节点数量或者节点元素大小超过了设置的值,那么就会使用哈希表来作为底层结构的实现。

哈希表

Redis中的Hash类型在大部分的情况下是以哈希表作为底层实现的,一个哈希表可以有多个哈希表节点,每个哈希节点会保存Hash中的一个键值对。

哈希表的由dict.h/dicht结构体定义,其定义内容如下

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

其中table表示一个哈希数组,数组中又指向另一个结构类型哈希节点dictEnty的指针,其结构定义在dict/dictEntry结构体中。

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

哈希表和哈希节点的关系的如下图所示:

每个dictEntry结构保存着一个键值对。在dicthttabel表示指向dicEntry数组的指针。size属性则负责记录table数组的大小,而used属性则记录了哈希表目前已有节点的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table的哪个索引上面。

对于哈希节点内部属性,不仅包含一个键值对,还包含一个next指针,将索引相同的键值对链接在一起,从而解决hash地址冲突的问题。

哈希算法

Redis使用dictht结构作为哈希表,但为了实现更为高级的操作,在此层之上又封装了一个dict结构,其结构设计在dict.h/dict中,作为字典的结构表示:

typedef struct dict {
	// 类型特定函数
	dictType *type;

	// 私有数据
	void *privdata

	// 哈希表
	dictht ht[2]
	
	// rehash 索引
	// 当rehash不在进行时,值为1
	int trehashidx
} dict;

type属性和privdata属性是针对不同类型的键值对,为创建多态字典设计的。其实现方法如下:

  • type 属性是一个指向dictType结构的指针,每个dictType结构都会保存一组用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
  • privdata属性则保存一些需要传给操作函数的可选参数。
typedef struct dictType {
    /* Callbacks */
    // 计算哈希值的函数
    uint64_t (*hashFunction)(const void *key);
    // 复制键的函数
    void *(*keyDup)(dict *d, const void *key);
    // 复制值的函数
    void *(*valDup)(dict *d, const void *obj);
    // 对比键的函数
    int (*keyCompare)(dict *d, const void *key1, const void *key2);
    // 销毁键的函数
    void (*keyDestructor)(dict *d, void *key);
    // 销毁值的函数
    void (*valDestructor)(dict *d, void *obj);

	...
	...


} dictType;

上述代码展示一部分方法,如果感兴趣可以查看dict.h中的代码实现。

对于dict结构体,其中ht属性是最为重要,其表示一个包含两个哈希表的数组。在常规情况下,Redis会只使用ht[0]的哈希表,ht[1]哈希表只会在对ht[0]进行rehash操作时使用。除了ht[1]之外,另一个和rehash相关的属性就是rehashidx,根据此属性可以判断当前字典中是否正在进行rehash的操作。关于rehash我们之后会进行介绍,让我们先看看结合dict字典后,整个redis的存储过程是什么样的。


上图表示没有出现rehash时,整个字典的存储状态,那么问题来了,字典是如何执行哈希查询的呢?接下来就需要介绍到Redis中使用的哈希算法了。

当Redis要将一个新的键值对添加到哈希表中,需要先根据键值对的键计算出哈希值索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的索引上面。

Redis计算键值对的索引值,过程如下

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

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

可以简单的理解为计算key的哈希值是为了将其他类型的值转换成可计算的值,进一步进行index的计算,如下图举个例子:


通过哈希值与索引值的两次计算,最终键值对按照索引值的结果存放到哈希节点中。Redis的哈希算法使用的MurmurHash2,如果想了解更多的读者可以自行搜索。

学习过数据结构的同学,肯定会注意到哈希表必然会出现的哈希冲突问题,上述图中,假设key被计算出4,那么就会和哈希值为8的冲突,因此Redis还设计了一些方法来解决哈希冲突。

解决哈希冲突

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面,此时就被称为哈希冲突,Redis解决哈希冲突的方法是链地址法。

像上述图中,next指针指向下一个节点,形成一个类似链表的结构,即链地址法的实现。因为dictEntry节点组成的链表没有指向表尾的指针,所以为了速度的考虑,Redis总是将新节点添加到链表的表头位置(复杂度为O(1)),所以每个新节点总是排在已有节点的前面。

rehash策略

链地址法虽然可以解决索引值相同带来的冲突,但是过长的链地址会导致查询时间的增大,从而降低Redis中哈希表的查询速度,因此Redis设计了一种rehash策略,通过负载因子算法来让哈希表的链地址长度维持在一个合理的范围。当哈希表保存的键值对数数量过多或是数量太少时,Redis需要对哈希表的大小进行一定的扩展或缩减。
触发rehash操作一般需要三个步骤:

  1. h[1]哈希表分配空间,这个空间的大小取决要执行的操作和h[0]当前包含的键值对数量。
    • 扩展操作:h[1]的大小为ht[0].used*2的2n
    • 收缩操作:h[1]的大小为ht[0].used的2n
  2. 将保存在ht[0]的数据按照顺序重新计算键的哈希值和索引值,按照新的索引值转移到ht[1]中。
  3. 迁移完成后,会把ht[0]的数据释放掉,并将ht[1]修改为ht[0],并在ht[1]处创建一个新的空哈希表为下次rehash做准备。

对于上述描述的过程,可用下图做对应的演示。


基于以上流程,整个rehash的流程就完成了,接下来Redis就会创建新的哈希表ht[1]等待下次扩展或收缩即可。
虽然rehash可以解决哈希冲突过多带来的查找时间复杂度上升的问题,但是随着哈希表中的节点不断增大,后续执行一次rehash可能会阻塞系统不少的时间,为了缓解rehash带来的时间问题,Reids还设计了渐进式rehash策略来缓解rehash对系统的阻塞。

渐进式rehash策略

渐进式rehash顾名思义,渐进式,这个动作不是一次性而是分多次渐进式的完成。
为了避免rehash对服务器性能造成影响,服务器不是一次性的将ht[0]中的所有键值对一次性搬移到ht[1]中,而是分多次、渐进式的将ht[0]中的键值对rehash到ht[1]中。
渐进式rehash的过程如以下步骤所示:

  1. ht[1]分配空间,让字典同时拥有ht[0]ht[1]两个哈希表
  2. rehashidx的值设置为0,表示当前正在进行rehash的工作
  3. rehash期间,Redis可以正常执行哈希表的增删改查,除此之外,Redis还会根据rehashidx的值将一部分ht[0]上的键值对搬移到ht[1]上,每搬一次,都会使rehashidx加一。
  4. 随着渐进式rehash的工作不断进行,最终ht[0]上的键值对会被搬空即结束rehash的工作,此时Redis将rehashidx的设置为-1,并按照rehash的收尾方法修改ht[0]ht[1]的指向。

如果对上述的过程还是不清楚,下图表示一个完整的渐进式rehash的过程:

上图即是整个渐进式rehash的流程。

看到这里大家可能会有一点疑问,如果执行渐进式rehash的过程中出现增删改查的情况,Redis是怎么处理的呢?
因为在渐进式rehash的过程中,Redis的字典会同时使用ht[0]ht[1]两个哈希表,所以增删改查的操作也会在两个表上执行。

  • 查、删、改:Redis会先在ht[0]上找,此时找不到会到ht[1]上再找
  • : 为了避免影响渐进式rehash的工作,所以在运行时,Redis中的所有增都不会在添加到ht[0]中,而是会直接增加到ht[1]上,这样rehash中只需要扫描一次ht[0]就可以完成rehash的操作。
负载因子与触发rehash的关系

首先介绍一下负载因子,负载因子的是哈希表的拥挤程度的一种状态反映,其计算方式可通过公式

// 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

结合负载因子和Redis当前的工作状态选择是否执行rehash操作,一般有以下两种情况

  • Redis当前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,即没有执行 RDB 快照或者 AOF 重写的操作时,此时负载因子大于等于1时就会执行rehash操作
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。
  • 当负载因子小于0.1时,程序将进行收缩操作

用流程图表示如下:

listpack

Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

listpack的结构设计

Redis 中的 listpack 是一种专门用于优化存储和访问列表数据结构的内部表示形式。它是为了解决传统的双向链表在存储大量小列表时产生的空间浪费和性能问题而设计的。


listpack 是一种紧凑的、二进制安全的数据结构,可以有效地存储多个列表元素,并提供快速的访问能力。它由以下3个部分组成

  1. 头部(Header):存储有关列表的元信息,如列表的长度等。
  2. 元素(Elements):紧接在头部后面的元素数组,每个元素可以是一个整数、一个字符串或者一个嵌套的列表。
  3. 压缩编码(Compression):用于对元素进行特定的编码,以节省空间。

对于每个元素,其内部结构也有3个部分组成

  1. Encoding:定义该元素的编码类型,会对不同长度的整数和字符串进行编码
  2. data:实际存放的数据
  3. len:encoding+data的总长度;

listpack 通过其紧凑的二进制格式和快速的索引访问,显著提升了 Redis 在处理大规模列表数据时的性能和效率。目前我只了解这些,如果你想了解更深入的,可以自行进行搜索。

本文是经过个人查阅相关资料后理解的提炼,可能存在理论上的问题,如果您在阅读过程中发现任何问题或有任何疑问,请不吝指出,我将非常感激并乐意与您讨论。谢谢您的阅读!

  • 32
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值