Redis学习(七)对象

导读:本篇文章讲解 Redis学习(七)对象,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

1. 简介

我们可知,Redis 的用到的主要的数据结构有:简单字符串(SDS)、链表、字典、压缩列表、整数集合等等。Redis 的对象都是由这些数据结构所实现。并且,Redis 为了可以对不同的对象在不同的使用场景下选择更加合适的数据结构实现,Redis 基于这些数据结构创建了一个对象系统,而这个对象系统包含了:字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。
使用对象系统的好处是:

  1. 执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令;
  2. 针对不同的使用场景为对象设置不同的数据结构实现,从而优化对象在不同场景下的使用效率;
  3. 实现了引用计数技术的内存回收机制,用于释放不再被使用的对象的内存;
  4. 实现了对象共享内存机制;
  5. 记录了对象的空转时长。

在具体介绍之前,这些功能听着有点迷糊,随着深入学习,会慢慢体会得到对象系统的强大之处。

2. 对象的类型和编码

Redis 使用对象来表示数据库中的键和值,每次当我们在 Redis 的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键,另一个对象用作键值对的值。
举个例子,如图所示:

  • 红色字体的为:键值对的键;
  • 绿色字体的为:键值对的值。
    在这里插入图片描述
    从中我们可以得知,键值对的键永远都是以字符串的形式存在,所以我们关注的是键值对的值所对应的对象类型
    在讲解对象类型之前,需要先知道对象它的数据结构长什么样子(部分字段暂时隐藏起来,只列出关键字段):
typedef struct redisObject {
    // 类型
    unsigned type:4;
    
    // 编码
    unsigned encoding:4;
    
    // 指向底层实现数据结构的指针
    void *ptr;
}

Redis 中的每个对象都由 redisObject 结构表示,该结构中和保存数据有关的三个属性分别是 typeencodingptr

2.1 对象类型 – type

对象的 type 属性记录了对象的类型,这个属性的值如下表:

类型常量 对象的名称
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合对象

上面我们提到,我们说的对象类型其实指的是键值对的值的对象类型,可以通过使用 TYPE 命令来查询某个键值对的值的对象类型:

127.0.0.1:6379> TYPE msg
string

127.0.0.1:6379> TYPE list
list

127.0.0.1:6379> TYPE hashmap
hash

127.0.0.1:6379> TYPE set
set

127.0.0.1:6379> TYPE price
zset

2.2 对象编码 – encoding

REDIS_STRING 等对象类型由不同的编码组成,其根据存储的内容不同而使用不同的编码,而这些编码的信息则存储于 encoding 中。ptr 指针则是用于指向对象的底层由 encoding 决定的数据结构。详细对象编码和对象类型的对照表如下:

类型常量 编码常量 具体对象
REDIS_STRING REDIS_ENCODING_INT 使用整数值实现的字符串对象
REDIS_STRING REDIS_ENCODING_EMBSTR 使用 embstr 编码的简单动态字符串实现的字符串对象
REDIS_STRING REDIS_ENCODING_RAW 使用简单动态字符串实现的字符串对象
REDIS_LIST REDIS_ENCODING_LINKEDLIST 使用双端链表实现的列表对象
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象
REDIS_SET REDIS_ENCODING_INTSET 使用整数集合实现的集合对象
REDIS_SET REDIS_ENCODING_HT 使用字典实现的集合对象
REDIS_ZSET REDIS_ENCODING_ZIPLIST 使用压缩列表实现的有序集合对象
REDIS_ZSET REDIS_ENCODING_SKIPLIST 使用跳跃表和字典实现的有序集合对象

我们可以通过 OBJECT ENCODING key 命令来查看键值对的值所对应的编码,如下:

127.0.0.1:6379> SET msg 1
OK
127.0.0.1:6379> OBJECT ENCODING msg
"int"

127.0.0.1:6379> SET student "wifi"
OK
127.0.0.1:6379> OBJECT ENCODING student
"embstr"

127.0.0.1:6379> SET numbers "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20"
OK
127.0.0.1:6379> OBJECT ENCODING numbers
"raw"

127.0.0.1:6379> HSET hash a 1 b 2 c 3
(integer) 3
127.0.0.1:6379> OBJECT ENCODING hash
"ziplist"

通过 encoding 属性来决定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了 Redis 的灵活性和效率,因为 Reids 可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。后面将会讲述到不同数据结构在什么情况下其编码会发生变化。

3. 字符串对象

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

例如:

redis> SET number 10086
OK
    
redis> OBJECT ENCODING number
"int"

结构如下:
在这里插入图片描述
如果字符串对象保存的是一个字符串长度小于等于 44 字节的字符串时,那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。

127.0.0.1:6379> SET str "hello world! hello world! hello world! hello"
OK
127.0.0.1:6379> STRLEN str
(integer) 44
127.0.0.1:6379> OBJECT ENCODING str
"embstr"

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

例如:

127.0.0.1:6379> SET msg "hello world! hello world! hello world! hello "
OK
127.0.0.1:6379> STRLEN msg
(integer) 45
127.0.0.1:6379> OBJECT ENCODING msg
"raw"

结构如下:
在这里插入图片描述

3.1 raw 和 embstr 的区别是?

  • 不同点:
    embstr 编码是专门用于保存短字符串的一种优化编码方式,这种编码和 raw 编码一样,都是用 readObject 结构 和 sdshdr 结构来表示字符串对象,但 raw 编码会调用两次内存分配函数来分别创建 readObjectsdshdr 结构,而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含 redisObjectsdshdr 两个结构,如下图所示:
    在这里插入图片描述

  • 相同点:
    embstr 编码的字符串对象在执行命令时,产生的效果和 raw 编码的字符串对象执行命令时产生的效果是相同的。

embstr 的优点:

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

更详细的信息可参考:Redis开发与运维:SDS

3.1.1 为什么 embstr 的分界线为 44 字节?

  1. 由上述内容可知,embstrredisObject 的内存是连续的,这里涉及到内存分配的问题。内存分配器 jemalloc、tcmalloc 等分配内存大小的单位都是:2/4/8/16/32/64 字节等,为了能容纳一个完整的 embstr 对象,jemalloc 最少会分配 32 字节的空间,如果字符串再稍微长一点,那就是 64 字节的空间。如果字符串总体超过了 64 字节,Redis 认为它是一个大字符串,不再适合使用 embstr 形式存储,而该使用 raw 形式。
    在内存分配上,embstrraw 的区别如下:
    在这里插入图片描述
  2. 从具体数据结构出发
    typedef struct redisObject {
        unsigned type:4;
        unsigned encoding:4;
        unsigned lru:24; /* LRU time (relative to global lru_clock) or
                                * LFU data (least significant 8 bits frequency
                                * and most significant 16 bits access time). */
        int refcount; // 4 bytes
        void *ptr;	  // 8 bytes
    } robj;
    
    struct SDS {
        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[];
    };
    

    embstr 得到的数据结构图如下:
    在这里插入图片描述
    所以,当分配的内存大小是 64 字节时,buf 可得的最大字节数为 64 - (4 + 4 + 64 + 24 + 32 + 8 + 8 + 8) / 8 = 45 字节,同时字符串又是以 \0 结尾,所以 embstr 的最大长度为:44 字节。分配再大的内存,buf 可分配到的内存大小就大于 64 字节了,所以以 44 字节作为分界线。

3.2 字符串编码的转换

具体可转换的形式有:

  • int -> int
  • int -> raw
  • embstr -> raw
  • raw -> raw

int -> int 示例:

redis> SET number 10086
OK

redis> OBJECT ENCODING number
"int"

127.0.0.1:6379> incr number
(integer) 10087

127.0.0.1:6379> OBJECT ENCODING number
"int"

int -> raw 示例:

127.0.0.1:6379> SET number 10086
OK

127.0.0.1:6379> OBJECT ENCODING number
"int"

127.0.0.1:6379> APPEND number " is a good number!"
(integer) 23

127.0.0.1:6379> GET number
"10086 is a good number!"

127.0.0.1:6379> OBJECT ENCODING number
"raw"

embstr -> raw 示例:

127.0.0.1:6379> SET msg "hello world"
OK

127.0.0.1:6379> OBJECT ENCODING msg
"embstr"

127.0.0.1:6379> APPEND msg " again!"
(integer) 18

127.0.0.1:6379> OBJECT ENCODING msg
"raw"

不是说 44 字节及以内的字符串的编码是 embstr 吗?怎么这里才 18 字节,就成了 raw 编码了?
这是因为 Redis 没有为 embstr 编码的字符串对象编写任何相应的修改程序(只有 intraw 编码的字符串有),所以 embstr 编码的字符串对象实际上是只读的。当我们对 embstr 编码的字符串对象执行任何修改命令时,程序会先将对象的编码从 embstr 转换成 raw,然后再执行命令。因为这个原因,embstr 编码的字符串对象在执行修改命令时,总会变成一个 raw 编码的字符串对象。

raw -> raw 示例:

127.0.0.1:6379> SET msg "hello world! hello world! hello world! hello "
OK

127.0.0.1:6379> OBJECT ENCODING msg
"raw"

127.0.0.1:6379> APPEND msg "TEST!!!!!!!"
(integer) 56

127.0.0.1:6379> OBJECT ENCODING msg
"raw"

3.3 不同字符串编码对应的命令实现

命令 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 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,计算并返回这个字符串值的长度 调用 strlen 函数,返回字符串的长度 调用 strlen 函数,返回字符串的长度
SETRANGE 将对象转换成 raw 编码,然后按 raw 编码的方式执行此操作 将对象转换成 raw 编码,然后按 raw 编码的方式执行此操作 将字符串特额定索引上的值设置为给定的字符
GETRANGE 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,然后取出并返回字符串指定索引上的字符 直接取出并返回字符串指定索引上的字符 直接取出并返回字符串指定索引上的字符

4. 哈希对象

哈希对象的编码有:

  • ziplist
  • hashtable

4.1 以 ziplist 为编码的哈希对象

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

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

例如:

127.0.0.1:6379> HMSET profile name "Tom" age 25 career "programmer"
OK
127.0.0.1:6379> OBJECT ENCODING profile
"ziplist"

结构如下:
在这里插入图片描述

4.2 以 hashtable 为编码的哈希对象

hashtable 编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:

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

结构如下:
在这里插入图片描述
注意:

  1. 上图的 hashtable 只是简略版,真正的 hashtable 结构会稍微复杂一点;
  2. 上面的例子不会让 Redishashtable 编码作为哈希对象的编码,只是用于显示出以 hashtable 为编码的哈希对象的结构;
  3. 什么时候用 ziplist 编码,什么时候用 hashtable 编码,再下一节会提及。

4.3 编码转换

当哈希对象可以同时满足以下两个条件时,哈希对象使用 ziplist 编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
  • 哈希对象保存的键值对数量小于 512 个;

不能满足这两个条件的哈希对象,会使用 hashtable 编码。

4.3.1 键太长引起的编码转换

示例:

127.0.0.1:6379> HSET book name "Mastering C++ in 21 days"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING book
"ziplist"

127.0.0.1:6379> HSET book long_long_long_long_long_long_long_long_long_long_long_description "content"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING book
"hashtable"

4.3.2 值太长引起的编码转换

示例:

127.0.0.1:6379> HSET hs greeting "hello world"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING hs
"ziplist"

# 65 字节
127.0.0.1:6379> HSET hs story "11111111111111111111111111111111111111111111111111111111111111111"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING hs
"hashtable"

只有 quicklist 转换成 hashtable,不能 hashtable 转换成 quicklist

5. 集合对象

集合对象的编码有

  • intset
  • hashtable

5.1 以 intset 为编码的集合对象

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

例如,以 intset 为编码的集合对象:

127.0.0.1:6379> SADD numbers 1 3 5
(integer) 3
127.0.0.1:6379> OBJECT ENCODING numbers
"intset"

结构如下:
在这里插入图片描述

5.2 以 hashtable 为编码的集合对象

hashtable 编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为 NULL

例如,以 hashtable 为编码的集合对象:

127.0.0.1:6379> SADD fruits "apple" "banana" "cherry"
(integer) 3
127.0.0.1:6379> OBJECT ENCODING fruits
"hashtable"

结构如下:
在这里插入图片描述

上图的 hashtable 只是简略版,真正的 hashtable 结构会稍微复杂一点;

5.3 编码转换

当集合对象可以同时满足以下两个条件时,对象使用 intset 编码,否则使用 hashtable 编码:

  • 集合对象保存的所有元素都是整数值;
  • 集合对象保存的元素数量不超过 512 个。

5.3.1 集合对象包含非整数元素

对于使用 intset 编码的集合对象来说,当使用 intset 编码所需的两个条件的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在整数集合中的所有元素都会被转移并保存到字典里面,并且对象的编码也会从 intset 变为 hashtable

示例:

127.0.0.1:6379> SADD numbers 1 3 5
(integer) 3
127.0.0.1:6379> OBJECT ENCODING numbers
"intset"

127.0.0.1:6379> SADD numbers "test"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING numbers
"hashtable"

5.3.2 集合对象保存的元素超过 512 个

127.0.0.1:6379> EVAL "for i = 1, 512 do redis.call('SADD', KEYS[1], i) end" 1 integers
(nil)
127.0.0.1:6379> SCARD integers
(integer) 512
127.0.0.1:6379> OBJECT ENCODING integers
"intset"

127.0.0.1:6379> SADD integers 513
(integer) 1
127.0.0.1:6379> SCARD integers
(integer) 513
127.0.0.1:6379> OBJECT ENCODING integers
"hashtable"

6. 有序集合对象

有序集合的编码有

  • ziplist
  • skiplist

6.1 以 ziplist 为编码的有序集合对象

ziplist 编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用那个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),第二个元素保存元素的分值(score)。
压缩列表内的集合元素从小到大进行排序,分值较小的元素被放置在靠近表头的位置,而分值较大的元素则被放置在靠近表尾的位置。

例如,以 ziplist 编码的有序集合:

127.0.0.1:6379> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
127.0.0.1:6379> OBJECT ENCODING price
"ziplist"

结构如下:
在这里插入图片描述

6.2 以 skiplist 为编码的有序集合对象

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

typedef struct zset {
    zskiplist *zsl;
    dict *dict;
} zset;

zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:

  • 跳跃表节点的 object 属性保存了元素的成员;
  • 跳跃表节点的 score 属性保存了元素的分值。

通过这个跳跃表,程序可以对有序集合进行范围型操作,比如 ZRANKZRANGE 等命令。

除此之外,zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:

  • 字典的键保存了元素的成员;
  • 字典的值保存了元素的分值。

通过这个字典,程序可以用 O(1) 复杂度查找给定成员的分值,ZSCORE 命令就是根据这一特性实现的。

虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费内存。

结构如下:
在这里插入图片描述

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

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

  • 只使用字典
    • 虽然可以以 O(1) 复杂度查找成员的分值,但是因为字典是以无序的方式来保存元素的,所以每次在执行范围型操作时(比如:ZRANGEZRANK 等命令),程序都需要对字典保存的所有元素进行排序,完成这种排序至少需要 O(NlogN) 时间复杂度,以及额外的 O(N) 内存空间用于保存排序后的元素;
  • 只使用跳跃表
    • 跳跃表执行范围型操作的所有优点都会被保留,但因为没有字典,所以根据成员查找分值这一操作的复杂度即将从 O(1) 上升为 O(logN)

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

6.3 编码转换

当有序集合对象可以同时满足以下两个条件时,对象使用 ziplist 编码

  • 有序集合保存的元素数量小于 128 个;
  • 有序集合保存的所有元素成员的长度都小于 64 字节。

不能满足以上两个条件的有序集合对象将使用 skiplist 编码

6.3.1 有序集合数量超过 128 个

示例:

127.0.0.1:6379> EVAL "for i = 1, 128 do redis.call('ZADD', KEYS[1], i, i) end" 1 numbers
(nil)
127.0.0.1:6379> ZCARD numbers
(integer) 128
127.0.0.1:6379> OBJECT ENCODING numbers
"ziplist"

127.0.0.1:6379> ZADD numbers 129 129
(integer) 1
127.0.0.1:6379> ZCARD numbers
(integer) 129
127.0.0.1:6379> OBJECT ENCODING numbers
"skiplist"

6.3.2 有序集合元素成员的长度大于 64 字节

示例:

127.0.0.1:6379> ZADD numbers 1.0 a
(integer) 1
127.0.0.1:6379> OBJECT ENCODING numbers
"ziplist"

127.0.0.1:6379> ZADD numbers 2.0 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
(integer) 1
127.0.0.1:6379> OBJECT ENCODING numbers
"skiplist"

7. 列表对象

Redis 3.2 之前,列表对象的编码可以是 ziplist 或者 linkedlist。但是考虑到链表的附加空间相对太高,prevnext 指针就要占去 16 个字节(64位操作系统的指针占 8 字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。所以,后来的 Redis 对列表数据结构进行了改造,使用 quicklist 代替了 ziplistlinkedlist

8. 对象类型检查与命令多态

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

  1. 面向 RedisObject 类型的命令,如:DEL、EXPIRE、RENAME、TYPE、OBJECT 等;
# 我们新建 5 种类型的数据
127.0.0.1:6379> SET string "value"
OK
127.0.0.1:6379> RPUSH list 1 3 5
(integer) 3
127.0.0.1:6379> HSET hash a 1
(integer) 1
127.0.0.1:6379> SADD set 1 3 5
(integer) 3
127.0.0.1:6379> ZADD zset 1 a 2 b
(integer) 2

# 使用 TYPE 命令
127.0.0.1:6379> TYPE string
string
127.0.0.1:6379> TYPE list
list
127.0.0.1:6379> TYPE set
set
127.0.0.1:6379> TYPE zset
zset
127.0.0.1:6379> TYPE hash
hash

面向 RedisObject 编码的命令,如

  1. SET、GET、APPEND、STRLEN 等命令只能对字符串键执行;
  2. HDEL、HSET、HGET、HLEN 等命令只能对哈希键执行;
  3. RPUSH、LPOP、LINSERT、LLEN 等命令只能对列表建执行;
  4. SADD、SPOP、SINSERT、SCARD 等命令只能对集合键执行;
  5. ZADD、ZCARD、ZRANK、ZSCORE 等命令只能对有序集合键执行。
# 用特定的命令操作特定的对象
127.0.0.1:6379> SET string "value"
OK
127.0.0.1:6379> RPUSH list 1 3 5
(integer) 3
127.0.0.1:6379> HSET hash a 1
(integer) 1
127.0.0.1:6379> SADD set 1 3 5
(integer) 3
127.0.0.1:6379> ZADD zset 1 a 2 b
(integer) 2

8.1 对象类型检查的实现

为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令之前,Redis 会先检查输入键的类型是否正确,然后再决定是否执行给定的命令。

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

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

流程如图所示:
在这里插入图片描述
示例:

127.0.0.1:6379> HSET profile name "wifi"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING profile
"ziplist"
127.0.0.1:6379> GET profile
(error) WRONGTYPE Operation against a key holding the wrong kind of value

8.2 多态命令的实现

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

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

DEL、EXPIRE、TYPE 等命令和 SET、GET、APPEND、STRLEN 等命令的区别在于,前者是基于类型的多态,后者是基于编码的多态

多态命令的执行流程如图所示:
在这里插入图片描述

9. 内存回收

因为 C 语言并不具备自动内存回收功能,所以 Redis 在自己的对象系统中构建了一个引用计数来实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。

每个对象的引用计数信息由 redisObject 结构的 refcount 属性记录:

typedef struct redisObject {
    // 引用计数
    int refcount;
} robj;

对象的引用计数信息回随着对象的使用状态而不断变化:

  • 在创建一个新对象时,引用计数的值会被初始化为:1
  • 当对象被一个新程序使用时,它的引用计数值会被:+1
  • 当对象不再被一个程序使用时,它的引用计数值会被:-1
  • 当对象的引用计数值变为 0 时,对象所占用的内存会被释放。

对象的整个生命周期可以划分为:创建对象、操作对象、释放对象三个阶段。
作为例子,以下代码展示了一个字符串对象从创建到释放的整个过程:

// 创建一个字符串对象 s,对象的引用计数为 1
robj *s = createStringObject(...)
    
// 对象 s 执行各种操作

// 对象 s 的引用计数 -1,使得对象的引用计数变为 0
// 导致对象 s 被释放
decrRefCount(s)

10. 对象共享

引用计数属性除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。

举个列子,假设键 A 创建了一个包含整数值 100 的字符串对象作为值对象,如图所示:
在这里插入图片描述
如果这时键 B 也要创建一个同样保存了整数值 100 的字符串对象作为值对象,那么服务器有以下两种做法:

  1. 为键 B 新创建一个包含整数值 100 的字符串对象;
  2. 让键 A 和键 B 共享同一个字符串对象。

以上两种方法很明显是第二种更节约内存。在 Redis 中,让多个键共享同一个值对象需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

如下图所示,键 A 和键 B 同时共享整数值 100 的字符串对象,唯一的变化就是 refcount1 变为 2
在这里插入图片描述

共享对象机制对于节约内存非常有帮助,数据库中保存的相同值对象乐队,对象共享机制就能节约越多的内存。

目前来说,Redis 会在初始化服务器时,创建一万个字符串对象,这些对象包含了从 09999 的所有整数值,当服务器需要用到这个区间的整数值字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。

但是,实际操作的时候,会发现值引用计数的并不是简单的 1 或者 2,而是 2147483647

127.0.0.1:6379> SET A 100
OK
127.0.0.1:6379> OBJECT REFCOUNT A
(integer) 2147483647

我的 Redis 版本是:5.0.8

通过查看源码发现,在 object.c 文件中的 robj *makeObjectShared(robj *o) 方法里面:

robj *makeObjectShared(robj *o) {
    serverAssert(o->refcount == 1);
    # 共享对象时,refcount 指向的是:OBJ_SHARED_REFCOUNT
    o->refcount = OBJ_SHARED_REFCOUNT;
    return o;
}

那么再查看 server.h 文件中的 OBJ_SHARED_REFCOUNT 变量定义可知:

# OBJ_SHARED_REFCOUNT 初始化值为:INT_MAX
# define OBJ_SHARED_REFCOUNT INT_MAX     /* Global object never destroyed. */

所以,在较新的版本中,09999 的整数值字符串对象所对应的 refcount 值都为:INT_MAX

10.1 为什么 Redis 不共享包含字符串的对象?

通过简单实验,Redis 每次都会创建新的字符串对象,而不是共享字符串对象:

127.0.0.1:6379> SET B "HELLO"
OK
127.0.0.1:6379> OBJECT REFCOUNT B
(integer) 1
127.0.0.1:6379> SET C "HELLO"
OK
127.0.0.1:6379> OBJECT REFCOUNT C
(integer) 1

这是因为:

当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的 CPU 时间也会越多:

  • 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为:O(1);
  • 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为:O(N);
  • 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度为:O(N2)。

因此,尽管共享更复杂的对象可以节约更多的内存,但受到 CPU 时间的限制,Redis 只对包含整数值的字符串对象进行共享。

11. 对象的空转时长

除了前面介绍过的 type、encoding、ptrrefcount 四个属性外,redisObject 结构包含的最后一个属性为 lru 属性,该属性记录了对象最后一次被命令程序访问的时间

typedef struct redisObject {
    unsigned lru:24; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
} robj;

通过使用 OBJECT IDLETIME 命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的 lru 时间计算得出的:

127.0.0.1:6379> SET msg "test"
OK
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 10
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 11
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 12
127.0.0.1:6379> GET msg
"test"
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 2

除了可以被 OBJECT IDLETIME 命令打印出来之外,键的空转时长还有另外一项作用:

如果服务器打开了 maxmemory 选项,并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru,那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时,空转时长较高的那部分键会被优先被服务器释放,从而回收内存。

问题:那么 0~9999 的整数值字符串会被回收吗?

11.1 OBJECT IDLETIME 命令的“坑”

通过前面知识中我们知道,在 Redis 中,0~9999 整数值字符串是共享的,那么在用 OBJECT IDELTIME 命令时,键的值是整数值字符串的对象的 lru 值也是共享的,如下例所示:

127.0.0.1:6379> set a 1
OK
127.0.0.1:6379> set b 1
OK
# 在 1 很久没被调用的情况下,CURRENT_TIME - LRU 的值很大
127.0.0.1:6379> OBJECT IDLETIME a
(integer) 365481
127.0.0.1:6379> OBJECT IDLETIME b
(integer) 365487
        
# 调用键 a
127.0.0.1:6379> get a
"1"
# 此时同时影响了键 a 和 键 b 的空转时长值
127.0.0.1:6379> OBJECT IDLETIME b
(integer) 2
127.0.0.1:6379> OBJECT IDLETIME a
(integer) 5
# 非整数值字符串的话,就不受影响
127.0.0.1:6379> set msg "hello"
OK
127.0.0.1:6379> set string "hello"
OK
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 18
127.0.0.1:6379> OBJECT IDLETIME string
(integer) 17
127.0.0.1:6379> get msg
"hello"
127.0.0.1:6379> OBJECT IDLETIME string
(integer) 24
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 9

所以使用 OBJECT IDLETIME 命令时,需要注意共享整数值字符。

12. 完整的 redisObject 对象结构

最后,展现完整的 redisObject 对象的数据结构:

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

源码位置于:server.h 中

13. 参考

14. 其他相关文章

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/77869.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!