Redis 的 hash 是什么?

Sherwin.Wei Lv7

Redis 的 hash 是什么?

回答重点

Redis 的 hash 是一种键值对集合,可以将多个字段和值存储在同一个键中,便于管理一些关联数据。
特点如下:

  1. 适合存储小数据,使用哈希表实现,能够在内存中高效存储和操作。
  2. 支持快速的字段操作(如增、删、改、查),非常适合存储对象的属性。

扩展知识

hash 常用命令

HSET:设置 hash 中指定字段的值。

1
HSET user:1001 name "mianshiya"

HGET:获取 hash 中指定字段的值。

1
HGET user:1001 name

HMSET:一次性设置多个字段的值。

1
HMSET user:1001 name "mianshiya" age "18" city "shanghai"

HMGET:一次性获取多个字段的值。

1
HMGET user:1001 name age

HDEL:删除 hash 中的一个或多个字段。

1
HDEL user:1001 age

HINCRBY:为 hash 中的字段加上一个整数值。

1
HINCRBY user:1001 age 1

Hash 底层实现解析

Hash 是 Redis 中的一种数据基础数据结构,类似于数据结构中的哈希表,一个 Hash 可以存储 2 的 32 次方 - 1 个键值对(约 40 亿)。底层结构需要分成两个情况:

  • Redis 6 及之前,Hash 的底层是压缩列表加上哈希表的数据结构(ziplist + hashtable)
  • Redis 7 之后,Hash 的底层是紧凑列表(Listpack) 加上哈希表的数据结构(Listpack + hashtable)

ziplist 和 listpack 查找 key 的效率是类似的,时间复杂度都是 O(n),其主要区别就在于 listpack 解决了 ziplist 的级联更新问题。

那么 hash 在什么时候使用 ziplist 和 listpack,什么时候使用 Hashtable 呢?

Redis 内有两个值,分别是 hash-max-ziplist-entrieshash-max-listpack-entries)以及 hash-max-ziplist-valuehash-max-listpack-value),即 Hash 类型键的字段个数(默认512)以及每个字段名和字段值的长度(默认64)。

redis 7.0 为了兼容早期的版本,并没有把 ziplist 相关的值删掉。

Snipaste_2024-05-15_21-42-23.jpg

当 hash 小于这两个值的时候,会使用 listpack 或者 ziplist 进行存储。

大于这两个值的时候会使用 hashtable 进行存储。

这里需要注意一个点,在使用 hashtable 结构之后,就不会再退化成 ziplist 或 listpack,之后都是使用 hashtable 进行存储。

这里需要注意,这两个值是可以修改的。如下图所示,将其值修改为 4399 以及 2024 并且查询后效果如下图所示:

Snipaste_2024-05-15_21-41-14.jpg

聊聊 Redis 中的 Hashtable?

面试官可能会细问 Hashtable 相关的知识点。

Hashtable 就是哈希表实现,查询时间复杂度为 O(1),效率非常快。

看下 HashTable 的结构:

1
2
3
4
5
6
7
8
9
10
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;

dictht 一共有 4 个字段:

  • table:哈希表实现存储元素的结构,可以看成是哈希节点(dictEntry)组成的数组。
  • size:表示哈希表的大小。
  • sizemask:这个是指哈希表大小的掩码,它的值永远等于 size-1,这个属性和哈希值一起约定了哈希节点所处的哈希表的位置,索引的值 index = hash(哈希值) & sizemask。
  • used:表示已经使用的节点数量。
Snipaste_2024-05-15_22-27-32.jpg

我们再看下哈希节点(dictEntry)的组成,它主要由三个部分组成,分别为 key,value 以及指向下一个哈希节点的指针,其源码中结构体的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct dictEntry {
//键值对中的键
void *key;

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

结合以上图片,我们可以发现,哈希节点中的 value 值是由一个联合体组成的。因此,这个值可以是指向实际值的指针、64位无符号整数、64 为整数以及 double 的值。

这样设计的主要目的就是为了节省 Redis 的内存空间,当值是整数或者浮点数的时候,可以将值的数据存在哈希节点中,不需要使用一个指针指向实际的值,从一定程度上节省了空间。

渐进式 rehash

还需要了解另一个面试重点,Redis 的渐进式 rehash。

从字面意思上来说,就是一点点地扩容,而不是直接一次性完成扩容

我们再来看这个图:

wecom-temp-170726-8d24749bd759fb281def9afc0a25e3f2.png

dict 有两个 dictht 组成,为什么需要 2 个哈希表呢?主要原因就是为了实现渐进式。

在平时,插入数据的时候,所有的数据都会写入 ht[0] 即哈希表 1,ht [1] 哈希表 2 此时就是一张没有分配空间的空表。

但是随着数据越来越多,当 dict 的空间不够的时候,就会触发扩容条件,其扩容流程主要分为三步:

1)首先,为哈希表 2 即分配空间。新表的大小是第一个大于等于原表 2 倍 used 的 2 次方幂。举个例子,如果原表即哈希表 1 的值是 1024,那个其扩容之后的新表大小就是 2048。

分配好空间之后,此时 dict 就有了两个哈希表了,然后此时字典的 rehashidx 即 rehash 索引的值从 -1 暂时变成 0 ,然后便开始数据转移操作。

2)数据开始实现转移。每次对 hash 进行增删改查操作,都会将当前 rehashidx 的数据从在哈希表 1 迁移到 2 上,然后 rehashidx + 1,所以迁移的过程是分多次、渐进式地完成

注意:插入数据会直接插入到 2 表中。

3)随着操作不断执行,最终哈希表 1 的数据都会被迁移到 2 中,这时候进行指针对象进行互换,即哈希表 2 变成新的哈希表 1,而原先的哈希表 1 变成哈希表 2并且设置为空表,最后将 rehashidx 的值设置为 -1。

就这样,渐进式 rehash 的过程就完成了。

Hash 扩容以及缩容的条件

Redis 具体在什么时候会进行扩容和缩容呢?

我们先来说一下扩容,这里涉及到一个概念,即负载因子,redis 中 hash 的负载因子计算有一条公式:

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

Redis 会根据负载因子的情况决定是否采取扩容:

  1. 负载因子大于等于 1,这个时候说明空间非常紧张,新数据是在哈希节点的链表上找到的,这个时候如果服务器没有执行 RDB 快照或者 AOF 重写这两个持久化机制的时候,就会进行 rehash 操作。
  2. 当负载因子大于等于 5,这个时候说明哈希冲突非常严重了,这个时候无论有没有进行 AOF 重写或者 RDB 快照,都会强制执行rehash 操作。

缩容也和负载因子有关,当负载因子小于 0.1 的时候,就会进行缩容操作。这个时候新表大小是老表的 used 的最近的一个 2 次方幂。例如老表的 used = 1000,那么新表的大小就是 1024。如果没有执行 RDB 快照和 AOF 重写的时候就会进行缩容,反之不会进行。

Comments