前一章介绍了Redis的主要数据结构,但Redis并没有直接使用这些数据结构来实现键值对数据库, 而是基于这些数据结构创建了一个对象系统 ,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。
使用对象有两个好处:
- 执行命令前,根据对象类型来判断是否可以执行此命令。
- 针对不同使用场景,为对象设置多种不同的数据结构实现,达到优化的目的。
此外,对象系统还引入了引用计数实现内存回收机制,以及对象共享。
1. 对象的类型与编码
Redis 中的每个键值对的键和值都是一个对象,每个对象都由一个 redisObject
结构表示, 该结构中和保存数据有关的三个属性分别是 type
属性、 encoding
属性和 ptr
属性:
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
...
} robj;
结构体的冒号表示位域,表示该变量占用的二进制位数
对象的 type
属性记录了对象的类型,属性的值如下表所示:
类型常量 | 对象的名称 |
---|---|
REDIS_STRING |
字符串对象 |
REDIS_LIST |
列表对象 |
REDIS_HASH |
哈希对象 |
REDIS_SET |
集合对象 |
REDIS_ZSET |
有序集合对象 |
对于 Redis 数据库保存的键值对来说, 键总是一个字符串对象, 而值则可以是上表中的其中一个。
所以,当我们称呼一个数据库键为“字符串键”时, 我们指的是“这个数据库键所对应的值为字符串对象”;同理,当我们称呼一个键为“列表键”时, 我们指的是“这个数据库键所对应的值为列表对象”。
对象的 ptr
指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding
属性决定。encoding
属性如下表所示:
编码常量 | 编码所对应的底层数据结构 |
---|---|
REDIS_ENCODING_INT |
long 类型的整数 |
REDIS_ENCODING_EMBSTR |
embstr 编码的简单动态字符串 |
REDIS_ENCODING_RAW |
简单动态字符串 |
REDIS_ENCODING_HT |
字典 |
REDIS_ENCODING_LINKEDLIST |
双端链表 |
REDIS_ENCODING_ZIPLIST |
压缩列表 |
REDIS_ENCODING_INTSET |
整数集合 |
REDIS_ENCODING_SKIPLIST |
跳跃表和字典 |
2. 字符串对象
2.1 编码方式
字符串对象的编码可以是 int
、 raw
或者 embstr
。
(1)如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long
类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr
属性里面(将 void*
转换成 long
), 并将字符串对象的编码设置为 int
。
(2)如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39
字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw
。
(3)如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39
字节, 那么字符串对象将使用 embstr
编码的方式来保存这个字符串值。
embstr
编码是专门用于保存短字符串的一种优化编码方式。这种编码和 raw
编码一样, 都使用 redisObject
结构和 sdshdr
结构来表示字符串对象,区别在于:
raw
编码会调用两次内存分配函数来分别创建redisObject
结构和sdshdr
结构。embstr
编码则通过调用一次内存分配函数来分配一块连续的空间。
由于减少了内存分配的次数,以及将零散的内存整合到一起,这种编码的字符串对象比起 raw
编码能够更好地利用缓存带来的优势。
如果保存浮点数,则会先转化为字符串类型保存。比如保存3.14就会先转化为"3.14"
。下表是值和对应的编码类型
值 | 编码 |
---|---|
可以用 long 类型保存的整数。 |
int |
可以用 long double 类型保存的浮点数。 |
embstr 或者 raw |
字符串值,长度太大没办法用 long 类型表示的整数。 |
embstr 或者 raw |
2.2 编码转换
编码之间也会有互相转换的情况。对于 int
编码的字符串对象来说,如果因为命令导致这个对象保存的不再是整数值, 而是一个字符串值, 那么字符串对象的编码将从 int
变为 raw
。
redis> SET number 10086
OK
redis> OBJECT ENCODING number
"int"
redis> APPEND number " is a good number!"
(integer) 23
redis> GET number
"10086 is a good number!"
redis> OBJECT ENCODING number
"raw"
因为 Redis 没有为 embstr
编码的字符串对象编写任何相应的修改程序 , 所以 embstr
编码的字符串对象实际上是只读的。当修改embstr
编码的字符串对象, 程序会先将对象的编码从 embstr
转换成 raw
, 然后再执行修改命令。
redis> SET msg "hello world"
OK
redis> OBJECT ENCODING msg
"embstr"
redis> APPEND msg " again!"
(integer) 18
redis> OBJECT ENCODING msg
"raw"
3. 列表对象
3.1 编码方式
列表对象的编码可以是 ziplist
或者 linkedlist
。
如同前面提到的,压缩列表每个节点(entry)只保存一个列表元素。下面例子中,我们输入数字1,字符”three”和数字5,
redis> RPUSH numbers 1 "three" 5
(integer) 3
如果使用的不是 ziplist
编码, 而是 linkedlist
双端链表 编码, 那么
这其实是一个嵌套编码,Redis使用了一个带有 StringObject
来表示一个字符串对象,编码方式如同上面提到的那三种。如果编码对象时字符串值,展开后就是:
3.2 编码转换
当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist
编码:
- 列表对象保存的所有字符串元素的长度都小于
64
字节; - 列表对象保存的元素数量小于
512
个;
当上述条件任意一个不满足时,就会执行转换操作: 原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面, 对象的编码也会从 ziplist
变为 linkedlist
。
4. 哈希对象
4.1 编码方式
哈希对象的编码可以是 ziplist
或者 hashtable
。
ziplist
编码时,每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此:
- 保存了同一键值对的两个节点总是紧挨在一起, 键前值后。
- 先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
比如:
redis> HSET profile name "Tom"
(integer) 1
redis> HSET profile age 25
(integer) 1
redis> HSET profile career "Programmer"
(integer) 1
hashtable
编码时, 哈希对象中的每个键值对都使用一个字典键值对来保存:
- 字典的每个键都是一个字符串对象, 对象中保存了键值对的键;
- 字典的每个值都是一个字符串对象, 对象中保存了键值对的值。
比如,上面的例子改为hashtable
编码
4.2 编码转换
当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist
编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于
64
字节; - 哈希对象保存的键值对数量小于
512
个;
和列表对象一样,不满足条件时原本保存在压缩列表里的所有键值对都会被转移并保存到字典里面, 对象的编码也会从 ziplist
变为 hashtable
。
5. 集合对象
Redis中集合和列表结构相似,但集合具有唯一性,列表不具有。
5.1 编码方式
集合对象的编码可以是 intset
或者 hashtable
。
intset
编码时,元素将被密集得堆叠在位上,比如
redis> SADD numbers 1 3 5
(integer) 3
另一方面, hashtable
编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL
。
5.2 编码的转换
当集合对象可以同时满足以下两个条件时, 对象使用 intset
编码:
- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过
512
个;
当使用 intset
编码所需的两个条件的任意一个不能被满足时, 对象的编码转换操作就会被执行: 原本保存在整数集合中的所有元素都会被转移并保存到字典里面, 并且对象的编码也会从 intset
变为 hashtable
。
6. 有序集合对象
6.1 编码方式
有序集合的编码可以是 ziplist
或者 skiplist
。
ziplist
编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
skiplist
编码的有序集合对象使用 zset
结构作为底层实现, 一个 zset
结构同时包含一个字典和一个跳跃表:
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
起作用主要是跳跃表,字典是辅助加速用。字典的键记录了元素的成员,而值则保存了元素的分值。通过字典,能实现$O(1)$复杂度的查找给定成员分值。
有序集合每个元素的成员都是一个字符串对象, 而每个元素的分值都是一个 double
类型的浮点数。
虽然 zset
结构同时使用跳跃表和字典来保存有序集合元素, 但这两种数据结构都会通过指针来共享相同元素的成员和分值, 所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值, 也不会因此而浪费额外的内存。
6.2 编码转换
当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist
编码:
- 有序集合保存的元素数量小于
128
个; - 有序集合保存的所有元素成员的长度都小于
64
字节;
不能满足以上两个条件的有序集合对象将使用 skiplist
编码。
7. 内存回收、对象共享和空转时长
对象中包括了一个引用计数器:
typedef struct redisObject {
// ...
// 引用计数
int refcount;
// ...
} robj;
对象的引用计数信息会随着对象的使用状态而不断变化:
- 在创建一个新对象时, 引用计数的值会被初始化为
1
; - 当对象被引用时,计数值+1;
- 当对象不再被引用时,计数值-1;
- 当对象的引用计数值变为
0
时, 对象所占用的内存会被释放。
函数 | 作用 |
---|---|
incrRefCount |
将对象的引用计数值增一。 |
decrRefCount |
将对象的引用计数值减一, 当对象的引用计数值等于 0 时, 释放对象。 |
resetRefCount |
将对象的引用计数值设置为 0 , 但并不释放对象, 这个函数通常在需要重新设置对象的引用计数值时使用。 |
通过引用机制,还能实现对象共享。共享只针对整数值对象,不针对包含字符串的对象。
假设键 A 创建了一个包含整数值 100
的字符串对象作为值对象,
如果这时键 B 也要创建一个同样保存了整数值 100
的字符串对象作为值对象, 那么服务器有以下两种做法:
- 为键 B 新创建一个包含整数值
100
的字符串对象; - 让键 A 和键 B 共享同一个字符串对象;
以上两种方法很明显是第二种方法更节约内存。在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
将被共享的值对象的引用计数增一。
Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从
0
到9999
的所有整数值, 当服务器需要用到值为0
到9999
的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象。
为什么 Redis 不共享包含字符串的对象?
判断是否共享时要检验共享对象和目标对象是否相同。复杂度如下,
共享对象 | 复杂度 |
---|---|
保存整数值字符串对象 | $O( 1)$ |
保存字符串值的字符串对象 | $O(N)$ |
包含了多个值的对象(列表或哈希) | $O(N^2)$ |
redisObject
结构包含的最后一个属性为 lru
属性, 该属性记录了对象最后一次被命令程序访问的时间:
typedef struct redisObject {
// ...
unsigned lru:22;
// ...
} robj;
OBJECT IDLETIME
命令可以打印出给定键的空转时长, 这一空转时长就是通过将当前时间减去键的值对象的 lru
时间计算得出的:
redis> SET msg "hello world"
OK
# 等待一小段时间
redis> OBJECT IDLETIME msg
(integer) 20
注意OBJECT IDLETIME
命令的实现是特殊的, 这个命令在访问键的值对象时, 不会修改值对象的 lru
属性。这类似于std::weak_ptr
的作用。
当内存满时,空转时长较长的键会被优先释放。