1.3 hash - 图1

1.内部结构

哈希类型的内部编码有两种:ziplist(压缩列表),hashtable(哈希表)
只有当存储的数据量比较小的情况下,Redis 才使用压缩列表来实现字典类型;

  • 哈希类型元素个数小于hash-max-ziplist-entries配置(默认512个)
  • 所有值都小于hash-max-ziplist-value配置(默认64字节)

    1. `ziplist`使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比`hashtable`更加优秀。当哈希类型无法满足`ziplist`的条件时,Redis会使用`hashtable`作为哈希的内部实现,因为此时`ziplist`的读写效率会下降,而`hashtable`的读写时间复杂度为O1)。

    1.1 压缩列表(ziplist.c/ziplist.h)

    详情查看list中讲解。

    1.2 字典(dict.c/dict.h)

1.3 hash - 图2

1.2.1 dict 字典

  1. typedef struct dict{
  2. //类型特定函数
  3. void *type;
  4. //私有数据
  5. void *privdata;
  6. //哈希表-见1.2.3
  7. dictht ht[2];
  8. //rehash 索引 当rehash不在进行时 值为-1
  9. int trehashidx;
  10. }dict;

type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。

  • type属性是一个指向dictType结构的指针,每个dictType用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
  • privdata属性则保存了需要传给给那些类型特定函数的可选参数。
  • ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表, 一般情况下,字典只使用ht[0] 哈希表, ht[1]哈希表只会对ht[0]哈希表进行rehash时使用。
  • rehashidx记录了rehash目前的进度,如果目前没有进行rehash,值为-1。

1.2.2 dictType 多态类型

  1. typedef struct dictType
  2. {
  3. //计算哈希值的函数
  4. unsigned int (*hashFunction) (const void *key);
  5. //复制键的函数
  6. void *(*keyDup) (void *privdata,const void *key);
  7. //复制值的函数
  8. void *(*keyDup) (void *privdata,const void *obj);
  9. //复制值的函数
  10. void *(*keyCompare) (void *privdata,const void *key1, const void *key2);
  11. //销毁键的函数
  12. void (*keyDestructor) (void *privdata, void *key);
  13. //销毁值的函数
  14. void (*keyDestructor) (void *privdata, void *obj);
  15. }dictType;

1.2.3 dictht散列表

类似Java中的HashMap

  1. typedef struct dictht
  2. {
  3. //哈希表数组,C语言中,*号是为了表明该变量为指针,有几个* 号就相当于是几级指针,这里是二级指针,理解为指向指针的指针
  4. dictEntry **table;// 就理解成一个二维的数组,类似于HashMap中的结构
  5. //哈希表大小
  6. unsigned long size;
  7. //哈希表大小掩码,用于计算索引值
  8. unsigned long sizemask;
  9. //该哈希已有节点的数量
  10. unsigned long used;
  11. }dictht;
  • table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对
  • size属性记录了哈希表的大小,也是table数组的大小
  • used属性则记录哈希表目前已有节点(键值对)的数量
  • sizemask属性的值总是等于 size-1(从0开始),这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面(索引下标值)

    1.2.4 dictEntry散列表节点

    1. //哈希表节点定义dictEntry结构表示,每个dictEntry结构都保存着一个键值对。
    2. typedef struct dictEntry
    3. {
    4. //键
    5. void *key;
    6. //值
    7. union{
    8. void *val;
    9. uint64_t u64;// 在stdint.h头文件中定义 typedef unsigned long long int uint64_t;
    10. int64_t s64;
    11. }v;
    12. // 指向下个哈希表节点,形成链表
    13. struct dictEntry *next;
    14. }dictEntry;
    key属性保存着键值中的键,而v属性则保存着键值对中的值,其中键值(v属性)可以是一个指针,或uint64_t整数,或int64_t整数。 next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,解决键冲突问题。

1.3 Redis如何解决Hash冲突

1.3.1 拉链法(链表法)

1.3 hash - 图3

如图所示,当键k0和k1的经过散列函数得到索引值都为1时,就会使用next指针将两个节点连接起来。而由于节点没有指向链尾的指针,因此新的节点总是插入到链表的头部,排在已有节点的前面。(Redis 单线程,所以不会像HashMap那样因多线程访问 导致JDK1.7中头插法导致的死环)

1.3.2 Redis Rehash

随着操作的进行,散列表中保存的键值对会也会不断地增加或减少,为了保证负载因子维持在一个合理的范围,当散列表内的键值对过多或过少时,内需要定期进行rehash,以提升性能或节省内存。Redis的rehash的步骤如下:

1.3 hash - 图4

1.为字典的ht[1]散列表分配空间,这个空间的大小取决于要执行的操作以及ht[0]当前包含的键值对数量(即:ht[0].used的属性值)

  • 扩展操作:ht[1]的大小为 第一个大于等于ht[0].used*2的 1.3 hash - 图5 。如:ht[0].used=3则ht[1]的大小为8,ht[0].used=4则ht[1]的大小为8。
  • 收缩操作: ht[1]的大小为 第一个大于等于ht[0].used的1.3 hash - 图6

1.3 hash - 图7

2.将保存在ht[0]中的键值对重新计算键的散列值和索引值,然后放到ht[1]指定的位置上。

1.3 hash - 图8

3.将ht[0]包含的所有键值对都迁移到了ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并创建一个新的ht[1]哈希表为下一次rehash做准备;

1.3 hash - 图9

1.3.3 哈希表的扩展与收缩

服务器目前没有执行BGSAVE(rdb持久化)命令或者BGREWRITEAOF(AOF文件重写)命令,并且散列表的负载因子大于等于1。
服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且负载因子大于等于5。
当负载因子小于0.1时,程序自动开始执行收缩操作。

其中哈希表的负载因子可以通过公式:

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

根据 BGSAVE 命令(RDB持久化命令)或 BGREWRITEAOF (APF持久化命令)命令是否正在执行, 服务器执行扩展操作所需的负载因子并不相同, 这是因为在执行 BGSAVE 命令或 BGREWRITEAOF 命令的过程中, Redis 需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率, 所以在子进程存在期间, 服务器会提高执行扩展操作所需的负载因子, 从而尽可能地避免在子进程存在期间进行哈希表扩展操作, 这可以避免不必要的内存写入操作, 最大限度地节约内存。

1.4 渐进式Rehash

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

这样做的原因在于, 如果 ht[0] 里只保存着四个键值对, 那么服务器可以在瞬间就将这些键值对全部 rehash 到 ht[1] ; 但是, 如果哈希表里保存的键值对数量不是四个, 而是四百万、四千万甚至四亿个键值对, 那么要一次性将这些键值对全部 rehash 到 ht[1] 的话, 庞大的计算量可能会导致服务器在一段时间内停止服务。

因此, 为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0] 里面的所有键值对全部 rehash 到 ht[1] , 而是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1]

1.4.1 渐进式Rehash 步骤

以下是哈希表渐进式 rehash 的详细步骤:

  1. ht[1] 分配空间, 让字典同时持有 ht[0]ht[1] 两个哈希表。
  2. 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
  3. 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
  4. 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。

渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。

1.4.2 完整的渐进式Rehash过程

注意观察在整个 rehash 过程中, 字典的 rehashidx 属性是如何变化的。

1.3 hash - 图101.3 hash - 图11
1.3 hash - 图12
1.3 hash - 图13
1.3 hash - 图14

1.3 hash - 图15

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

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

2.常用命令

命令 说明 时间复杂度
HMSET key field value [field value …] 设置hash字段值 O(N) N是设置的字段数
HSET key field value 设置hash里面一个字段的值 O(1)
HSETNX key field value 设置hash的一个字段,只有当这个字段不存在时有效 O(1)
HSTRLEN key field 获取hash里面指定field的长度 O(1)
HVALS key 获得hash的所有值 O(N) N是Hash的长度
HDEL key field [field …] 删除一个或多个Hash的field O(N) N是被删除的字段数量。
HEXISTS key field 判断field是否存在于hash中 O(1)
HGET key field 获取hash中field的值 O(1)
HGETALL key 从hash中读取全部的域和值 O(N) N是Hash的长度
HINCRBY key field increment 将hash中指定域的值增加给定的数字 O(1)
HINCRBYFLOAT key field increment 将hash中指定域的值增加给定的浮点数 O(1)
HKEYS key 获取hash的所有字段 O(N) N是Hash的长度
HLEN key 获取hash里所有字段的数量 O(1)
HMGET key field [field …] 获取hash里面指定字段的值 O(N) N是请求的字段数
HSCAN key cursor [MATCH pattern] [COUNT count] 迭代hash里面的元素

3.使用场景

3.1 购物车

很多电商网站都会使用 cookie实现购物车,也就是将整个购物车都存储到 cookie里面。这种做法的一大优点:无须对数据库进行写入就可以实现购物车功能,这种方式大大提高了购物车的性能,而缺点则是程序需要重新解析和验证( validate) cookie,确保cookie的格式正确,并且包含的商品都是真正可购买的商品。cookie购物车还有一个缺点:因为浏览器每次发送请求都会连 cookie一起发送,所以如果购物车cookie的体积比较大,那么请求发送和处理的速度可能会有所降低。
购物车的定义非常简单:我们以每个用户的用户ID(或者CookieId)作为Redis的Key,每个用户的购物车都是一个哈希表,这个哈希表存储了商品ID与商品订购数量之间的映射。在商品的订购数量出现变化时,我们操作Redis哈希对购物车进行更新:

如果用户订购某件商品的数量大于0,那么程序会将这件商品的ID以及用户订购该商品的数量添加到散列里面;
image.png
用户uid:zhangsan, 商品sku :order_item:skuid:123311223445 ,购买了10个

3.2 计数器

image.png
例如某博客 ,blogid为8759 ,在2021年4月16日的访问量的统计。
image.png
模拟当年斗鱼 某直播间 online人数 突破12亿的操作,可能就是 redis 的hash value值被直接修改。(^.^)

引用

http://redisbook.com/preview/dict/incremental_rehashing.html
http://zhangtielei.com/posts/blog-redis-dict.html
https://blog.csdn.net/bellediao/article/details/82967645