在前面的章节里,我们陆续介绍了Redis用到的所有主要数据结构,比如简单动态字符串(SDS)、双端链表,字典,压缩列表,整数集合等。
Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种我们前面介绍的数据结构。
通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
除此之外,Redis的对象系统还实现了基于引用计数技术在内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;另外,Redis还通过引用计数技术实现了对象的共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。
最后,Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了maxmemory功能的情况下,空转时长较大的那些键可能会优化被服务器删除。
对象的类型与编码
Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。
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数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种,因此:
1、当我们称呼一个数据库键为字符串键时,我们指的是这个数据库键所对应的值为字符串对象。
2、我们称呼一个键为列表键时,我们指的是这个数据库键所对应的值为列表对象。
TYPE命令的实现方式也与此类似,当我们对一个数据库键执行TYPE命令时,命令返回的结果为数据库键所对应的值对象的类型,而不是键对象的类型。
对象 | 对象type属性的值 | TYPE命令 |
---|---|---|
字符串对象 | REDIS_STRING | “string” |
列表对象 | REDIS_LIST | “list” |
哈希对象 | REDIS_HASH | “hash” |
集合对象 | REDIS_SET | “set” |
有序集合对象 | REDIS_ZSET | “zset” |
编码和底层实现
对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。
encoding属性记录了对象所使用的编码,也即是这个对象使用了什么数据结构作为对象的底层实现。
编码常量 | 编码所对应的底层数据结构 |
---|---|
REDIS_ENCODING_INT | long类型的整数 |
REDIS_ENCODING_EMBSTR | emberstr编码的简单动态字符串 |
REDIS_ENCODING_RAW | j简单动态字符串 |
REDIS_ENCODING_HT | 字典 |
REDIS_ENCODING_LINKEDLIST | 双端链表 |
REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_ENCODING_INTSET | 整数集合 |
REDIS_ENCODING_SKIPLIST | 跳跃表和字典 |
每种类型的对象都至少使用了两种不同的编码。
类型 | 编码 | 对象 |
---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | 使用整数值实现的字符串对象 |
REDIS_STRING | REDIS_ENCODING_EMBSTR | 使用embstr编码的简单动态字符串实现的字符串对象 |
REDIS_STRING | REDIS_ENCODING_RAW | 使用简单动态字符串实现的字符串对象 |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的列表对象 |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 使用双端链表实现的列表对象 |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用压缩链表实现的哈希对象 |
REDIS_HASH | REDIS_ENCODING_HT | 使用字典实现的哈希对象 |
REDIS_SET | REDIS_ENCODING_INTSET | 使用整数集合实现的集合对象 |
REDIS_SET | REDIS_ENCODING_HT | 使用字典实现的集合对象 |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的有序集合对象 |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 使用跳跃表和字典实现的有序集合对象 |
使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码。
对象所使用的的底层数据结构 | 编码常量 | OBJECT ENCODING |
---|---|---|
整数 | REDIS_ENCODING_INT | “int” |
embstr编码的简单动态字符串(SDS) | REDIS_ENCODING_EMBSTR | “embstr” |
简单动态字符串 | REDIS_ENCODING_RAW | “raw” |
字典 | REDIS_ENCODING_HT | “hashtable” |
双端链表 | REDIS_ENCODING_LINKEDLIST | “linkedlist” |
压缩列表 | REDIS_ENCODING_ZIPLIST | “ziplist” |
整数集合 | REDIS_ENCODING_INTSET | “intset” |
跳跃表和字典 | REDIS_ENCODING_SKIPLIST | skiplist |
通过encoding属性来设定对象所使用的编码,而不是为了特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一个场景下的效率。
举个例子,在列表对象包含的元素比较少时,Redis使用压缩列表作为列表对象的底层实现:
- 因为压缩列表比双端链表更节约内存,并在元素数量较少时,在内存中可以连续块的方式保存的压缩列表比起双端列表可以更快被载入到缓存中。
- 随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转向功能更强,也更适合保存大量元素的双端链表上面。
其他类型的对象也会通过使用多种不同的编码来进行类似的优化。
字符串对象
字符串对象的编码可以是int、raw或者embstr。
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面,并将字符串对象的编码设置为int。
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于39字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于39字节,那么字符串对象将使用embstr编码的方式来保存这个字符串的值。
embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构。
embstr编码的字符串对象在执行命令时,产生的效果和raw编码的字符串对象执行命令时产生的效果是相同的,但使用embstr编码的字符串对象来保存短字符值有以下好处:
1、embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。
2、释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数。
3、因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。
最后要说的是,可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的。如果我们要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串的值,然后在保存转换所得的字符串的值。
编码的转换
int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象。
对于int编码的字符串对象来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw。
另外,因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序,所以embstr编码的字符串对象实际上是只读的。当我们对embstr编码的字符串对象执行任何修改命令时,程序会首先将对象的编码从embstr转换成raw,然后再执行修改命令。因为这个原因,embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象。
列表对象
列表对象的编码可以是ziplist或者linkedlist。
ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。
另一方面,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(Node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。
编码转换
当列表对象可以同时满足一下两个条件时,对象列表使用ziplist编码:
1、列表对象保存的所有字符串元素的长度都小于64字节。
2、列表对象保存的元素数量小于512个,不能满足这两个条件的列表对象需要使用linkedlist编码。
对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码也会从ziplist变为linkedlist。
命令 | int编码的实现方法 | embstr编码的实现方法 | raw编码的实现方法 |
---|---|---|---|
SET | 使用int编码保存值 | 使用embstr编码保存值 | 使用raw编码保存值 |
GET | 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,然后向客户端返回这个字符串的值 | 直接向客户端返回字符串值 | 直接向客户端返回字符串值 |
APPEND | 将对象转换成raw编码,然后按raw编码的方式执行此操作 | 将对象转换成raw编码,然后按raw编码的方式执行此操作 | 调用sdscatlen函数,将给定字符串追加到现有字符串的末尾 |
INCRBYFLOAT | 取出整数值并将其转换成long double类型的浮点数,对这个浮点数进行加法计算,然后将得出的浮点数结果保存起来 | 取出字符串值并尝试将其转换成long double类型的浮点数,对这个浮点数进行加法计算,然后得出的浮点数结果保存起来。如果字符串值不能被转换成浮点数,那么向客户端返回一个错误 | 取出字符串值并尝试将其转换成long double类型的浮点数,对这个浮点数进行加法计算,然后得出的浮点数结果保存起来。如果字符串值不能被转换成浮点数,那么向客户端返回一个错误 |
INCRBY | 对整数值进行加法计算,得出的计算结果会作为整数被保存起来 | ebmstr编码不能执行此命令,向客户端返回一个错误 | raw编码不能执行此命令,向客户端返回一个错误 |
DECRBY | 对整数值进行减法计算,得出的计算结果会作为整数被保存起来 | ebmstr编码不能执行此命令,向客户端返回一个错误 | raw编码不能执行此命令,向客户端返回一个错误 |
STRLEN | 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,计算并返回这个字符串值的长度 | 调用sdslen函数,返回字符串的长度 | 调用sdslen函数,返回字符串的长度 |
SETRANGE | 将对象转换成raw编码,然后按raw编码的方式执行此命令 | 将对象转换成raw编码,然后按raw编码的方式执行此命令 | 将字符串特定的索引上的值设置为给定的字符 |
GETRANGE | 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,然后取出并返回字符串特定索引上的字符 | 直接取出并返回字符串指定索引上的字符 | 直接取出并返回字符串指定索引上的字符 |
列表对象
列表对象的编码可以是ziplist或者linkedlist。
ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。
编码转换
当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:
- 列表对象保存的所有字符串元素的长度都小于64字节。
- 列表对象保存的元素数量小于512个,不能满足这两个条件的列表对象需要使用的linkedlist编码。
对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有列表元素都会被转移并保存在双端链表里面,对象的编码也会从ziplist变为linkedlist。
命令 | ziplist编码的实现方法 | linkedlist编码的实现方法 |
---|---|---|
LPUSH | 调用ziplistPush函数,将新元素推入到压缩列表的表头 | 调用listAddNodeHead函数,将新元素推入到双端链表的表头 |
RPUSH | 调用ziplistPush函数,将新元素推入到压缩列表的表尾 | 调用listAddNodeHead函数,将新元素推入到双端链表的表尾 |
LPOP | 调用ziplistIndex函数定位压缩列表的表头节点,在向用户返回节点所保存的元素之后,调用ziplistDelete函数删除表头节点 | 调用listFirst函数定位双端链表的表头节点,在向用户返回节点所保存的元素后,调用listDelNode函数删除表头节点 |
RPOP | 调用ziplistIndex函数定位压缩列表的表尾节点,在向用户返回节点所保存的元素之后,调用ziplistDelete函数删除表尾节点 | 调用listFirst函数定位双端链表的表尾节点,在向用户返回节点所保存的元素后,调用listDelNode函数删除表尾节点 |
LINDEX | 调用ziplistIndex函数定位压缩列表中的指定节点,然后返回节点所保存的元素 | 调用listIndex函数定位压缩列表中的指定节点,然后返回节点所保存的元素 |
LLEN | 调用ziplistlen函数返回压缩列表的长度 | 调用listlen函数返回压缩列表的长度 |
LINSERT | 插入新节点到压缩列表的表头或者表尾时,使用ziplistPush函数;插入新节点到压缩列表的其他位置时,使用ziplistInsert函数 | 调用listInsertNode函数,将新节点插入到双端链表的指定位置 |
LREM | 遍历压缩列表节点,并调用ziplistDelete函数删除包含了给定元素的节点 | 遍历双端链表节点,并调用listDelNode函数删除包含了给定元素的节点 |
LTRIM | 调用ziplistDeleteRange函数,删除压缩列表中所有不在指定索引范围内的节点 | 遍历双端链表节点,并调用listDelNode函数删除链表中所有不在指定索引范围内的节点 |
LSET | 调用ziplistDelete函数,先删除压缩链表指定索引上的现有节点,然后调用ziplistInsert函数,将一个包含给定元素的新节点插入到相同索引上面 | 调用listIndex函数,定位到双端链表指定索引上的节点,然后通过赋值操作更新节点的值 |
哈希对象
哈希对象的编码可以是ziplist或者hashtable。
ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:
1、保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后。
2、先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
举个例子,如果我们执行一下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编码,那么这个值对象将会如下图所示,
另一方面,hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:
1、字典的每个键都是一个字符串对象,对象中保存了键值对的键。
2、字典的每个值都是一个字符串对象,对象中保存了键值对的值。
编码转换
当哈希对象可以同时满足一下两个条件时,哈希对象使用ziplist编码:
1、哈希对象保存的所有键值对的键和值的字符串长度都小于64字节。
2、哈希对象保存的键值对数量小于512个,不能满足这两个条件的哈希对象需要使用hashtable编码。
对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有键值对都会被转移并保存到字典里面,对象的编码也会从ziplist变为hashtable。
集合对象
集合对象的编码可以是intset或者hashtable。
intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。
编码的转换
当集合对象可以同时满足一下两个条件时,对象使用intset编码:
1、集合对象保存的所有元素都是整数值。
2、集合对象保存的元素数量不超过512个。
不能满足这两个条件的集合对象都需要使用hashtable编码。
对于使用intset编码的集合对象来说,当使用intset编码所需的两个条件的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在整数集合中的所有元素都会被转移并保存到字典里面,并且对象的编码也会从intset变为hashtable。
有序集合对象
有序集合的编码可以是ziplist或者skiplist。
ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。
压缩列表内的集合元素按分值从小到大排序,分值较小的元素被放置在靠近表头的位置,而分值较大的元素则被放置在靠近表尾的位置。
zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围操作。
除此之外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了这个元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定的成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现内部用到了这一特性。
有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。值得一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或分值,也不会因此而浪费额外的内存。
为什么有序集合需要同时使用跳跃表和字典来实现?在理论上,有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,在性能上对比同时使用字典和跳跃表都会有所降低。举个例子,如果我们只使用字典来实现有序集合,那么虽然O(1)复杂度查找成员的分值这一特性会被保留,但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围操作-比如ZRANK、ZRANGE等命令时,程序需要对字典保存的所有元素进行排序,完成这种排序至少需要O(LogN)时间复杂度,以及额外的O(N)内存空间。另一方面,如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了字典,为了让有序集合的查找和范围型操作都尽可能块地执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合。
类型检查与命令多态
Redis中用于操作键的命令基本上可以分为两种类型。
其中一种命令可以对任何类型的键执行,比如说DEL命令,EXPIRE命令,RENAME命令,TYPE命令,OBJECT命令等。
而另一种命令只能对特定类型的键执行,比如说:
- SET、GET、APPEND、STRLEN等命令只能对字符串键执行。
- HDEL、HSET、HGET、HLEN等命令只能对哈希键执行。
- RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行。
- SADD、SPOP、SINTER、SCARD等命令只能对集合键执行。
- ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行。
类型检查的实现
从上面发生类型错误的代码实例可以看出,为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令之前,Redis会先检查插入键的类型是否正确,然后再决定是否执行给定的命令。
类型特定命令进行的类型检查是通过redisobject结构的type属性来实现的:
- 在执行一个类型特定的命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令。
- 否则,服务器将拒绝执行命令,并向客户端返回一个类型错误。
重点回顾
1、Redis数据库中的每个键值对的键和值都是一个对象。
2、Redis共有字符串、列表、哈希、集合、有序集合五种类型的对象,每种类型的对象至少都有两种或以上的编码的方式,不同的编码可以在不同的使用场景上优化对象的使用效率。
3、服务器在执行某些命令之前,会先检查给定键的类型能否执行指定的命令,而检查一个键的类型就是检查键的值对象的类型。
4、Redis的对象系统带有引用计数实现的内存回收机制,当一个对象不再被使用时,该对象所占用的内存就会被自动释放。
5、Redis会共享值为0到9999的字符串对象。
6、对象会记录自己的最后一次被访问的时间,这个时间可以用于计算对象的空转时间。