文章目录
1. 简介
我们可知,Redis
的用到的主要的数据结构有:简单字符串(SDS)、链表、字典、压缩列表、整数集合等等。Redis
的对象都是由这些数据结构所实现。并且,Redis
为了可以对不同的对象在不同的使用场景下选择更加合适的数据结构实现,Redis
基于这些数据结构创建了一个对象系统,而这个对象系统包含了:字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。
使用对象系统的好处是:
- 执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令;
- 针对不同的使用场景为对象设置不同的数据结构实现,从而优化对象在不同场景下的使用效率;
- 实现了引用计数技术的内存回收机制,用于释放不再被使用的对象的内存;
- 实现了对象共享内存机制;
- 记录了对象的空转时长。
在具体介绍之前,这些功能听着有点迷糊,随着深入学习,会慢慢体会得到对象系统的强大之处。
2. 对象的类型和编码
Redis 使用对象来表示数据库中的键和值,每次当我们在 Redis
的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键,另一个对象用作键值对的值。
举个例子,如图所示:
- 红色字体的为:键值对的键;
- 绿色字体的为:键值对的值。
从中我们可以得知,键值对的键永远都是以字符串的形式存在,所以我们关注的是键值对的值所对应的对象类型。
在讲解对象类型之前,需要先知道对象它的数据结构长什么样子(部分字段暂时隐藏起来,只列出关键字段):
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
}
Redis
中的每个对象都由 redisObject
结构表示,该结构中和保存数据有关的三个属性分别是 type
、encoding
和 ptr
。
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. 字符串对象
字符串对象的编码可以是:int
、raw
或者 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
编码会调用两次内存分配函数来分别创建readObject
和sdshdr
结构,而embstr
编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject
和sdshdr
两个结构,如下图所示:
-
相同点:
embstr
编码的字符串对象在执行命令时,产生的效果和raw
编码的字符串对象执行命令时产生的效果是相同的。
embstr
的优点:
embstr
编码将创建字符串对象所需的内存分配次数从两次降为一次;- 释放
embstr
编码的字符串对象只需要调用一次内存释放函数; - 因为
embstr
编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw
编码的字符串对象能够更好地利用缓存带来的优势。
更详细的信息可参考:Redis开发与运维:SDS
3.1.1 为什么 embstr 的分界线为 44 字节?
- 由上述内容可知,
embstr
和redisObject
的内存是连续的,这里涉及到内存分配的问题。内存分配器jemalloc、tcmalloc
等分配内存大小的单位都是:2/4/8/16/32/64
字节等,为了能容纳一个完整的embstr
对象,jemalloc
最少会分配32
字节的空间,如果字符串再稍微长一点,那就是64
字节的空间。如果字符串总体超过了64
字节,Redis
认为它是一个大字符串,不再适合使用embstr
形式存储,而该使用raw
形式。
在内存分配上,embstr
与raw
的区别如下:
- 从具体数据结构出发
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
编码的字符串对象编写任何相应的修改程序(只有 int
和 raw
编码的字符串有),所以 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
编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:
- 字典的每个键都是一个字符串对象,对象保存了键值对的键;
- 字典的每个值都是一个字符串对象,对象保存了键值对的值;
结构如下:
注意:
- 上图的
hashtable
只是简略版,真正的hashtable
结构会稍微复杂一点; - 上面的例子不会让
Redis
以hashtable
编码作为哈希对象的编码,只是用于显示出以hashtable
为编码的哈希对象的结构; - 什么时候用
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
属性保存了元素的分值。
通过这个跳跃表,程序可以对有序集合进行范围型操作,比如
ZRANK
、ZRANGE
等命令。
除此之外,zset
结构中的 dict
字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:
- 字典的键保存了元素的成员;
- 字典的值保存了元素的分值。
通过这个字典,程序可以用 O(1)
复杂度查找给定成员的分值,ZSCORE
命令就是根据这一特性实现的。
虽然
zset
结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费内存。
结构如下:
6.2.1 为什么有序集合要同时使用跳跃表和字典来实现?
在理论上,有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低。
- 只使用字典
- 虽然可以以
O(1)
复杂度查找成员的分值,但是因为字典是以无序的方式来保存元素的,所以每次在执行范围型操作时(比如:ZRANGE
、ZRANK
等命令),程序都需要对字典保存的所有元素进行排序,完成这种排序至少需要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
。但是考虑到链表的附加空间相对太高,prev
和 next
指针就要占去 16
个字节(64位操作系统的指针占 8
字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。所以,后来的 Redis
对列表数据结构进行了改造,使用 quicklist
代替了 ziplist
和 linkedlist
。
8. 对象类型检查与命令多态
Redis
中用于操作键的命令基本上可以分为两种类型。
- 面向
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
编码的命令,如
SET、GET、APPEND、STRLEN
等命令只能对字符串键执行;HDEL、HSET、HGET、HLEN
等命令只能对哈希键执行;RPUSH、LPOP、LINSERT、LLEN
等命令只能对列表建执行;SADD、SPOP、SINSERT、SCARD
等命令只能对集合键执行;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
属性来实现的:
- 在执行一个类型特定命令之前,服务器会先检查输入数据键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令;
- 否则,服务器将拒绝执行命令,并向客户端返回一个类型错误。
流程如图所示:
示例:
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
的字符串对象作为值对象,那么服务器有以下两种做法:
- 为键
B
新创建一个包含整数值100
的字符串对象; - 让键
A
和键B
共享同一个字符串对象。
以上两种方法很明显是第二种更节约内存。在 Redis
中,让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增一。
如下图所示,键 A
和键 B
同时共享整数值 100
的字符串对象,唯一的变化就是 refcount
从 1
变为 2
。
共享对象机制对于节约内存非常有帮助,数据库中保存的相同值对象乐队,对象共享机制就能节约越多的内存。
目前来说,Redis
会在初始化服务器时,创建一万个字符串对象,这些对象包含了从 0
到 9999
的所有整数值,当服务器需要用到这个区间的整数值字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。
但是,实际操作的时候,会发现值引用计数的并不是简单的 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. */
所以,在较新的版本中,0
到 9999
的整数值字符串对象所对应的 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、ptr
和 refcount
四个属性外,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. 参考
- Redis开发与运维:SDS
- redis OBJECT REFCOUNT 0-9999 会显示 (integer) 2147483647
- Redis之object idletime为什么是不可靠的
- 《Redis 设计与实现》
14. 其他相关文章
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/77869.html