Redis实现之对象(四)

类型检查与命令多态

Redis中用于操作键的命令基本上可以分为两种类型:其中一种命令可以对任何类型的键执行,比如DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等。举个栗子,DEL命令可以用来删除三种不同类型的键:

# 字符串键
127.0.0.1:6379> SET msg "hello"
OK
# 列表键
127.0.0.1:6379> RPUSH numbers 1 2 3
(integer) 3
# 集合键
127.0.0.1:6379> SADD fruits apple banana cherry
(integer) 3
127.0.0.1:6379> DEL msg
(integer) 1
127.0.0.1:6379> DEL numbers
(integer) 1
127.0.0.1:6379> DEL fruits
(integer) 1

  

而另一种命令只能对特性类型执行,比如:

  • SET、GET、APPEND、STRLEN等命令只能对字符串键执行
  • HDEL、HSET、HGET、HLEN等命令只能对哈希键执行
  • RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行
  • SADD、SPOP、SINTER、SCARD等命令只能对集合键执行
  • ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行

举个栗子,我们可以用SET命令创建一个字符串键,然后用GET命令和APPEND命令操作这个键,但如果我们试图对这个字符串键执行列表键才能执行的LLEN命令,那么Redis将向我们返回一个类型错误:

127.0.0.1:6379> SET msg "hello"
OK
127.0.0.1:6379> GET msg
"hello"
127.0.0.1:6379> APPEND msg "again!"
(integer) 11
127.0.0.1:6379> LLEN msg
(error) WRONGTYPE Operation against a key holding the wrong kind of value

  

类型检查和实现

从上面发生类型错误的代码可以看出,为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令前,Redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令。类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的:

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

举个栗子,对于LLEN命令来说:

  • 在执行LLEN命令之前,服务器会先检查输入数据库键的值对象是否为列表类型,即检查值对象redisObject结构的type属性是否为REDIS_LIST,如果是的话执行命令
  • 否则的话,服务器就拒绝执行命令,并向客户端返回一个类型错误,图1-18展示了这一类型检查过程

图1-18   LLEN命令执行时的类型检查过程

多态命令的实现

Redis除了会根据值对象的类型来判断键是否能够执行指令外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令。举个栗子,列表对象有ziplist和linkedlist两种编码可用,其中前者使用压缩列表API来实现列表命令,而后者使用双端链表来实现列表命令

现在,考虑这样一个情况,如果我们对一个键执行LLEN命令,那么服务器除了要确保执行命令的是列表键之外,还要根据键的值对象所使用的编码方式来选择正确的LLEN命令实现:

  • 如果列表对象的编码为ziplist,程序将使用ziplistLen函数来返回列表的长度
  • 如果列表对象的编码为linkedlist,程序将使用listLength函数来返回双端链表的长度

借用面向对象方面的术语来说,我们可以认为LLEN命令时多态的,只要执行LLEN命令的是列表键,那么无论值对象时压缩列表还是双端链表,命令都可以正常执行。实际上,我们可以将DEL、EXPIRE、TYPE等命令也称为多态命令,因为无论输入的键是什么类型,这些命令都可以正确地执行。图1-19展示了LLEN命令从类型检查到根据编码选择实现函数的整个执行过程,其他类型特定命令的执行过程也是类似

图1-19   LLEN命令的执行过程

DEL、EXPIRE等命令和LLEN等命令的区别在于,前者是基于类型的多态——一个命令可以同时用于处理多种不同类型的键,而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码

内存回收

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

redis.h

typedef struct redisObject {
    unsigned type:4;/* Not used */
    unsigned encoding:4;
    unsigned lru:22;     
	//引用计数
    int refcount;
    void *ptr;
} robj;

  

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

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

表1-12列出了修改对象引用计数的API,这些API分贝用于增加、减少、重置对象的引用计数

表1-12   修改对象引用计数的 API
函数 作用
incrRefCount 将对象的引用计数值增一
decrRefCount 将对象的引用计数值减一,当对象的引用计数值等于0时,释放对象
resetRefCount 将对象的引用计数值设置为0,但并不释放对象,这个函数通常在需要重新设置对象的引用计数值时使用

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

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

// 对象 s 执行各种操作 ...

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

  

其他不同类型的对象也会经历类似的过程

对象共享

除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。举个栗子,假设键A创建了一个包含整数值100的字符串对象作为值对象,如图1-20所示

图1-20   未被共享的字符串对象

如果这时键B也要创建一个同样保存了整数值100的字符串对象作为值对象,那么服务器有以下两种做法:

  • 为键B新建一个包含整数值100的字符串对象
  • 让键A和键B共享一个字符串对象

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

  • 将数据库键的值指针指向一个现有的值对象
  • 将被共享的值对象的引用计数加1

举个栗子,图1-21就展示了包含整数值100的字符串对象同时被键A和键B共享之后的样子,可以看到,除了对象的引用计数从之前的1变成2之外,其他属性并无变化。共享对象机制对节约内存非常有帮助,数据库中保存的相同值对象越多,对象共享机制就越能节约越多的内存

图1-21   被共享的字符串对象

例如,假设数据库中保存了整数值100的键不只有键A和键B两个,而是由一百个,那么服务器只需要用一个字符串对象的内存就可以保存原本需要使用一个白字符串对象的内存才能保存的数据。目前来说,Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串时,服务器就会使用这些共享对象,而不是新创建对象。另外,创建共享字符串的数量可以通过修改redis.h/REDIS_SHARED_INTEGERS来修改

举个栗子,如果我们创建一个值为100的键A,并使用OBJECT REFCOUNT命令查看键A的值对象的引用计数,我们会发现值对象的引用计数为2

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

  

引用这个值对象的两个程序分别是持有这个值对象的服务器程序,以及共享这个值对象的键A,如图1-22所示

图1-22   引用数为2的共享对象

如果这时我们再创建一个值为100的键B,那么键B也会指向包含整数值100的共享对象,使得共享对象的引用计数为3:

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

  

图1-23展示了共享值对象的三个程序

图1-23   引用数为3的共享对象

另外,这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象、以及zset编码的有序集合对象)都可以用这些共享对象

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

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

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

对象的空转时长

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

redis.h

typedef struct redisObject {
    unsigned type:4;
    unsigned notused:2;    
    unsigned encoding:4;
    unsigned lru:22;       
    int refcount;
    void *ptr;
} robj;

  

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

127.0.0.1:6379> SET msg "hello world"
OK
# 等待一小段时间
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 28
# 等待一小段时间
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 51
# 访问 msg 键的值
127.0.0.1:6379> GET msg
"hello world"
# 键处于活跃状态,空转时长为 3
127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 3

  

OBJECT IDLETIME命令的实现是特殊的,这个命令在访问键时不会修改lru属性。除了可以被OBJECT IDLETIME命令打印出来之外,键的空转时长还有另外一项作用:如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存超过maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存

配置文件的maxmemory选项和maxmemory-policy选项的说明介绍了关于这方面的更多信息

posted @ 2018-10-02 19:01  北洛  阅读(290)  评论(0编辑  收藏  举报