一 数据结构与对象
7 对象
7.1 对象的类型与编码
Redis使用对象来表示数据库中的键和值,每创建一个键值对时,会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。
每个对象都使用同一种数据结构表示:RedisObject
typedef struct redisObject{
//类型
unsigned type:4;
//编码
unsigned encoding:4;
//指向其具体实现数据结构的指针
void *ptr;
}robj;
7.1.1 type
type属性记录了对象的类型,这个属性是以下常量中的一个。
我们可以使用TYPE 键,来返回值的类型。
类型常量 | 对象名称 | TYPE命令的输出 |
---|---|---|
REDIS_STRING | 字符串对象 | string |
REDIS_LIST | 列表对象 | list |
REDIS_HASH | 哈希对象 | hash |
REDIS_SET | 集合对象 | set |
REDIS_ZSET | 有序集合对象 | zset |
7.1.2 encoding
ptr指向一个底层实现数据结构,而数据结构由encoding决定,encoding记录对象所使用的编码,也就是对象使用怎样的数据结构。
对象的编码(使用OBJECT ENCODING 键,可以返回值的底层数据结构):
编码常量 | 编码对应的数据结构 | OBJECT ENCODING命令输出 |
---|---|---|
REDIS_ENCODING_INT | long类型的整数 | int |
REDIS_ENCODING_EMBSTR | 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 |
不同类型的对象可以使用的编码对象
类型 | 编码 |
---|---|
REDIS_STRING | REDIS_ENCODING_INT,REDIS_ENCODING_EMBSTR,REDIS_ENCODING_RAW |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST,REDIS_ENCODING_ZIPLIST |
REDIS_HASH | REDIS_ENCODING_HT,REDIS_ENCODING_ZIPLIST |
REDIS_SET | REDIS_ENCODING_HT,REDIS_ENCODING_INTSET |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST,REDIS_ENCODING_SKIPLIST |
使用encoding来设定对象使用的编码,而不是特定的对象关联一种数据结构,可以极大的提升Redis的灵活性和效率,比如列表对象数据少的时候就使用压缩列表,多的时候就使用双端链表。
7.2 字符串对象
字符串对象的编码可以是int,raw,embstr
- 如果字符串对象保存的是一个整数值,且可以用long类型来表示,那么对象的ptr会为long类型,编码设置为int。
- 如果保存的是一个字符串值
- 若长度大于39字节,那么字符串对象使用一个简单动态字符串(SDS)来保存这个字符串值,编码设置为raw;
- 若长度小于等于39字节,那么字符串对象使用embstr编码。
- 用long double类型表示的浮点数在redis中也作为字符串来保存,在使用时,会先从字符串传化为浮点数,再拿来使用。
7.2.1 embstr与raw的区别
embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码与raw的相同之处在于,都使用redisObject结构和sdshdr结构来表示字符串对象。但raw编码是两次调用内存分配函数,而embstr是一次调用内存分配函数,创建一串连续化的空间来保存redisObject和sdshdr。
在raw中,分别调用两次内存分配,创建redisObject和sdshdr,用redisObject的ptr指向sdshdr。
在embstr,直接申请一串连续空间来连续储存redisObject和sdshdr,ptr指向的就是它的下一部分空间。
7.2.2 编码的转换
在使用int,embstr,raw三种编码格式的字符串时,他们是可以相互转化的
- 比如对int编码的字符串执行append操作添加非数字字符串,或者添加数字字符串到long存不下,int编码就变成raw。
- Redis里面没有为embstr编码字符串对象编写任何相应的修改程序(只有int和raw有),所以embstr编码的字符串实际上是只读的,为其执行读以外命令时embstr会变为raw。
7.2.3 字符串命令的实现
命令 | int编码实现方法 | embstr编码实现方法 | raw编码实现方法 |
---|---|---|---|
SET | 使用int编码保存值 | 使用embstr编码保存值 | 使用raw编码保存值 |
GET | 拷贝这个整数值,然后转换为字符串以后返回 | 直接返回字符串 | 直接返回字符串 |
APPEND | 将对象转换为raw编码,然后调用raw的对应方法 | 将对象转换为raw编码,然后调用raw的对应方法 | 调用sdscatlen函数,将给的字符串添加到原字符串后面 |
INCRBYFLOAT | 取出整数并转换为long double类型的浮点数,然后对这个浮点数进行计算,再将新得到的浮点数保存起来 | 取出字符串并尝试转换为long double类型的浮点数,转化成功就对这个浮点数进行计算,再将新得到的浮点数保存起来;转换失败返回错误 | 取出字符串并尝试转换为long double类型的浮点数,转化成功就对这个浮点数进行计算,再将新得到的浮点数保存起来;转换失败返回错误 |
INCRBY/DECRBY | 取出整数并对这个浮点数进行加法/减法计算,再将新得到的整数数保存起来 | embstr编码不能执行这两个命令,返回一个错误 | raw编码不能执行这两个命令,返回一个错误 |
STRLEN | 拷贝对象保存的整数,将整数转换为字符串值,用sdslen返回字符串长度 | 用sdslen返回字符串长度 | 用sdslen返回字符串长度 |
SETRANGE | 将对象转化为raw编码,用raw的对应方法 | 将对象转化为raw编码,用raw的对应方法 | 将字符串指定索引上的值修改为指定值 |
GETRANGE | 拷贝对象保存的整数,将整数转换为字符串值,再返回特点索引上的字符 | 直接返回特点索引上的字符 | 直接返回特点索引上的字符 |
7.3 列表对象
列表对象的编码可以为ziplist或者linkedlist,ziplist编码的列表对象使用压缩列表作为底层实现,linkedlist使用双端链表作为底层实现(使用linkedlist时,其每个节点又包含一个字符串对象)
7.3.1 编码转换
使用ziplist作为底层实现的条件:
- 列表对象所有字符串元素的长度都小于64byte
- 列表对象保存的元素小于512个
这两个数值的大小可以在配置文件中修改
若以上两个条件都满足时,使用ziplist作为底层实现,一旦其中一个不满足,编码就会从ziplist转换为linkedlist。
7.3.2 列表命令的实现
命令 | 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函数,先删除索引的指定节点,在调用ziplistInser函数将新元素插入指定索引上 | 调用listIndex函数,定为到双端链表指定索引上的节点,通过赋值操作更新节点上的值 |
7.4 哈希对象
哈希对象的编码可以是ziplist或hashtable。
ziplist使用压缩列表作为底层实现,程序在需要保存一对新的键值对时,先将键放在表尾,紧接着将值放在当前的表尾。
- 保存的统一键值对的两节节点总是紧挨在一起,键在前方,值在后方。
- 先添加到哈希对象的键值对会放在压缩列表的表头方向,后添加的在表尾方向。
另一方面,hashtable编码的哈希对象使用字典作为底层实现,哈希对象的每个键值对使用一个字典的一个哈希表节点来存放。
- 哈希表节点中的键是一个字符串对象,对象中保存了键值对的键。
- 哈希表节点中的值是一个字符串对象,对象中保存了键值对的值。
7.4.1 编码转换
当哈希对象满足以下两个条件是,哈希对象使用ziplist编码
- 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
- 哈希对象的键值对数要小于512个
这两个数值的大小可以在配置文件中修改
一旦不能满足这两个条件任意一个的哈希对象需要使用hashtable编码
7.4.2 哈希命令的实现
命令 | ziplist编码实现方法 | hashtable编码实现方法 |
---|---|---|
HSET | 使用ziplistpush依次将键和值插入到压缩列表表尾 | 调用dictAdd,将保存了键值对的哈希表节点添加到字典中 |
HGET | 通过ziplistFind找到指定键,然后调用ziplistNext移动指针到下一个节点返回节点值 | 调用dictFind函数,在字典中查找给定键,接着调用dictGetVal函数返回键对应的值 |
HEXISTS | 调用ziplistFind函数查找键,找到说明存在,找不到就不存在 | 调用dictFind函数,在字典中查找给定键,找到说明存在,找不到就不存在 |
HDEL | 调用ziplistFind函数查找键,删除键及旁边的值 | 调用dictDelete删除指定键值对对应的哈希表节点 |
HLEN | 调用ziplistLen,取得压缩列表的总节点数,接着除以2就是键值对的数量 | 调用dictSize函数,返回自动中的键值对数量 |
HGETALL | 遍历整个压缩列表,调用ziplistGet函数返回所以键值对 | 遍历整个字典,用dictGetKey函数返回所有键,用dictGetVal函数返回所有值 |
7.5 集合对象
集合对象使用的编码为intset或者hashtable
- intset编码的集合使用整数集合作为底层实现,集合对象包含的元素都被保存在整数集合中
- hashtable编码的集合对象使用字典作为底层实现,字典的中每个哈希表节点的键是一个字符串,用来保存一个集合元素,而值全部置空。
7.5.1 编码的转换
当集合满足下面两个条件时,对象使用intset编码
- 集合对象保存的所有元素都是整数值
- 集合对象保存的元素数量不超过512个
以上两个条件只要有一个不满足,就会使用hashtable编码
可在配置文件中修改参数
7.5.2 集合命令的实现
命令 | intset编码的实现方法 | hashtable编码的实现方法 |
---|---|---|
SADD | 调用intsetAdd函数来添加新元素 | 调用dictAdd,以新元素为键,NULL为值 |
SCARD | 调用intsetLen函数返回整数集合的长度 | 调用dictSize函数,返回字典所包含的键值对数量 |
SISMEMBER | 调用intsetFind函数,在集合中查找指定元素,找到返回true,没找到返回false | 调用dictFind函数,从字典中查找给定的元素,找到返回true,没找到返回false |
SMEMBERS | 遍历整个整数集合,使用intsetGet函数返回整个集合所有元素 | 遍历整个字典,使用dictGetKey返回字典中所有键 |
SRANDMEMBER | 调用intsetRandom函数,从整数集合中随机返回一个元素 | 调用dictGetRandomKey函数,从字典中返回一个字典键 |
SPOP | 调用intsetRandom函数,从整数集合中随机返回一个元素,然后用intsetRemove函数,将这个随机元素删除掉 | 调用dictGetRandomKey函数,从字典中返回一个字典键,然后用dictDelete函数删除这个键值对 |
SREM | 用intsetRemove函数,将指定元素删除掉 | 用dictDelete函数删除这个键值对 |
7.6 有序集合对象
有序集合的编码可以是ziplist或者skiplist,成员都是字符串,分值都是double类型浮点数。
- ziplist编码的有序集合对象使用压缩列表作为底层实现。
- 每个节点使用两个挨着的节点来保存,第一个保存元素的成员,第二个保存元素的分值。
- 压缩列表内的集合元素按分值从小到大进行排序,分值小的在表头,分值大的在表尾。
typedef struct zset{
zskiplist *zsl;
dict *dict;
}zset;
skiplist编码的有序集合使用zset结构作为底层实现,一个zset结构里面同时包括一个字典和一个跳跃表
- zset的zsl跳跃表按分值从小到大保存所有集合元素,每个跳跃表节点保存员工集合元素:跳跃表节点的object属性保存了该元素的值,score属性保存该元素的分值,通过跳跃表,出现就能对有序集合进行zrank,zrange这样的范围性操作。
- 而使用字典,以成员为键,分值为值,程序就能使用O(1)复杂度查到成员的值
注:使用两种数据结构来实现,结合两种数据结构的优点,在执行zrank或者zrange这样的范围性操作时,若使用字典保存就需要排序一次,至少为O(N*logN)的复杂度加上额外O(N)的空间,但有跳跃表的存在就避免了这样的复杂度,而在查找成员对应的分值时,若只有跳跃表,就需要O(logN)的时间复杂度,字典的存在,只需要O(1)的时间复杂度。
两种数据结构可能会被认为值保存了两次,浪费了内存,其实不然,值只有一份,两种结构里面保存的是值的指针,指向值
7.6.1 编码的转换
当有序集合对象同时满足以下两个条件时,对象使用ziplist编码,任意一个不满足时使用zset编码。
- 有序集合保存的元素数量小于128个;
- 有序集合保存的所有元素成员的长度都小于64字节
以上两个属性可以通过设置conf文件来修改
7.6.2 有序集合命令的实现
命令 | ziplist编码的实现 | zset编码的实现 |
---|---|---|
ZADD | 调用ziplistInset函数将成员与分值作为两个节点进行插入 | 先调用zslInsert函数,将新元素添加到跳跃表,再调用dictAdd函数将新元素关联到字典 |
ZCARD | 调用ziplistLen函数获得压缩列表包含节点的数量,将这个数量除以2得出集合元素的数量 | 访问跳跃表的length属性得到元素数量 |
ZCOUNT | 遍历整个列表统计分值在指定范围内元素数量 | 遍历跳跃表统计分值在指定范围内元素数量 |
ZRANGE | 表头到表尾遍历压缩列表,返回索引范围的所有值 | 表头到表尾遍历跳跃表,返回索引范围的所有值 |
ZREVRANGE | 表尾到表头遍历压缩列表,返回索引范围的所有值 | 表尾到表头遍历跳跃表,返回索引范围的所有值 |
ZRANK | 表头到表尾遍历压缩列表查找指定成员,并沿途记录节点个数,最后返回统计的节点个数作为rank | 表头到表尾遍历跳跃表查找指定成员,并沿途记录节点个数,最后返回统计的节点个数作为rank |
ZREVRANK | 表尾到表头遍历压缩列表查找指定成员,并沿途记录节点个数,最后返回统计的节点个数作为rank | 表尾到表头遍历跳跃表查找指定成员,并沿途记录节点个数,最后返回统计的节点个数作为rank |
ZREM | 遍历压缩列表,删除所有包含指定成员的节点及其旁边的分值节点 | 遍历跳跃表,删除所有包含给定成员的跳跃表节点,并在字典中删除其相关的关联 |
ZSCORE | 遍历压缩列表,查找包含指定成员的节点及其旁边的分值节点 | 直接从字典中取分值 |
7.7 类型检查与命令多态
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会返回一个类型错误
redis>SET msg "hello world"
OK
redis>LLEN msg
(error) WRONGTYPE Operation against a key holding the wrong kind of value
7.7.1 类型检查的实现
由上的错误可以看出,为确保命令对应了正确的键,在执行命令之前,会有一个检查的步骤,检查输入键的类型是否正确,而这个检查是利用redisObject结构的type属性来实现。
执行特定命令之前,服务器会检查输入数据库键的值对象是否为执行命令对应类型,是就执行命令,否则会返回一个类型错误
7.7.2 多态命令的实现
Redis除了会根据对象类型来判断是否可以执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码执行命令。
例如列表对象的编码有ziplist和linkedlist两种编码,当执行LLEN命令,那么首先需要确定是列表键,其次还得根据编码格式来选择命令的正确实现
- 若为编码为ziplist,那么说明列表对象的实现为压缩列表,程序调用ziplistLen函数
- 若为编码为linkedlist,那么说明列表对象的实现为双端链表,程序调用listLength函数
从面对对象方面来讲,就认为LLEN指令是多态的,无论列表对象是那种编码,LLEN都可以正确使用。
DEL这类命令是基于类型的多态,LLEN这类命令是基于编码的多态。
7.8 内存回收
C语言不具备自动内存回收功能,所以Redis在对象系统中构建了一个引用计数来实现内存回收机制
typedef struct redisObject{
//...
//引用计数
int refcout;
//...
}robj;
- 新创建对象时,引用计数的值会被初始化为1;
- 当对象被一个新程序,引用计数加1;
- 不再被一个程序使用时,它的引用计数值减1;
- 当对象的引用计数值变为0时,对象所占用的内存会被释放;
7.9 对象共享
对象的引用计数属性还带有对象共享作用,若键A创建了值为100的字符串对象,这时键B也创建了值为100的字符串对象。这时会让键A和键B共享这个对象。
共享步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增加1
Redis在初始化服务器时,创建了0-9999一万个字符串对象,在使用到这些对象时,就会使用这些共享对象,而不会再创建新的对象。
共享对象不仅字符串键可以使用,数据结构中嵌套的字符串对象(linkedlist编码的列表对象,hashtable编码的哈希对象…)也能使用共享对象
为什么不共享包含字符串的其他对象共享对象需要使用的对象完全相同,而另一个对象保存的值越复杂,验证相同所需要的复杂度就会越高,消耗的CPU时间就越多
- 如果保存整数值的字符串对象,验证操作的复杂度为O(1)
- 如果保存字符串值的字符串对象,那么验证操作复杂度为O(N)
- 如果个共享对象包含多个值(或对象),例如列表对象和哈希对象,那么验证的复杂度为O(N^2)
7.10 对象的空转时间
在Redis的对象结构包含的最后一个属性是lru属性,记录对象最后一次被命令程序访问的时间
typedef struct redisObject{
//...
unsigned lru:22;
//...
}robj;
使用OBJECT IDLETIME命令可以在不改变lru值的情况下,打印出键的空转时长。
空转时长=当前时间-lru
如果服务器打开maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或allkeys-lru,当服务器内存超过maxmemory的值时,空转时长较长的那部分键会被优先释放,从而回收内存。
maxmemory和maxmemory-policy(算法选项)可以在配置文件中修改。