Redis数据类型与数据结构

8. 对象的类型与编码

redis将键和值都封装成了了对象 redisObject,对象中有当前存储的数据(键或者值)的类型,编码,以及存储他们用到的数据结构的指针。比如一个字符串键,redis会创建两个对象,一个是用作字符串键的键,另一个对象是用作字符串键的值。(仔细读这一段话)。

举个例子, 以下 SET 命令在数据库中创建了一个新的键值对, 其中键值对的键是一个包含了字符串值 "msg" 的对象, 而键值对的值则是一个包含了字符串值 "hello world" 的对象:

redis> SET msg "hello world"
OK

看redis的对象的结构,该结构中有三个重要的属性和保存数据相关,分别是type,encoding,和ptr属性。

typedef struct redisObject {

    // 类型
    unsigned type:4;
    
    // 编码
    unsigned encoding:4;
    
    // 指向底层实现数据结构的指针
    void *ptr;

    // ...

} robj;

下面深度解析一下这三个属性:

8.1 type 类型

对象的type属性记录的是当前对象的类型,可能是下面表中的其中一个。
在这里插入图片描述
这些类型,正是对应了redis的五种数据类型,对于redis保存的键值对来讲,键总是一个字符串对象,而值则可能是字符串对象,列表对象等其中的任意一个。

  • 当我们称呼一个数据库键为 “字符串键”时,我们指的是“这个数据键锁对应的值是字符串对象”
  • 当称呼一个键为 “列表键”时,我们值的是“这个数据库键所对应的值为列表对象”

TYPE 命令的实现方式也与此类似, 当我们对一个数据库键执行 TYPE 命令时, 命令返回的结果为数据库键对应的值对象的类型, 而不是键对象的类型:

# 键为字符串对象,值为字符串对象

redis> SET msg "hello world"
OK

redis> TYPE msg
string


# 键为字符串对象,值为列表对象

redis> RPUSH numbers 1 3 5
(integer) 6

redis> TYPE numbers
list


# 键为字符串对象,值为哈希对象

redis> HMSET profile name Tome age 25 career Programmer
OK

redis> TYPE profile
hash


# 键为字符串对象,值为集合对象

redis> SADD fruits apple banana cherry
(integer) 3

redis> TYPE fruits
set


# 键为字符串对象,值为有序集合对象

redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3

redis> TYPE price
zset

表 8-2 列出了 TYPE 命令在面对不同类型的值对象时所产生的输出。
在这里插入图片描述

8.2 encoding 编码和底层实现

encoding代表了存储当前值所使用的数据结构。encoding翻译是编码,就是类似于传统编码,数据是二进制文件,看用何种编码方式打开,redis对应的编码方式就是实现他们的数据结构。这个属性的值可以是表 8-3 列出的常量的其中一个。
在这里插入图片描述

每种类型的对象都至少使用了两种不同的编码, 表 8-4 列出了每种类型的对象可以使用的编码。
在这里插入图片描述
里面的设计的数据结构,大都讲过,需要理解每个数据结构的存储方式。

使用 OBJECT ENCODING 命令可以查看一个数据库键的值对象的编码:

redis> SET msg "hello wrold"
OK

redis> OBJECT ENCODING msg
"embstr"

redis> SET story "long long long long long long ago ..."
OK

redis> OBJECT ENCODING story
"raw"

redis> SADD numbers 1 3 5
(integer) 3

redis> OBJECT ENCODING numbers
"intset"

redis> SADD numbers "seven"
(integer) 1

redis> OBJECT ENCODING numbers
"hashtable"

表 8-5 列出了不同编码的对象所对应的 OBJECT ENCODING 命令输出。
在这里插入图片描述
通过 encoding 属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提升了 Redis 的灵活性和效率, 因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率。

举个例子, 在列表对象包含的元素比较少时, Redis 使用压缩列表作为列表对象的底层实现:

  • 因为压缩列表比双端链表更节约内存, 并且在元素数量较少时, 在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中;
  • 随着列表对象包含的元素越来越多, 使用压缩列表来保存元素的优势逐渐消失时, 对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面;

9. 字符串对象

字符串对象的编码是 intraw 或者 embstr

如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long 类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换成 long ), 并将字符串对象的编码设置为 int

举个例子, 如果我们执行以下 SET 命令, 那么服务器将创建一个如图 8-1 所示的 int 编码的字符串对象作为 number 键的值:

redis> SET number 10086
OK

redis> OBJECT ENCODING number
"int"

在这里插入图片描述
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39 字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw

举个例子, 如果我们执行以下命令, 那么服务器将创建一个如图 8-2 所示的 raw 编码的字符串对象作为 story 键的值:

redis> SET story "Long, long, long ago there lived a king ..." #大于39个字符
OK

redis> STRLEN story
(integer) 43

redis> OBJECT ENCODING story
"raw"

在这里插入图片描述
从上面的案例分析存入的值和redis底层数据结构的关系:

redis为了灵活操作,定义了多种的数据结构去存入值,(key只能用sds数据结构存储,因为是String类型),对于String数据类型,值对象使用raw的编码去存,对应的数据结构就是SDS(简单动态字符串),用ptr数据指针指向这个数据结构,在这里面去获取或者操作数据。更加详细的分析可以看list或者hash

embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码一样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象, 但 raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构, 而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObjectsdshdr 两个结构, 如图 8-3 所示。

embstr 编码的字符串对象在执行命令时, 产生的效果和 raw 编码的字符串对象执行命令时产生的效果是相同的, 但使用 embstr 编码的字符串对象来保存短字符串值有以下好处:

  1. embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次。
  2. 释放 embstr 编码的字符串对象只需要调用一次内存释放函数, 而释放 raw 编码的字符串对象需要调用两次内存释放函数。
  3. 因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起 raw 编码的字符串对象能够更好地利用缓存带来的优势。

作为例子, 以下命令创建了一个 embstr 编码的字符串对象作为 msg 键的值, 值对象的样子如图 8-4 所示:

redis> SET msg "hello"
OK

redis> OBJECT ENCODING msg
"embstr"

在这里插入图片描述

最后要说的是, 可以用 long double 类型表示的浮点数在 Redis 中也是作为字符串值来保存的: 如果我们要保存一个浮点数到字符串对象里面, 那么程序会先将这个浮点数转换成字符串值, 然后再保存起转换所得的字符串值。

举个例子, 执行以下代码将创建一个包含 3.14 的字符串表示 "3.14" 的字符串对象:

redis> SET pi 3.14
OK

redis> OBJECT ENCODING pi
"embstr"

在有需要的时候, 程序会将保存在字符串对象里面的字符串值转换回浮点数值, 执行某些操作, 然后再将执行操作所得的浮点数值转换回字符串值, 并继续保存在字符串对象里面。

举个例子, 如果我们执行以下代码的话:

redis> INCRBYFLOAT pi 2.0
"5.14"

redis> OBJECT ENCODING pi
"embstr"

那么程序首先会取出字符串对象里面保存的字符串值 "3.14" , 将它转换回浮点数值 3.14 , 然后把 3.142.0 相加得出的值 5.14 转换成字符串 "5.14" , 并将这个 "5.14" 保存到字符串对象里面。

表 8-6 总结并列出了字符串对象保存各种不同类型的值所使用的编码方式。
在这里插入图片描述

9.2字符串命令的实现

因为字符串键的值为字符串对象, 所以用于字符串键的所有命令都是针对字符串对象来构建的, 表 8-7 列举了其中一部分字符串命令, 以及这些命令在不同编码的字符串对象下的实现方法。

命令int 编码的实现方法embstr 编码的实现方法raw 编码的实现方法
SET使用 int 编码保存值。使用 embstr 编码保存值。使用 raw 编码保存值。
GET拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后向客户端返回这个字符串值。直接向客户端返回字符串值。直接向客户端返回字符串值。
APPEND将对象转换成 raw 编码, 然后按 raw 编码的方式执行此操作。将对象转换成 raw 编码, 然后按 raw 编码的方式执行此操作。调用 sdscatlen 函数, 将给定字符串追加到现有字符串的末尾。
INCRBYFLOAT取出整数值并将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。取出字符串值并尝试将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。取出字符串值并尝试将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。
INCRBY对整数值进行加法计算, 得出的计算结果会作为整数被保存起来。embstr 编码不能执行此命令, 向客户端返回一个错误。raw 编码不能执行此命令, 向客户端返回一个错误。
DECRBY对整数值进行减法计算, 得出的计算结果会作为整数被保存起来。embstr 编码不能执行此命令, 向客户端返回一个错误。raw 编码不能执行此命令, 向客户端返回一个错误。
STRLEN拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 计算并返回这个字符串值的长度。调用 sdslen 函数, 返回字符串的长度。调用 sdslen 函数, 返回字符串的长度。
SETRANGE将对象转换成 raw 编码, 然后按 raw 编码的方式执行此命令。将对象转换成 raw 编码, 然后按 raw 编码的方式执行此命令。将字符串特定索引上的值设置为给定的字符。
GETRANGE拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后取出并返回字符串指定索引上的字符。直接取出并返回字符串指定索引上的字符。直接取出并返回字符串指定索引上的字符。

http://redisbook.com/preview/object/string.html

10. 列表对象

列表对象的编码可以是 ziplist 或者 linkedlist

ziplist 编码的列表对象使用压缩列表作为底层实现, 每个压缩列表节点(entry)保存了一个列表元素。

举个例子, 如果我们执行以下 RPUSH 命令, 那么服务器将创建一个列表对象作为 numbers 键的值:

redis> RPUSH numbers 1 "three" 5
(integer) 3

如果 numbers 键的值对象使用的是 ziplist 编码, 这个这个值对象将会是图 8-5 所展示的样子。

另一方面, linkedlist 编码的列表对象使用双端链表作为底层实现, 每个双端链表节点(node)都保存了一个字符串对象, 而每个字符串对象都保存了一个列表元素。

举个例子, 如果前面所说的 numbers 键创建的列表对象使用的不是 ziplist 编码, 而是 linkedlist 编码, 那么 numbers 键的值对象将是图 8-6 所示的样子。
在这里插入图片描述
更加准确而言,ptr指向的数据结构完整的是上文提到的链表的数据结构,有list节点存储元素个数,head和tail指针等。
1253693576.png)]

注意, linkedlist 编码的列表对象在底层的双端链表结构中包含了多个字符串对象, 这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现, 字符串对象是 Redis 五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。

注意

为了简化字符串对象的表示, 我们在图 8-6 使用了一个带有 StringObject 字样的格子来表示一个字符串对象, 而 StringObject 字样下面的是字符串对象所保存的值。

比如说, 图 8-7 代表的就是一个包含了字符串值 "three" 的字符串对象, 它是 8-8 的简化表示。
在这里插入图片描述
在这里插入图片描述

10.2 列表命令的实现

因为列表键的值为列表对象, 所以用于列表键的所有命令都是针对列表对象来构建的, 表 8-8 列出了其中一部分列表键命令, 以及这些命令在不同编码的列表对象下的实现方法。

命令ziplist 编码的实现方法linkedlist 编码的实现方法
LPUSH调用 ziplistPush 函数, 将新元素推入到压缩列表的表头。调用 listAddNodeHead 函数, 将新元素推入到双端链表的表头。
RPUSH调用 ziplistPush 函数, 将新元素推入到压缩列表的表尾。调用 listAddNodeTail 函数, 将新元素推入到双端链表的表尾。
LPOP调用 ziplistIndex 函数定位压缩列表的表头节点, 在向用户返回节点所保存的元素之后, 调用 ziplistDelete 函数删除表头节点。调用 listFirst 函数定位双端链表的表头节点, 在向用户返回节点所保存的元素之后, 调用 listDelNode 函数删除表头节点。
RPOP调用 ziplistIndex 函数定位压缩列表的表尾节点, 在向用户返回节点所保存的元素之后, 调用 ziplistDelete 函数删除表尾节点。调用 listLast 函数定位双端链表的表尾节点, 在向用户返回节点所保存的元素之后, 调用 listDelNode 函数删除表尾节点。
LINDEX调用 ziplistIndex 函数定位压缩列表中的指定节点, 然后返回节点所保存的元素。调用 listIndex 函数定位双端链表中的指定节点, 然后返回节点所保存的元素。
LLEN调用 ziplistLen 函数返回压缩列表的长度。调用 listLength 函数返回双端链表的长度。
LINSERT插入新节点到压缩列表的表头或者表尾时, 使用 ziplistPush 函数; 插入新节点到压缩列表的其他位置时, 使用 ziplistInsert 函数。调用 listInsertNode 函数, 将新节点插入到双端链表的指定位置。
LREM遍历压缩列表节点, 并调用 ziplistDelete 函数删除包含了给定元素的节点。遍历双端链表节点, 并调用 listDelNode 函数删除包含了给定元素的节点。
LTRIM调用 ziplistDeleteRange 函数, 删除压缩列表中所有不在指定索引范围内的节点。遍历双端链表节点, 并调用 listDelNode 函数删除链表中所有不在指定索引范围内的节点。
LSET调用 ziplistDelete 函数, 先删除压缩列表指定索引上的现有节点, 然后调用 ziplistInsert 函数, 将一个包含给定元素的新节点插入到相同索引上面。调用 listIndex 函数, 定位到双端链表指定索引上的节点, 然后通过赋值操作更新节点的值。

11. 哈希对象

哈希对象的编码可以是 ziplist 或者 hashtable

ziplist 编码的哈希对象使用压缩列表作为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此:

  • 保存了同一键值对的两个节点总是紧挨在一起, 保存键的节点在前, 保存值的节点在后;
  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。

举个例子, 如果我们执行以下 HSET 命令, 那么服务器将创建一个列表对象作为 profile 键的值:

redis> HSET profile name "Tom"
(integer) 1

redis> HSET profile age 25
(integer) 1

redis> HSET profile career "Programmer"
(integer) 1

如果 profile 键的值对象使用的是 ziplist 编码, 那么这个值对象将会是图 8-9 所示的样子, 其中对象所使用的压缩列表如图 8-10 所示。
在这里插入图片描述
在这里插入图片描述
另一方面, hashtable 编码的哈希对象使用字典作为底层实现, 哈希对象中的每个键值对都使用一个字典键值对来保存

  • 字典的每个键都是一个字符串对象, 对象中保存了键值对的键;
  • 字典的每个值都是一个字符串对象, 对象中保存了键值对的值。

举个例子, 如果前面 profile 键创建的不是 ziplist 编码的哈希对象, 而是 hashtable 编码的哈希对象, 那么这个哈希对象应该会是图 8-11 所示的样子。

在这里插入图片描述

11.2 哈希命令的实现

因为哈希键的值为哈希对象, 所以用于哈希键的所有命令都是针对哈希对象来构建的, 表 8-9 列出了其中一部分哈希键命令, 以及这些命令在不同编码的哈希对象下的实现方法。

命令ziplist 编码实现方法hashtable 编码的实现方法
HSET首先调用 ziplistPush 函数, 将键推入到压缩列表的表尾, 然后再次调用 ziplistPush 函数, 将值推入到压缩列表的表尾。调用 dictAdd 函数, 将新节点添加到字典里面。
HGET首先调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 然后调用 ziplistNext 函数, 将指针移动到键节点旁边的值节点, 最后返回值节点。调用 dictFind 函数, 在字典中查找给定键, 然后调用 dictGetVal 函数, 返回该键所对应的值。
HEXISTS调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 如果找到的话说明键值对存在, 没找到的话就说明键值对不存在。调用 dictFind 函数, 在字典中查找给定键, 如果找到的话说明键值对存在, 没找到的话就说明键值对不存在。
HDEL调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 然后将相应的键节点、 以及键节点旁边的值节点都删除掉。调用 dictDelete 函数, 将指定键所对应的键值对从字典中删除掉。
HLEN调用 ziplistLen 函数, 取得压缩列表包含节点的总数量, 将这个数量除以 2 , 得出的结果就是压缩列表保存的键值对的数量。调用 dictSize 函数, 返回字典包含的键值对数量, 这个数量就是哈希对象包含的键值对数量。
HGETALL遍历整个压缩列表, 用 ziplistGet 函数返回所有键和值(都是节点)。遍历整个字典, 用 dictGetKey 函数返回字典的键, 用 dictGetVal 函数返回字典的值。

12. 集合对象

集合对象的编码可以是 intset 或者 hashtable

intset 编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。

举个例子, 以下代码将创建一个如图 8-12 所示的 intset 编码集合对象:

redis> SADD numbers 1 3 5
(integer) 3

在这里插入图片描述
另一方面, hashtable 编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL

举个例子, 以下代码将创建一个如图 8-13 所示的 hashtable 编码集合对象:

redis> SADD fruits "apple" "banana" "cherry"
(integer) 3

12.2 集合命令的实现

因为集合键的值为集合对象, 所以用于集合键的所有命令都是针对集合对象来构建的, 表 8-10 列出了其中一部分集合键命令, 以及这些命令在不同编码的集合对象下的实现方法。

命令intset 编码的实现方法hashtable 编码的实现方法
SADD调用 intsetAdd 函数, 将所有新元素添加到整数集合里面。调用 dictAdd , 以新元素为键, NULL 为值, 将键值对添加到字典里面。
SCARD调用 intsetLen 函数, 返回整数集合所包含的元素数量, 这个数量就是集合对象所包含的元素数量。调用 dictSize 函数, 返回字典所包含的键值对数量, 这个数量就是集合对象所包含的元素数量。
SISMEMBER调用 intsetFind 函数, 在整数集合中查找给定的元素, 如果找到了说明元素存在于集合, 没找到则说明元素不存在于集合。调用 dictFind 函数, 在字典的键中查找给定的元素, 如果找到了说明元素存在于集合, 没找到则说明元素不存在于集合。
SMEMBERS遍历整个整数集合, 使用 intsetGet 函数返回集合元素。遍历整个字典, 使用 dictGetKey 函数返回字典的键作为集合元素。
SRANDMEMBER调用 intsetRandom 函数, 从整数集合中随机返回一个元素。调用 dictGetRandomKey 函数, 从字典中随机返回一个字典键。
SPOP调用 intsetRandom 函数, 从整数集合中随机取出一个元素, 在将这个随机元素返回给客户端之后, 调用 intsetRemove 函数, 将随机元素从整数集合中删除掉。调用 dictGetRandomKey 函数, 从字典中随机取出一个字典键, 在将这个随机字典键的值返回给客户端之后, 调用 dictDelete 函数, 从字典中删除随机字典键所对应的键值对。
SREM调用 intsetRemove 函数, 从整数集合中删除所有给定的元素。调用 dictDelete 函数, 从字典中删除所有键为给定元素的键值对。

13. 有序集合对象

有序集合的编码可以是 ziplist 或者 skiplist

ziplist 编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。

压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。

举个例子, 如果我们执行以下 ZADD 命令, 那么服务器将创建一个有序集合对象作为 price 键的值:

redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3

如果 price 键的值对象使用的是 ziplist 编码, 那么这个值对象将会是图 8-14 所示的样子, 而对象所使用的压缩列表则会是 8-15 所示的样子。

skiplist 编码的有序集合对象使用 zset 结构作为底层实现, 一个 zset 结构同时包含一个字典和一个跳跃表:

typedef struct zset {

    zskiplist *zsl;

    dict *dict;

} zset;

zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素: 跳跃表节点的 object 属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。 通过这个跳跃表, 程序可以对有序集合进行范围型操作, 比如 ZRANK 、 ZRANGE 等命令就是基于跳跃表 API 来实现的。

除此之外, zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过这个字典, 程序可以用 O(1) 复杂度查找给定成员的分值, ZSCORE 命令就是根据这一特性实现的, 而很多其他有序集合命令都在实现的内部用到了这一特性。

有序集合每个元素的成员都是一个字符串对象, 而每个元素的分值都是一个 double 类型的浮点数。 值得一提的是, 虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素, 但这两种数据结构都会通过指针来共享相同元素的成员和分值, 所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值, 也不会因此而浪费额外的内存。

为什么有序集合需要同时使用跳跃表和字典来实现?

在理论上来说, 有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现, 但无论单独使用字典还是跳跃表, 在性能上对比起同时使用字典和跳跃表都会有所降低。

举个例子, 如果我们只使用字典来实现有序集合, 那么虽然以 O(1) 复杂度查找成员的分值这一特性会被保留, 但是, 因为字典以无序的方式来保存集合元素, 所以每次在执行范围型操作 —— 比如 ZRANK 、 ZRANGE 等命令时, 程序都需要对字典保存的所有元素进行排序, 完成这种排序需要至少 O(N \log N) 时间复杂度, 以及额外的 O(N) 内存空间 (因为要创建一个数组来保存排序后的元素)。

另一方面, 如果我们只使用跳跃表来实现有序集合, 那么跳跃表执行范围型操作的所有优点都会被保留, 但因为没有了字典, 所以根据成员查找分值这一操作的复杂度将从 O(1) 上升为 O(\log N) 。

因为以上原因, 为了让有序集合的查找和范围型操作都尽可能快地执行, Redis 选择了同时使用字典和跳跃表两种数据结构来实现有序集合。

举个例子, 如果前面 price 键创建的不是 ziplist 编码的有序集合对象, 而是 skiplist 编码的有序集合对象, 那么这个有序集合对象将会是图 8-16 所示的样子, 而对象所使用的 zset 结构将会是图 8-17 所示的样子。
在这里插入图片描述
在这里插入图片描述
注意

为了展示方便, 图 8-17 在字典和跳跃表中重复展示了各个元素的成员和分值, 但在实际中, 字典和跳跃表会共享元素的成员和分值, 所以并不会造成任何数据重复, 也不会因此而浪费任何内存。

13.2 有序集合命令的实现

因为有序集合键的值为有序集合对象, 所以用于有序集合键的所有命令都是针对有序集合对象来构建的, 表 8-11 列出了其中一部分有序集合键命令, 以及这些命令在不同编码的有序集合对象下的实现方法。

命令ziplist 编码的实现方法zset 编码的实现方法
ZADD调用 ziplistInsert 函数, 将成员和分值作为两个节点分别插入到压缩列表。先调用 zslInsert 函数, 将新元素添加到跳跃表, 然后调用 dictAdd 函数, 将新元素关联到字典。
ZCARD调用 ziplistLen 函数, 获得压缩列表包含节点的数量, 将这个数量除以 2 得出集合元素的数量。访问跳跃表数据结构的 length 属性, 直接返回集合元素的数量。
ZCOUNT遍历压缩列表, 统计分值在给定范围内的节点的数量。遍历跳跃表, 统计分值在给定范围内的节点的数量。
ZRANGE从表头向表尾遍历压缩列表, 返回给定索引范围内的所有元素。从表头向表尾遍历跳跃表, 返回给定索引范围内的所有元素。
ZREVRANGE从表尾向表头遍历压缩列表, 返回给定索引范围内的所有元素。从表尾向表头遍历跳跃表, 返回给定索引范围内的所有元素。
ZRANK从表头向表尾遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。从表头向表尾遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。
ZREVRANK从表尾向表头遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。从表尾向表头遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。
ZREM遍历压缩列表, 删除所有包含给定成员的节点, 以及被删除成员节点旁边的分值节点。遍历跳跃表, 删除所有包含了给定成员的跳跃表节点。 并在字典中解除被删除元素的成员和分值的关联。
ZSCORE遍历压缩列表, 查找包含了给定成员的节点, 然后取出成员节点旁边的分值节点保存的元素分值。直接从字典中取出给定成员的分值。

14. 类型检查与命令多态

Redis 中用于操作键的命令基本上可以分为两种类型。

其中一种命令可以对任何类型的键执行, 比如说 DEL 命令、 EXPIRE 命令、 RENAME 命令、 TYPE 命令、 OBJECT 命令, 等等。

举个例子, 以下代码就展示了使用 DEL 命令来删除三种不同类型的键:

# 字符串键
redis> SET msg "hello"
OK

# 列表键
redis> RPUSH numbers 1 2 3
(integer) 3

# 集合键
redis> SADD fruits apple banana cherry
(integer) 3

redis> DEL msg
(integer) 1

redis> DEL numbers
(integer) 1

redis> DEL fruits
(integer) 1

而另一种命令只能对特定类型的键执行, 比如说:

  • SET 、 GET 、 APPEND 、 STRLEN 等命令只能对字符串键执行;
  • HDEL 、 HSET 、 HGET 、 HLEN 等命令只能对哈希键执行;
  • RPUSH 、 LPOP 、 LINSERT 、 LLEN 等命令只能对列表键执行;
  • SADD 、 SPOP 、 SINTER 、 SCARD 等命令只能对集合键执行;
  • ZADD 、 ZCARD 、 ZRANK 、 ZSCORE 等命令只能对有序集合键执行;

诸如此类。

举个例子, 我们可以用 SET 命令创建一个字符串键, 然后用 GET 命令和 APPEND 命令操作这个键, 但如果我们试图对这个字符串键执行只有列表键才能执行的 LLEN 命令, 那么 Redis 将向我们返回一个类型错误:

redis> SET msg "hello world"
OK

redis> GET msg
"hello world"

redis> APPEND msg " again!"
(integer) 18

redis> GET msg
"hello world again!"

redis> LLEN msg
(error) WRONGTYPE Operation against a key holding the wrong kind of value

类型检查的实现

从上面发生类型错误的代码示例可以看出, 为了确保只有指定类型的键可以执行某些特定的命令, 在执行一个类型特定的命令之前, Redis 会先检查输入键的类型是否正确, 然后再决定是否执行给定的命令。

类型特定命令所进行的类型检查是通过 redisObject 结构的 type 属性来实现的:

  • 在执行一个类型特定命令之前, 服务器会先检查输入数据库键的值对象是否为执行命令所需的类型, 如果是的话, 服务器就对键执行指定的命令;
  • 否则, 服务器将拒绝执行命令, 并向客户端返回一个类型错误。

举个例子, 对于 LLEN 命令来说:

  • 在执行 LLEN 命令之前, 服务器会先检查输入数据库键的值对象是否为列表类型, 也即是, 检查值对象 redisObject 结构 type 属性的值是否为 REDIS_LIST , 如果是的话, 服务器就对键执行 LLEN 命令;
  • 否则的话, 服务器就拒绝执行命令并向客户端返回一个类型错误;

图 8-18 展示了这一类型检查过程。
在这里插入图片描述

其他类型特定命令的类型检查过程也和这里展示的 LLEN 命令的类型检查过程类似。

多态命令的实现

Redis 除了会根据值对象的类型来判断键是否能够执行指定命令之外, 还会根据值对象的编码方式, 选择正确的命令实现代码来执行命令。

举个例子, 在前面介绍列表对象的编码时我们说过, 列表对象有 ziplistlinkedlist 两种编码可用, 其中前者使用压缩列表 API 来实现列表命令, 而后者则使用双端链表 API 来实现列表命令。

现在, 考虑这样一个情况, 如果我们对一个键执行 LLEN 命令, 那么服务器除了要确保执行命令的是列表键之外, 还需要根据键的值对象所使用的编码来选择正确的 LLEN 命令实现:

  • 如果列表对象的编码为 ziplist , 那么说明列表对象的实现为压缩列表, 程序将使用 ziplistLen 函数来返回列表的长度;
  • 如果列表对象的编码为 linkedlist , 那么说明列表对象的实现为双端链表, 程序将使用 listLength 函数来返回双端链表的长度;

借用面向对象方面的术语来说, 我们可以认为 LLEN 命令是多态(polymorphism)的: 只要执行 LLEN 命令的是列表键, 那么无论值对象使用的是 ziplist 编码还是 linkedlist 编码, 命令都可以正常执行。

图 8-19 展示了 LLEN 命令从类型检查到根据编码选择实现函数的整个执行过程, 其他类型特定命令的执行过程也是类似的。

digraph {      label = "\n 图 8-19    LLEN 命令的执行过程";      //      node [shape = box];      call_command [label = "客户端发送 LLEN  命令"];      check_type [label = "服务器检查 \n 键 key 的值对象\n是否列表对象", shape = diamond];      //execute_command [label = "对键 key 执行 LLEN 命令"];      select_encoding [label = "对象的编码是 \n ziplist 还是 linkedlist ?", shape = diamond];      ziplist [label = "调用 ziplistLen 函数 \n 返回压缩列表的长度"];      linkedlist [label = "调用 listLength 函数 \n 返回双端链表的长度"];      type_error [label = "返回一个类型错误"];      //      call_command -> check_type;      //check_type -> execute_command [label = "是"];      check_type -> type_error [label = "否"];      //execute_command -> select_encoding;      check_type -> select_encoding [label = "是"];      select_encoding -> ziplist [label = "ziplist \n 编码"];      select_encoding -> linkedlist [label = "linkedlist \n 编码"];  }

实际上, 我们可以将 DEL 、 EXPIRE 、 TYPE 等命令也称为多态命令, 因为无论输入的键是什么类型, 这些命令都可以正确地执行。

DEL 、 EXPIRE 等命令和 LLEN 等命令的区别在于, 前者是基于类型的多态 —— 一个命令可以同时用于处理多种不同类型的键, 而后者是基于编码的多态 —— 一个命令可以同时用于处理多种不同编码。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值