Redis学习流程:对象、执行流程、持久化、场景、集群。本篇讲述第一部分:Redis的各种数据结构。

0.Why Redis?

相比与MySQL这种磁盘型的数据库,Redis是一款内存数据存储系统,所有数据采用键值对结构,以其卓越的性能和灵活性在现代应用架构中占据重要地位。它将数据直接存储在RAM中,提供毫秒级响应速度,同时支持多样化的数据结构(如后面要讲到的String、List、Hash、Set、ZSet等)。

在以前,Redis作为传统缓存层的首选技术充当请求与主数据库的缓冲。而现在,Redis不仅能够提供数据持久化保障,还支持JSON格式、高效搜索和对象映射功能,也能支持作为主数据库使用了。无论是自托管还是通过Redis Cloud服务使用,它都能为需要低延迟数据访问的应用场景(如缓存、会话管理、实时分析)提供理想解决方案,有效减轻主数据库负担并显著提升整体系统性能。

和之前语言的学习不同,对于Redis数据类型的讲解,我们都是应用方法+底层实现直接一锅端掉送走。在这里请注意仔细区分Redis数据类型和底层数据结构/编码的区别,如String和SDS的区别。

让我们先高屋建瓴地进行概述:

Redis 共有 5 种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。

这 5 种数据类型是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这 8 种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、Dict(哈希表/字典)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。

Redis 5 种基本数据类型对应的底层数据结构实现如下表所示:

String List Hash Set Zset
SDS LinkedList/ZipList/QuickList Dict、ZipList Dict、Intset ZipList、SkipList

Redis 3.2 之前,List 底层实现是 LinkedList 或者 ZipList。 Redis 3.2 之后,引入了 LinkedList 和 ZipList 的结合 QuickList,List 的底层实现变为 QuickList。从 Redis 7.0 开始, ZipList 被 ListPack 取代。

0.5 redisObject

Redis是key-value存储,key和value在Redis中都被抽象为对象,key只能是String对象,而Value支持丰富的对象种类,包括String、List、Set、Hash、Sorted Set、Stream等。

本部分的内容留个大概印象即可。

type:是哪种Redis对象

encoding:表示用哪种底层编码,用OBJECT ENCODING[key]可以看到对应的编码方式

lru:记录对象访问信息,用于内存淘汰,这个可以先忽略,后续章节会详细介绍。

refcount:引用计数,用来描述有多少个指针,指向该对象

ptr:内容指针,指向实际内容

1.String

String操作

String是Redis中最基本的数据类型,可以存储文本、整数或二进制数据。一个键最大能存储512MB的数据。

说到底,操作还是增删改查四个方面…

基本命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 创建
SET key value # 设置key对应的值为string类型的value
SETNX key value # 只有当key不存在时才设置value(SET if Not eXists)

# 查询
GET key # 获取key对应的值
MGET key1 [key2 ...] # 批量获取多个key的值

# 更新
SET key value # 设置key对应的值(如果已存在则覆盖)
INCR key # 将key对应的数字值加1
DECR key # 将key对应的数字值减1
INCRBY key increment # 将key对应的数字值增加increment
DECRBY key decrement # 将key对应的数字值减少decrement
APPEND key value # 将value追加到key对应值的末尾

# 删除
DEL key # 删除key
STRLEN key # 返回key对应值的长度

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 创建操作
127.0.0.1:6379> SET username "redis" # 常规创建
OK
127.0.0.1:6379> SETNX newkey "value" # 不存在才创建
(integer) 1
127.0.0.1:6379> SETNX username "test" # 已存在,不会设置
(integer) 0

# 查询操作
127.0.0.1:6379> GET username
"redis"
127.0.0.1:6379> MGET username newkey # 批量获取
1) "redis"
2) "value"

# 更新操作
127.0.0.1:6379> SET counter 100
OK
127.0.0.1:6379> INCR counter
(integer) 101
127.0.0.1:6379> INCRBY counter 50
(integer) 151
127.0.0.1:6379> APPEND username "_user"
(integer) 10
127.0.0.1:6379> GET username
"redis_user"

# 删除操作
127.0.0.1:6379> DEL newkey
(integer) 1
127.0.0.1:6379> GET newkey
(nil)

底层实现

String结构看起来很简单,但是实际上有三种编码方式:

  • INT编码:这个很好理解,就是存一个整型,可以用long表示的整数就以这种编码存储;

  • EMBSTR编码:如果字符串小于等于阈值字节,使用EMBSTR编码;

  • RAW编码:字符串大于阈值字节,则用RAW编码。

这里的阈值字节在Redis3.2之前的版本是39,3.2以及之后的版本是44,原因这里不做探讨。

EMBSTR和RAW都是由redisObject和SDS两个结构组成,它们的差异在于,EMBSTR下redisObject和SDS是连续的内存,RAW编码下redisObject和SDS的内存是分开的。

EMBSTR优点是redisObject和SDS两个结构可以一次性分配空间,缺点在于如果重新分配空间,整体都需要再分配,所以EMBSTR设计为只读任何写操作之后EMBSTR都会变成RAW,理念是发生过修改的字符串通常会认为是易变的。

那么EMBSTR和RAW内存的结构又是什么呢?请看:

EMBSTR内存结构:

RAW内存结构:

我们注意到,字符串编码EMBSTR和RAW都包含一个结构叫SDS,它是Simple Dynamic String的缩写,即简单动态字符串,这是Redis内部作为基石的字符串封装,十分重要,下面我们详细介绍下SDS。

为什么需要SDS?

在C语言中,字符串用一个”0”结尾的char数组表示,比如”hello 0kr” 即”hello 0kr\0”。然而

  1. 每次计算字符串长度的复杂度为O(N);
  2. 对字符串进行追加,需要重新分配内存;
  3. 非二进制安全(这里就是指在字符串中出现\0的情况)。

在Redis内部,字符串的追加和长度计算很常见,这两个简单的操作不应该成为性能的瓶颈,于是Redis封装了一个叫SDS的字符串结构,用来解决上述问题。下面我们来看看它是怎样的结构。

首先,Redis中SDS分为sdshdr8、sdshdr16、sdshdr32、sdshdr64,它们的字段属性都是一样,区别在于应对不同大小的字符串以节省内存开销。我们以sdshdr8为例:

1
2
3
4
5
6
7
// from Redis 7.0.8
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[]; /* 实际的字符数组 */
};
len (已使用长度, 1字节) alloc (分配长度, 1字节) flags (类型标志, 1字节) buf[] (字符数据) Redis 7.0.8 SDS结构 * packed 属性确保结构体紧凑存储,没有对齐填充 * flags中3个低位表示SDS类型,5位未使用

此结构中:

  • len:表示已使用的字节数
  • alloc:表示除头部和结束符外分配的字节数
  • flags:SDS类型标志,低3位表示SDS类型(如SDS_TYPE_8),在代码中就是:
1
#define SDS_TYPE_8 1
  • buf[]:存储实际字符数据的数组(二进制存储,这使得Redis字符串可以处理任何类型的数据,而不仅限于文本)

于是我们能很清晰地看出SDS是如何解决上面三个问题的:

  1. 增加长度字段len,快速返回长度;
  2. 增加空余空间(alloc-len),为后续追加数据留余地;
  3. 不再以’\0’作为判断标准,二进制安全。

2.List

List是一个按照插入顺序排序的字符串链表。你可以添加元素到头部(左边)或尾部(右边)。一个列表最多可包含2^32 - 1个元素(超过40亿)。

List操作

基本命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 创建
LPUSH key value [value ...] # 将一个或多个值插入到列表头部
RPUSH key value [value ...] # 将一个或多个值插入到列表尾部

# 查询
LLEN key # 获取列表长度
LRANGE key start stop # 获取列表指定范围内的元素
LINDEX key index # 通过索引获取列表中的元素

# 更新
LPUSH key value [value ...] # 从列表头部添加元素
RPUSH key value [value ...] # 从列表尾部添加元素
LPOP key # 移出并获取列表的第一个元素
RPOP key # 移出并获取列表的最后一个元素
LREM key count value # 移除列表中与value相等的元素

# 删除
DEL key # 删除整个列表
UNLINK key # 非阻塞删除,仅将键从keyspace元数据中删除

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 创建操作
127.0.0.1:6379> LPUSH mylist "world" # 从左侧插入
(integer) 1
127.0.0.1:6379> LPUSH mylist "hello"
(integer) 2
127.0.0.1:6379> RPUSH mylist "redis" # 从右侧插入
(integer) 3

# 查询操作
127.0.0.1:6379> LLEN mylist # 获取长度
(integer) 3
127.0.0.1:6379> LRANGE mylist 0 -1 # 获取全部元素
1) "hello"
2) "world"
3) "redis"

# 更新操作
127.0.0.1:6379> LPOP mylist # 从左侧弹出
"hello"
127.0.0.1:6379> RPOP mylist # 从右侧弹出
"redis"
127.0.0.1:6379> LRANGE mylist 0 -1
1) "world"
127.0.0.1:6379> LPUSH mylist "new"
(integer) 2
127.0.0.1:6379> LREM mylist 1 "world" # 删除指定元素
(integer) 1

# 删除操作
127.0.0.1:6379> DEL mylist # 删除整个列表
(integer) 1
127.0.0.1:6379> LPUSH mylist "test"
(integer) 1
127.0.0.1:6379> UNLINK mylist # 非阻塞删除
(integer) 1

这里需要解释一下DEL和UNLINK指令的区别:

del 命令同步删除命令,会阻塞客户端,直到删除完成。 unlink 命令是异步删除命令,只是取消Key在键空间的关联,让其不再能查到,删除是异步进行,所以不会阻塞客户端(相当于逻辑删除)。

底层实现

Redis的List在早期版本(3.2之前)使用两种数据结构:ziplist和linkedlist。在Redis 3.2之后,引入了quicklist作为List的统一实现:

  1. ziplist(压缩列表):当列表元素较少且元素值较小时使用
  2. linkedlist(双向链表):当列表元素多或元素值较大时使用
  3. quicklist(快速列表):结合了ziplist和linkedlist的优点,将linkedlist按段切分,每一段使用ziplist实现,相当于一个由ziplist组成的linkedlist

让我们分别介绍一下:

ZIPLIST

当满足如下条件时,用ZIPLIST编码:

  1. 列表对象保存的所有字符串对象长度都小于64字节;
  2. 列表对象元素个数少于512个,注意,这是LIST的限制,而不是ZIPLIST的限制。

ZIPLIST底层用压缩列表实现,这里我们假设列表中包含”hello”、”niuniu”、”mar”三个元素,ZIPLIST编码示意如下:

  • zlbytes:表示该ZIPLIST一共占了多少字节数,这个数字是包含zlbytes本身占据的字节的。
  • zitail: ZIPLIST 尾巴节点 相对于 ZIPLIST 的开头(起始指针)偏移的字节数。通过这个字段可以快速定位到尾部节点,例如现在有一个 ZIPLIST,z| 指向它的开头,如果要获取 tail 尾巴节点,即 ZIPLIST 里的最后一个节点可以 zl + zltail的值,这样定位到它。如果没有尾节点,就定位到 zlend。
  • zllen:表示有多少个数据节点,在本例中就有3个节点。
  • entry1~entry3:表示压缩列表数据节点。
  • zlend:一个特殊的entry节点,表示ZIPLIST的结束。

这里的每一个entry又是由下面三个部分组成:

1
<prevlen> <encoding> <entry-data>

同样的,我们来看看,各字段的含义:

  • prevlen:表示上一个节点的数据长度。通过这个字段可以定位上一个节点的起始地址——换言之,prevlen 可以跳到前一个节点的开头位置,实现从后往前操作,所以压缩列表才可以从后往前遍历。
  • encoding:编码类型。编码类型里还包含了一个entry的长度信息,可用于正向遍历。
  • entry-data:实际的数据。

如果前一节点的长度,也就是前一个ENTRY的大小,小于254字节,那么prevlen属性需要用1字节长的空间来保存这个长度值,(255是特殊字符,被zlend使用了.)

如果前一节点的长度大于等于254字节,那么prevlen属性需要用5字节长的空间来保存这个长度值,注意5个字节中的第一个字节为11111110,也就是254,标志这是个5字节的prevlen信息,剩下4字节来表示大小。

连锁更新问题

连锁更新(cascading update)问题发生在以下场景:

ZIPLIST使用可变长度编码来存储元素长度,长度编码可能占用1或5个字节。当一个元素的长度发生变化,可能导致其长度编码字节数也发生变化。

当长度编码字节数增加时,会导致后续所有元素位置发生偏移,这种偏移可能导致其他元素的长度编码也需要调整。在最坏情况下,一个元素的变化会引发整个ZIPLIST的所有元素需要重新定位。这就是连锁更新的问题。

为了解决这个问题,我们需要一种不记录prevlen,并且还能找到上一个节点的起始位置的办法,Redis使用了很巧妙的一种方式:LISTPACK。

LISTPACK

LISTPACK的内存布局大致如下:

1
<total-bytes><num-elements><element-1>...<element-N><end-mark>

每个元素的格式为:

1
<encoding-type> <element-data> <element-tot-len>

其中:

  1. encoding-type: 表示数据的编码类型(如整数、字符串等)
  2. element-data: 实际存储的数据内容
  3. element-tot-len: 记录整个节点的总长度(不包括自身)

在LISTPACK中,向后遍历很简单,因为我们知道每个节点的长度,可以直接跳到下一个节点。但向前遍历就有挑战性,因为我们需要知道前一个节点的起始位置。

Redis采用了一个巧妙的设计:在element-tot-len字段中,每个字节的最高位(第一个bit)用作标记位:

  • 0表示这是结束字节

  • 1表示还有后续字节

  • 当我们需要向前遍历时,可以按照以下步骤:

    1. 从当前位置向前查找,检查每个字节的最高位
    2. 找到最高位为0的字节,这标志着前一个节点的element-tot-len字段的结束
    3. 基于element-tot-len的值,计算出前一个节点的起始位置

举例

假设某个节点的element-tot-len值为132(二进制:10000100),需要两个字节存储:

  • 第一个字节:00000001(最高位为0,表示这是结束字节)
  • 第二个字节:10000100(最高位为1,表示这是继续字节)

向前遍历时:

  1. 从当前位置向前扫描,查找最高位为0的字节(00000001)
  2. 读取完整的长度值:00000001 10000100 = 132
  3. 从当前位置减去132,就得到了前一个节点的起始位置

通过这样的设计,当一个节点的大小变化时,只需修改该节点本身。后续节点不存储关于前一个节点的长度信息,因此不会因为前一个节点的变化而需要更新。

总结

本质区别在于信息依赖关系:

  • ZIPLIST:节点A存储节点B的长度信息 → 节点B变化 → 节点A需要更新
  • LISTPACK:节点只存储自身信息 → 节点变化 → 只修改自身,不影响其他节点

即使LISTPACK需要从后向前遍历(通过检查字节的最高位来识别长度字段的结束),这种设计也不会导致连锁更新问题,因为每个节点的变化仍然是局部的、独立的。

LINKEDLIST

回到正文。如果不满足ZIPLIST编码的条件,则使用LINKEDLIST编码。为了便于描述,我们还是假设列表含”hello”、”niuniu”、”mart”三个元素,如果用LINKEDLIST编码,则是几个String对象的链接结构,结构示意图如下:

可以看到,”hello”、”niuniu”、”mart”这几个数据是以链表的形式连接在一起,实际上删除更为灵活,但是内存不如ZIPLIST紧凑,所以只有在列表个数或节点数据长度比较大的时候,才会使用LINKEDLIST编码,以加快处理性能,一定程度上牺牲了内存。

QUICKLIST

上面的分析有说到,ZIPLIST是为了在数据较少时节约内存,LINKEDLIST是为了数据多时提高更新效率,ZIPLIST数据稍多时插入数据会导致很多内存复制。

但如果节点非常多的情况,LINKEDLIST链表的节点就很多,会占用不少的内存。

为了解决上面的问题引入了QUICKLIST。

这种方案其实是用ZIPLIST、LINKEDLIST综合的结构,取代二者本身。

  • 当数据较少的时候,QUICKLIST的节点就只有一个,此时其实相当于就是一个ZIPLIST;
  • 当数据很多的时候,则同时利用了ZIPLIST和LINKEDLIST的优势。

3.Set

Set是一个无序且唯一的字符串集合。可以进行交集、并集、差集等集合运算。

Set操作

基本命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建
SADD key member [member ...] # 向集合添加一个或多个成员

# 查询
SISMEMBER key member # 判断member是否是集合key的成员
SCARD key # 获取集合中的成员数
SMEMBERS key # 返回集合中的所有成员
SSCAN key cursor [MATCH pattern] [COUNT count] # 迭代集合中的元素
SINTER key [key ...] # 返回给定所有集合的交集
SUNION key [key ...] # 返回给定所有集合的并集
SDIFF key [key ...] # 返回给定所有集合的差集

# 更新
SADD key member [member ...] # 向集合添加一个或多个成员
SREM key member [member ...] # 移除集合中一个或多个成员

# 删除
DEL key # 删除整个集合

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 创建操作
127.0.0.1:6379> SADD fruits "apple" # 创建集合并添加元素
(integer) 1
127.0.0.1:6379> SADD fruits "banana" "cherry" # 添加多个元素
(integer) 2

# 查询操作
127.0.0.1:6379> SISMEMBER fruits "apple" # 检查元素是否存在
(integer) 1
127.0.0.1:6379> SCARD fruits # 获取集合大小
(integer) 3
127.0.0.1:6379> SMEMBERS fruits # 获取所有成员
1) "apple"
2) "banana"
3) "cherry"
127.0.0.1:6379> SADD vegetables "carrot" "celery"
(integer) 2
127.0.0.1:6379> SUNION fruits vegetables # 集合并集操作
1) "apple"
2) "banana"
3) "cherry"
4) "carrot"
5) "celery"
127.0.0.1:6379> SINTER fruits vegetables # 集合交集操作
(empty list or set)
127.0.0.1:6379> SDIFF fruits vegetables # 集合差集操作
1) "apple"
2) "banana"
3) "cherry"

# 更新操作
127.0.0.1:6379> SADD fruits "grape" # 添加新元素
(integer) 1
127.0.0.1:6379> SREM fruits "banana" # 删除指定元素
(integer) 1

# 删除操作
127.0.0.1:6379> DEL vegetables # 删除整个集合
(integer) 1

底层实现

Redis的Set在底层使用两种数据结构实现:

  1. intset(整数集合):当集合中的元素都是整数且元素数量较少时使用
  2. hashtable(哈希表):当集合中包含非整数元素或元素数量较多时使用

如果集合元素都是整数,且元素数量不超过512个,就可以用INTSET编码,结构如下图,可以看到INTSET排列比较紧凑,内存占用少,但是查询时需要二分查找。

如果不满足INTSET的条件,就需要用HASHTABLE,HASHTABLE结构如下图,可以看到HASHTABLE查询一个元素的性能很高,能O(1)时间就能找到一个元素是否存在。

4.Hash

Redis Hash是一个field、value都为string的hash表,类似于编程语言中的Map或Dictionary类型。

每个hash可以存储2^32 - 1个键值对。

Hash操作

基本命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 创建
HSET key field value [field value ...] # 为哈希表key设置一个或多个字段的值
HSETNX key field value # 只在字段不存在时,才设置哈希表字段的值

# 查询
HGET key field # 获取哈希表key中字段field的值
HGETALL key # 获取哈希表key中所有的字段和值
HLEN key # 获取哈希表key中字段的数量
HSCAN key cursor [MATCH pattern] [COUNT count] # 迭代哈希表中的键值对

# 更新
HSET key field value # 设置哈希表字段的值
HSETNX key field value # 只在字段不存在时,设置哈希表字段的值
HDEL key field [field ...] # 删除哈希表key中的一个或多个字段

# 删除
DEL key # 删除整个哈希表

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 创建操作
127.0.0.1:6379> HSET user:1 name "Tom" age 25 # 创建哈希表并设置多个字段
(integer) 2
127.0.0.1:6379> HSETNX user:1 email "tom@example.com" # 设置不存在的字段
(integer) 1
127.0.0.1:6379> HSETNX user:1 name "Thomas" # 字段已存在,设置失败
(integer) 0

# 查询操作
127.0.0.1:6379> HGET user:1 name # 获取单个字段值
"Tom"
127.0.0.1:6379> HGETALL user:1 # 获取所有字段和值
1) "name"
2) "Tom"
3) "age"
4) "25"
5) "email"
6) "tom@example.com"
127.0.0.1:6379> HLEN user:1 # 获取字段数量
(integer) 3

# 更新操作
127.0.0.1:6379> HSET user:1 age 26 # 更新已有字段
(integer) 0
127.0.0.1:6379> HDEL user:1 email # 删除某个字段
(integer) 1

# 删除操作
127.0.0.1:6379> DEL user:1 # 删除整个哈希表
(integer) 1

底层实现

Redis的Hash类型底层实现使用了两种数据结构:

  1. ziplist(压缩列表):当哈希表元素较少且元素值较小时使用(当然,新版本已经替换成LISTPACK)
  2. hashtable(哈希表):当哈希表元素较多或元素值较大时使用

老样子,同时满足以下两个条件,用压缩列表:

  1. Hash对象保存的所有值和键的长度都小于64字节,
  2. Hash对象元素个数少于512个。

两个条件任何一条不满足,编码结构就用HASHTABLE。在之前无序集合Set中也有应用,和Set的区别在于,在Set中value始终为NULL,但是在Hash中,是有对应的值的。

由于HASHTABLE是面试的超级大热门,所以我们在这里也深究一下其中的原理。

HASHTABLE

Redis中HASHTABLE的结构如下:

1
2
3
4
5
6
7
8
// from Redis 5.0.5
/* This is our hash table structure. */
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;

最外层是一个封装的dictht结构,其中字段含义如下:

  • table:指向实际hash存储。
  • size:哈希表大小。实际就是dictEntry有多少元素空间。
  • sizemask:哈希表大小的掩码表示,总是等于size-1。这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面,规则Index=hash&sizemask
  • used:表示已有节点数量。

所以说查询HASHTABLE节点总数的时间复杂度是O(1),因为表头是有记录的。

接下来的重点是HASHTABLE的扩容、缩容和发生的时机。

扩容时机

  1. 负载因子过高:当哈希表的负载因子(used/size)超过1时,意味着平均每个bucket已经有至少一个元素,此时会触发扩容。通常情况下,当负载因子大于等于1时,会将哈希表的大小扩大为原来的两倍。
  2. 强制扩容:在没有开启自动rehash的情况下(如载入大量数据时),如果负载因子超过5,则强制执行rehash。

缩容时机

当负载因子小于0.1时,即哈希表中的元素数量不足哈希表大小的10%,会触发缩容操作,将哈希表大小缩小为当前使用量的一倍大小(used*2),以节省内存空间。

渐进式rehash过程

Redis的渐进式rehash分为以下几个步骤:

  1. 分配新哈希表:为字典的ht[1]分配空间,大小由扩容或缩容决定。
  2. 维护rehash标志:设置rehashidx为0,表示rehash工作正在进行。
  3. 渐进迁移:rehash不是一次性完成的,而是分多次、渐进式完成的:
    • 在对字典执行添加、删除、查找或更新操作时,顺便将ht[0]中rehashidx索引上的所有键值对rehash到ht[1]
    • 每完成一次rehash,rehashidx值+1
    • 系统会在定时任务中执行rehash,每次最多执行1毫秒
  4. 完成rehash:当ht[0]的所有键值对都迁移到ht[1]后,将rehashidx设置为-1,并将ht[1]设置为ht[0],ht[1]重新初始化为空表

渐进式rehash的好处

  1. 避免阻塞:由于Redis是单线程模型,一次性rehash可能导致服务暂停响应。渐进式rehash将这个过程分散到多次操作中,减少了单次操作的时间消耗。
  2. 平滑性能:将rehash操作分摊到平时的各个增删改查操作中,使得服务器性能更加平稳,避免了突然的性能抖动。

在渐进式rehash过程中,字典的删除、查找、更新等操作会在两个哈希表上进行,而新增操作只会添加到ht[1]。这种设计保证了在rehash过程中服务依然可以正常运行,用户几乎感知不到rehash过程的存在。

5.ZSet

ZSet(Sorted Set,有序集合)是一个有序且唯一的字符串集合。与Set不同,ZSet中的每个成员都关联一个分数(score),Redis通过分数来为集合中的成员进行从小到大的排序。

ZSet操作

基本命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 创建
ZADD key score member [score member ...] # 向有序集合添加一个或多个成员,或更新已存在成员的分数

# 查询
ZRANGE key start stop [WITHSCORES] # 按照索引范围返回有序集合的成员
ZCOUNT key min max # 计算有序集合中指定分数区间的成员数
ZRANK key member # 返回有序集合中成员的排名(从低到高)
ZSCORE key member # 返回有序集中成员的分数值
ZCARD key # 获取有序集合的成员数

# 更新
ZADD key score member [score member ...] # 更新已有成员的分数值
ZREM key member [member ...] # 移除有序集合中的一个或多个成员

# 删除
DEL key # 删除整个有序集合
UNLINK key # 非阻塞删除,仅将键从keyspace元数据中删除

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 创建操作
127.0.0.1:6379> ZADD leaderboard 100 "user1" 80 "user2" 120 "user3" # 创建有序集合
(integer) 3

# 查询操作
127.0.0.1:6379> ZRANGE leaderboard 0 -1 WITHSCORES # 获取所有成员及分数(升序)
1) "user2"
2) "80"
3) "user1"
4) "100"
5) "user3"
6) "120"
127.0.0.1:6379> ZRANK leaderboard "user1" # 获取成员排名
(integer) 1
127.0.0.1:6379> ZSCORE leaderboard "user3" # 获取成员分数
"120"
127.0.0.1:6379> ZCOUNT leaderboard 90 150 # 统计分数范围内的成员数
(integer) 2
127.0.0.1:6379> ZCARD leaderboard # 获取成员总数
(integer) 3

# 更新操作
127.0.0.1:6379> ZADD leaderboard 200 "user1" # 更新成员分数
(integer) 0
127.0.0.1:6379> ZREM leaderboard "user2" # 移除成员
(integer) 1

# 删除操作
127.0.0.1:6379> DEL leaderboard # 删除整个有序集合
(integer) 1
127.0.0.1:6379> ZADD leaderboard 50 "user4" # 新建有序集合
(integer) 1
127.0.0.1:6379> UNLINK leaderboard # 非阻塞删除
(integer) 1

底层实现

Redis的ZSet是一个复合结构,底层同时使用了两种数据结构:

  1. ziplist(压缩列表):当有序集合的元素数量较少且元素值较小时使用
  2. skiplist(跳表)+ hashtable(哈希表):当有序集合的元素数量较多或元素值较大时使用

ziplist不必多说,之前List、Hash中都有打过交道,同样在,在ZSet中ZIPLIST也是用于数据量比较小时候的内存节省,结构如下:

如果满足如下规则,ZSet就用ZIPLIST编码:

  1. 列表对象保存的所有字符串对象长度都小于64字节;
  2. 列表对象元素个数少于128个。

两个条件任何一条不满足,编码机构就用SKIPLIST+HASHTABLE,其中SKIPLIST也是底层数据结构,是本节才出现的新伙伴。

SKIPLIST是一种可以快速查找的多级链表结构,通过SKIPLIST可以快速定位到数据所在。它的排名操作、范围查询性能都很高,在前面的章节,我们已经介绍过SKIPLIST,如果有所遗忘的同学,可以回过头去看一下。

除了SKIPLIST,Redis还使用了HASHTABLE来配合查询,这样可以在O(1)时间复杂度查到成员的分数值。

所以你知道的,接下来我们必须详细地介绍一下跳表。

跳表

跳表是什么

跳表本质上还是链表,普通链表结构如下:

这种结构虽然简单清晰,但是查询某个节点的效率比较低,而在有序集合场景,无论是查找还是添加删除元素,我们是需要能快速通过score定位到具体位置,如果用链表那时间复杂度其实就是O(N),N是节点个数。

为了提升查找的性能,Redis就引入了跳表,跳表在链表的基础上,给链表增加了多级的索引,通过索引可以一次实现多个节点的跳跃,提高性能。

跳表的结构

我们分几个场景来分析。

  • 场景一:查找分数为45的数据

如果只有原始的链表,那需要走4步,如果有图中的二级索引,只用走2步;

其实就是从第1个节点出发,通过二级索引走到35,再查看到下一个节点是65,已经超过了,所以降低到下方的索引,也就是一级索引,往后走一次就可以找到45。

  • 场景二:插入一条score为36的数据

首先,定位到第一个比score大的位置,这里是45,定位方式和查询类似,不再赘述。

然后,构造一个新的节点(这里我们假设节点层高随机到3,具体随机算法我们后面会介绍,目前不用太关注)。

最后,将各层链表补齐,其实就是在每一层进行链接,效果如图:

Redis跳表实现与源码解析

标准的跳表中,score分数值是不可以重复的,并且只有向前指针没有回退指针。Redis对以上两点做了改进,这使得其能够很方便地完成一些业务上的功能,比如排行榜中相同分数的用户以及倒序遍历等等。

这是Redis 跳表单个节点的定义:

1
2
3
4
5
6
7
8
9
10
11
// from Redis 7.0.8
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;

我们来看看字段的含义:

  • ele:很熟悉的SDS结构,用来存储数据。
  • score:节点的分数,浮点型数据
  • backward:指向上一个节点的回退指针,支持从表尾向表头遍历,也就是ZREVRANGE这个命令
  • level:是个zskiplistLevel结构体数组,zskiplistLevel这个结构体包含了两个字段,一个是forward,指向该层下个能跳到的节点,span记录了距离下个节点的步数,数组结构就表示每个节点都可能是个多层结构。

结合这张图能够更好地理解:

总而言之,就是从高级索引往后查找,如果下个节点的数值比目标节点小,则继续找,否则不跳过去,而是用下级索引往下找。

还有几个关键的问题:

Redis跳表单个节点有几层?

层次的决定,需要比较随机,才能在各个场景表现出较为平均的性能,这里Redis使用概率均衡的思路来确定新插入节点的层数:

Redis 跳表决定每一个节点,是否能增加一层的概率为25%,而最大层数限制在Redis 5.0是64层,在Redis 7.0是32层。

Redis跳表的性能优化了多少?

这个其实可以很容易想到,跳表的查找过程,其实是走高层,行得通跳过去,行不通走相对下层,很像二叉树的另种表现形式,实际上他们性能也是差不多的,平均时间复杂度都是O(logn),区别是二叉树最坏情况下也是O(logn)比较稳定,而跳表的最坏时间复杂度是O(N)。当然,实际的生产过程中,体现出来的基本都是跳表的平均时间复杂度。

前面也提到,有序集合无论是査找、还是增加删除元素,都是需要先定位到数据位置,所以跳表将这三个操作的时间复杂度,都从O(N)降低到了Iog(N)。

个人理解:ZSCORE这种单值查询可以用HASHTABLE,ZRANK这种范围查询可以用跳表。

总结

Redis五种基本数据类型各有其特点和适用场景:

  • String:适用于缓存简单数据、计数器、分布式锁等
  • List:适用于消息队列、最新消息列表等
  • Set:适用于去重、交集运算(共同好友)等
  • Hash:适用于存储对象、用户信息等
  • ZSet:适用于排行榜、优先级队列等

理解这些数据类型的底层实现有助于我们更高效地使用Redis,根据实际场景选择最合适的数据结构。Redis的优秀性能很大程度上得益于其精心设计的数据结构和算法。