Redis学习(二)基础数据对象

          Redis使用了6种简单基础数据结构(简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表)分别组合实现了字符串(string)、哈希(hash)、列表(list)、集合(set)和有序集合(sorted set)这五种类型的键的底层实现数据结构对象。

Redis对象

字符串对象(string)

列表对象(list)

哈希对象(hash)

集合对象(set)

有序集合对象(sorted set)

类型检查与命令多态

内存回收

对象共享

对象的空转时长

总结

Redis对象

        整体来看,所有Redis数据都是由键值对组成的,每次当我们在 Redis 的数据库中新创建一个键值对时, 我们至少会创建两个Redis对象, 一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)这里的键Key指的是字符串对象作为键,而值为字符串、哈希、列表、集合和有序集合5种对象中的一种,而且根据值使用的不同数据结构对象将键称为字符串键、列表键、哈希键、集合键和有序集合键

typedef struct redisObject {//Redis对象的结构

    // 类型
    unsigned type:4;

    // 编码
    unsigned encoding:4;

    // 指向底层实现数据结构的指针
    void *ptr;

    // ...

} robj;

对象的 type 属性记录了对象的类型, 这个属性的值可以是上述5大对象中的一个。

对象的 ptr 指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding 属性决定。encoding 属性记录了对象所使用的编码, 也即是说这个对象使用了什么数据结构作为对象的底层实现(关于Redis的基础数据结构可见Redis基础数据结构)。

编码常量编码所对应的底层数据结构
REDIS_ENCODING_INTlong 类型的整数
REDIS_ENCODING_EMBSTRembstr 编码的简单动态字符串
REDIS_ENCODING_RAW简单动态字符串
REDIS_ENCODING_HT字典
REDIS_ENCODING_LINKEDLIST双端链表
REDIS_ENCODING_ZIPLIST压缩列表
REDIS_ENCODING_INTSET整数集合
REDIS_ENCODING_SKIPLIST跳跃表和字典

每种类型的对象都至少使用了两种不同的编码,5大数据对象编码实现如下:

类型编码对象
REDIS_STRINGREDIS_ENCODING_INT使用整数值实现的字符串对象。
REDIS_STRINGREDIS_ENCODING_EMBSTR使用 embstr 编码的简单动态字符串实现的字符串对象。
REDIS_STRINGREDIS_ENCODING_RAW使用简单动态字符串实现的字符串对象。
REDIS_LISTREDIS_ENCODING_ZIPLIST使用压缩列表实现的列表对象。
REDIS_LISTREDIS_ENCODING_LINKEDLIST使用双端链表实现的列表对象。
REDIS_HASHREDIS_ENCODING_ZIPLIST使用压缩列表实现的哈希对象。
REDIS_HASHREDIS_ENCODING_HT使用字典实现的哈希对象。
REDIS_SETREDIS_ENCODING_INTSET使用整数集合实现的集合对象。
REDIS_SETREDIS_ENCODING_HT使用字典实现的集合对象。
REDIS_ZSETREDIS_ENCODING_ZIPLIST使用压缩列表实现的有序集合对象。
REDIS_ZSETREDIS_ENCODING_SKIPLIST使用跳跃表和字典实现的有序集合对象。

通过 encoding 属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提升了 Redis 的灵活性和效率, 因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率。例如,在列表对象包含的元素比较少时, Redis 使用压缩列表作为列表对象的底层实现,更节约内存。

字符串对象(string)

字符串对象的编码可以是 int 、 raw 或者 embstr 

  • 如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long 类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr属性里面(将 void* 转换成 long ), 并将字符串对象的编码设置为 int 。
  • 如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39 字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw 
  • 如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39 字节, 那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。embstr 编码是专门用于保存短字符串的一种优化编码方式。

如图为一个raw编码的字符串对象,类型为REDIS_StTRING表示字符串对象,编码为REDIAS_ENCODING_RAW表示raw编码方式,指针ptr指向一个SDS字符串。

digraph {      label = "\n å¾ 8-2    raw ç¼ç çå­ç¬¦ä¸²å¯¹è±¡";      rankdir = LR;      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_STRING | encoding \n REDIS_ENCODING_RAW | <ptr> ptr | ... "];      sdshdr [label = " <head> sdshdr | free \n 0 | len \n 43 | <buf> buf"];      buf [label = " { 'L' | 'o' | 'n' | 'g' | ... | 'k' | 'i' | 'n' | 'g' | ' ' | '.' | '.' | '.' | '\\0' } " ];      //      redisObject:ptr -> sdshdr:head;     sdshdr:buf -> buf;  }

编码的自动转换

int 编码的字符串对象和 embstr 编码的字符串对象在条件满足的情况下, 会被转换为 raw 编码的字符串对象。

对于 int 编码的字符串对象来说, 如果我们向对象执行了一些命令, 使得这个对象保存的不再是整数值, 而是一个字符串值, 那么字符串对象的编码将从 int 变为 raw 。

而对于embstr 编码,Redis没有为 embstr 编码的字符串对象编写任何相应的修改程序 (只有 int 编码的字符串对象和 raw 编码的字符串对象有这些程序), 所以 embstr 编码的字符串对象实际上是只读的: 当我们对 embstr 编码的字符串对象执行任何修改命令时, 程序会先将对象的编码从 embstr 转换成 raw , 然后再执行修改命令; 因为这个原因, embstr 编码的字符串对象在执行修改命令之后, 总会变成一个 raw 编码的字符串对象。

字符串命令的实现

因为字符串键的为字符串对象, 所以用于字符串键的所有命令都是针对字符串对象来构建的,下面列举了其中一部分字符串命令, 以及这些命令在不同编码的字符串对象下的实现方法。

命令int 编码的实现方法embstr 编码的实现方法raw 编码的实现方法
SET使用 int 编码保存值。使用 embstr 编码保存值。使用 raw 编码保存值。
GET拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后向客户端返回这个字符串值。直接向客户端返回字符串值。直接向客户端返回字符串值。
APPEND将对象转换成 raw 编码, 然后按 raw编码的方式执行此操作。将对象转换成 raw 编码, 然后按 raw编码的方式执行此操作。调用 sdscatlen 函数, 将给定字符串追加到现有字符串的末尾。
INCRBYFLOAT取出整数值并将其转换成 longdouble 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。取出字符串值并尝试将其转换成long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。取出字符串值并尝试将其转换成 longdouble 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。
INCRBY对整数值进行加法计算, 得出的计算结果会作为整数被保存起来。embstr 编码不能执行此命令, 向客户端返回一个错误。raw 编码不能执行此命令, 向客户端返回一个错误。
DECRBY对整数值进行减法计算, 得出的计算结果会作为整数被保存起来。embstr 编码不能执行此命令, 向客户端返回一个错误。raw 编码不能执行此命令, 向客户端返回一个错误。
STRLEN拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 计算并返回这个字符串值的长度。调用 sdslen 函数, 返回字符串的长度。调用 sdslen 函数, 返回字符串的长度。
SETRANGE将对象转换成 raw 编码, 然后按 raw编码的方式执行此命令。将对象转换成 raw 编码, 然后按 raw编码的方式执行此命令。将字符串特定索引上的值设置为给定的字符。
GETRANGE拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后取出并返回字符串指定索引上的字符。直接取出并返回字符串指定索引上的字符。直接取出并返回字符串指定索引上的字符。

列表对象(list)

列表对象的编码可以是 ziplist压缩列表 或者 linkedlist双端链表 

ziplist 编码的列表对象使用压缩列表作为底层实现, 每个压缩列表节点(entry)保存了一个列表元素。如下,为执行了 RPUSH numbers 1 "three" 5 命令后,列表采用 ziplist 编码时的存储结构。

digraph {      label = "\n å¾ 8-5    ziplist ç¼ç ç numbers å表对象";      rankdir = LR;      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_LIST | encoding \n REDIS_ENCODING_ZIPLIST | <ptr> ptr | ... "];      ziplist [label = " { zlbytes | zltail | zllen | 1 | \"three\" | 5 | zlend } "];      redisObject:ptr -> ziplist;  }

linkedlist 编码的列表对象使用双端链表作为底层实现, 每个双端链表节点(node)都保存了一个字符串对象, 而每个字符串对象都保存了一个列表元素。

digraph {      label = "\n å¾ 8-6    linkedlist ç¼ç ç numbers å表对象";      rankdir = LR;      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_LIST | encoding \n REDIS_ENCODING_LINKEDLIST | <ptr> ptr | ... "];      subgraph cluster_linked_list {          label = "é¾è¡¨";          style = dashed;          node1 [label = "StringObject \n 1 "];         node2 [label = "StringObject \n \"three\""];         node3 [label = "StringObject \n 5 "];          node1 -> node2 -> node3;      }      redisObject:ptr -> node1;  }

需要注意的是,linkedlist 编码的列表对象在底层的双端链表结构中包含了多个字符串对象, 这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现,字符串对象是 Redis 五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象

编码转换

当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist 编码:

  1. 列表对象保存的所有字符串元素的长度都小于 64 字节
  2. 列表对象保存的元素数量小于 512 个

不能满足这两个条件的列表对象需要使用 linkedlist 编码(以上两个条件的上限值是可以修改的, 具体请看配置文件中关于 list-max-ziplist-value 选项和 list-max-ziplist-entries 选项的说明。)。

列表命令的实现

因为列表键的值为列表对象, 所以用于列表键的所有命令都是针对列表对象来构建的。

命令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 函数, 先删除压缩列表指定索引上的现有节点, 然后调用 ziplistInsert 函数, 将一个包含给定元素的新节点插入到相同索引上面。调用 listIndex 函数, 定位到双端链表指定索引上的节点, 然后通过赋值操作更新节点的值。

哈希对象(hash)

哈希对象的编码可以是 ziplist压缩列表 或者 hashtable字典 

ziplist 编码的哈希对象使用压缩列表作为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此:

  • 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。

digraph {      label = "\n å¾ 8-9    ziplist ç¼ç ç profile åå¸å¯¹è±¡";      rankdir = LR;      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_HASH | encoding \n REDIS_ENCODING_ZIPLIST | <ptr> ptr | ... "];      ziplist [label = " å缩å表 ", width = 4.0];      redisObject:ptr -> ziplist;  }

digraph {      label = "\n å¾ 8-10    profile åå¸å¯¹è±¡çå缩å表åºå±å®ç°";      //      node [shape = record];      ziplist [label = " zlbytes | zltail | zllen | <key1> \"name\" | <value1> \"Tom\" | <key2> \"age\" | <value2> 25 | <key3> \"career\" | <value3> \"Programmer\" | zlend "];      node [shape = plaintext];      edge [style = dashed];      kv1 [label = "第ä¸ä¸ªæ·»å çé®å¼å¯¹"];     kv1 -> ziplist:key1 [label = "é®"];     kv1 -> ziplist:value1 [label = "å¼"];      kv2 [label = "第äºä¸ªæ·»å çé®å¼å¯¹"];     kv2 -> ziplist:key2;     kv2 -> ziplist:value2;      kvN [label = "ææ°æ·»å çé®å¼å¯¹"];     kvN -> ziplist:key3;     kvN -> ziplist:value3;  }

 hashtable 编码的哈希对象使用字典作为底层实现, 哈希对象中的每个键值对都使用一个字典键值对来保存:

  • 字典的每个键都是一个字符串对象, 对象中保存了键值对的键;
  • 字典的每个值都是一个字符串对象, 对象中保存了键值对的值。

digraph {      label = "\n å¾ 8-11    hashtable ç¼ç ç profile åå¸å¯¹è±¡";      rankdir = LR;      //      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_HASH | encoding \n REDIS_ENCODING_HT | <ptr> ptr | ... "];      dict [label = " <head> dict | <key1> StringObject \n \"age\" | <key2> StringObject \n \"career\" | <key3> StringObject \n \"name\" ", width = 1.5];      age_value [label = "StringObject \n 25"];     career_value [label = "StringObject \n \"Programmer\""];     name_value [label = "StringObject \n \"Tom\""];      //      redisObject:ptr -> dict:head;      dict:key1 -> age_value;     dict:key2 -> career_value;     dict:key3 -> name_value;  }

编码转换

当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist 编码:

  1. 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
  2. 哈希对象保存的键值对数量小于 512 个;

不能满足这两个条件的哈希对象需要使用 hashtable 编码,同样,这两个限值可以在配置文件里修改。

哈希命令的实现

命令ziplist 编码实现方法hashtable 编码的实现方法
HSET首先调用 ziplistPush 函数, 将键推入到压缩列表的表尾, 然后再次调用 ziplistPush 函数, 将值推入到压缩列表的表尾。调用 dictAdd 函数, 将新节点添加到字典里面。
HGET首先调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 然后调用 ziplistNext 函数, 将指针移动到键节点旁边的值节点, 最后返回值节点。调用 dictFind 函数, 在字典中查找给定键, 然后调用 dictGetVal 函数, 返回该键所对应的值。
HEXISTS调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 如果找到的话说明键值对存在, 没找到的话就说明键值对不存在。调用 dictFind 函数, 在字典中查找给定键, 如果找到的话说明键值对存在, 没找到的话就说明键值对不存在。
HDEL调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 然后将相应的键节点、 以及键节点旁边的值节点都删除掉。调用 dictDelete 函数, 将指定键所对应的键值对从字典中删除掉。
HLEN调用 ziplistLen 函数, 取得压缩列表包含节点的总数量, 将这个数量除以 2 , 得出的结果就是压缩列表保存的键值对的数量。调用 dictSize 函数, 返回字典包含的键值对数量, 这个数量就是哈希对象包含的键值对数量。
HGETALL遍历整个压缩列表, 用 ziplistGet 函数返回所有键和值(都是节点)。遍历整个字典, 用 dictGetKey 函数返回字典的键, 用 dictGetVal 函数返回字典的值。

集合对象(set)

集合对象的编码可以是 intset整数集合 或者 hashtable字典 

intset 编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。

digraph {      label = "\n å¾ 8-12    intset ç¼ç ç numbers éå对象";      rankdir = LR;      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_SET | encoding \n REDIS_ENCODING_INTSET | <ptr> ptr | ... "];     intset [label = " <head> intset | encoding \n INTSET_ENC_INT16 | length \n 3 | <contents> contents "];      contents [label = " { 1 | 3 | 5 } "];      redisObject:ptr -> intset:head;     intset:contents -> contents;  }

hashtable 编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL 。

digraph {      label = "\n å¾ 8-13    hashtable ç¼ç ç fruits éå对象";      rankdir = LR;      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_SET | encoding \n REDIS_ENCODING_HT | <ptr> ptr | ... "];      dict [label = " <head> dict | <cherry> StringObject \n \"cherry\" | <apple> StringObject \n \"apple\" | <banana> StringObject \n \"banana\" ", width = 1.5];      redisObject:ptr -> dict:head;      node [shape = plaintext, label = "NULL"];      dict:apple -> nullX;     dict:banana -> nullY;     dict:cherry -> nullZ;  }

编码转换

当集合对象可以同时满足以下两个条件时, 对象使用 intset 编码:

  1. 集合对象保存的所有元素都是整数值;
  2. 集合对象保存的元素数量不超过 512 个;

不能满足这两个条件的集合对象需要使用 hashtable 编码,限值可通过配置文件修改。

集合命令的实现

因为集合键的值为集合对象, 所以用于集合键的所有命令都是针对集合对象来构建的。

命令intset 编码的实现方法hashtable 编码的实现方法
SADD调用 intsetAdd 函数, 将所有新元素添加到整数集合里面。调用 dictAdd , 以新元素为键, NULL 为值, 将键值对添加到字典里面。
SCARD调用 intsetLen 函数, 返回整数集合所包含的元素数量, 这个数量就是集合对象所包含的元素数量。调用 dictSize 函数, 返回字典所包含的键值对数量, 这个数量就是集合对象所包含的元素数量。
SISMEMBER调用 intsetFind 函数, 在整数集合中查找给定的元素, 如果找到了说明元素存在于集合, 没找到则说明元素不存在于集合。调用 dictFind 函数, 在字典的键中查找给定的元素, 如果找到了说明元素存在于集合, 没找到则说明元素不存在于集合。
SMEMBERS遍历整个整数集合, 使用 intsetGet 函数返回集合元素。遍历整个字典, 使用 dictGetKey 函数返回字典的键作为集合元素。
SRANDMEMBER调用 intsetRandom 函数, 从整数集合中随机返回一个元素。调用 dictGetRandomKey 函数, 从字典中随机返回一个字典键。
SPOP调用 intsetRandom 函数, 从整数集合中随机取出一个元素, 在将这个随机元素返回给客户端之后, 调用 intsetRemove 函数, 将随机元素从整数集合中删除掉。调用 dictGetRandomKey 函数, 从字典中随机取出一个字典键, 在将这个随机字典键的值返回给客户端之后, 调用 dictDelete 函数, 从字典中删除随机字典键所对应的键值对。
SREM调用 intsetRemove 函数, 从整数集合中删除所有给定的元素。调用 dictDelete 函数, 从字典中删除所有键为给定元素的键值对。

有序集合对象(sorted set)

有序集合的编码可以是 ziplist压缩列表 或者 skiplist跳跃表 

ziplist 编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。

压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。

digraph {      label = "\n å¾ 8-14    ziplist ç¼ç çæåºéå对象";      rankdir = LR;      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_ZSET | encoding \n REDIS_ENCODING_ZIPLIST | <ptr> ptr | ... "];      ziplist [label = "å缩å表", width = 4.0];      redisObject:ptr -> ziplist;  }

digraph {      label = "\n å¾ 8-15    æåºéååç´ å¨å缩å表中æåå¼ä»å°å°å¤§æå";      //      node [shape = record];      ziplist [label = " zlbytes | zltail | zllen | <banana> \"banana\" | <banana_price> 5.0 | <cherry> \"cherry\" | <cherry_price> 6.0 | <apple> \"apple\" | <apple_price> 8.5 | zlend  "];      node [shape = plaintext];      banana [label = "å弿å°çåç´ "];     cherry [label = "åå¼æç¬¬äºçåç´ "];     apple [label = "å弿大çåç´ "];      //      edge [style = dashed]      banana -> ziplist:banana [label = "æå"];     banana -> ziplist:banana_price [label = "åå¼"];      cherry -> ziplist:cherry;     cherry -> ziplist:cherry_price;      apple -> ziplist:apple;     apple -> ziplist:apple_price;  }

skiplist 编码的有序集合对象使用 zset 结构作为底层实现, 一个 zset 结构同时包含一个字典和一个跳跃表

typedef struct zset {

    zskiplist *zsl;

    dict *dict;

} zset;

digraph {      label = "\n å¾ 8-16    skiplist ç¼ç çæåºéå对象";      rankdir = LR;      node [shape = record];      redisObject [label = " redisObject | type \n REDIS_ZSET | encoding \n REDIS_ENCODING_SKIPLIST | <ptr> ptr | ... "];      zset [label = " <head> zset | <dict> dict | <zsl> zsl "];      node [shape = plaintext];      dict [label = "..."];      zsl [label = "..."];      redisObject:ptr -> zset:head;     zset:dict -> dict;     zset:zsl -> zsl;  }

zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素: 跳跃表节点的object属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。 通过这个跳跃表, 程序可以对有序集合进行范围型操作, 比如 ZRANK、 ZRANGE 等命令就是基于跳跃表 API 来实现的。

除此之外, zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过这个字典, 程序可以用 O(1) 复杂度查找给定成员的分值, ZSCORE 命令就是根据这一特性实现的, 而很多其他有序集合命令都在实现的内部用到了这一特性。

digraph {      rankdir = LR;      //      node [shape = record];      zset [label = " <head> zset | <dict> dict | <zsl> zsl "];      dict [label = " <head> dict | ... | <ht0> ht[0] | ... "];      ht0 [label = " <head> dictht | ... | <table> table | ... "];      table [label = " <banana> StringObject \n \"banana\" | <apple> StringObject \n \"apple\" | <cherry> StringObject \n \"cherry\" "];      node [shape = plaintext];      apple_price [label = "8.5"];     banana_price [label = "5.0"];     cherry_price [label = "6.0"];      //      zset:dict -> dict:head;     dict:ht0 -> ht0:head;     ht0:table -> table:head;      table:apple -> apple_price;     table:banana -> banana_price;     table:cherry -> cherry_price;      //      node [shape = record, width = "0.5"];      //      l [label = " <header> header | <tail> tail | level \n 5 | length \n 3 "];      subgraph cluster_nodes {          style = invisible;          header [label = " <l32> L32 | ... | <l5> L5 | <l4> L4 | <l3> L3 | <l2> L2 | <l1> L1 "];          bw_null [label = "NULL", shape = plaintext];          level_null [label = "NULL", shape = plaintext];          A [label = " <l4> L4 | <l3> L3 | <l2> L2 | <l1> L1 | <backward> BW | 5.0 | StringObject \n \"banana\" "];          B [label = " <l2> L2 | <l1> L1 | <backward> BW | 6.0 | StringObject \n \"cherry\" "];          C [label = " <l5> L5 | <l4> L4 | <l3> L3 | <l2> L2 | <l1> L1 | <backward> BW | 8.5 | StringObject \n \"apple\" "];      }      subgraph cluster_nulls {          style = invisible;          n1 [label = "NULL", shape = plaintext];         n2 [label = "NULL", shape = plaintext];         n3 [label = "NULL", shape = plaintext];         n4 [label = "NULL", shape = plaintext];         n5 [label = "NULL", shape = plaintext];      }      //      l:header -> header;     l:tail -> C;      header:l32 -> level_null;     header:l5 -> C:l5;     header:l4 -> A:l4;     header:l3 -> A:l3;     header:l2 -> A:l2;     header:l1 -> A:l1;      A:l4 -> C:l4;     A:l3 -> C:l3;     A:l2 -> B:l2;     A:l1 -> B:l1;      B:l2 -> C:l2;     B:l1 -> C:l1;      C:l5 -> n5;     C:l4 -> n4;     C:l3 -> n3;     C:l2 -> n2;     C:l1 -> n1;      bw_null -> A:backward -> B:backward -> C:backward [dir = back];      zset:zsl -> l:header;      // HACK: æ¾å¨å¼å¤´çè¯ NULL æéçé¿åº¦ä¼æå¼æ ·     label = "\n å¾ 8-17    æåºéååç´ åæ¶è¢«ä¿å­å¨å­å¸åè·³è·è¡¨ä¸­";  }

有序集合每个元素的成员都是一个字符串对象, 而每个元素的分值都是一个 double 类型的浮点数。 值得一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素, 但这两种数据结构都会通过指针来共享相同元素的成员和分值, 所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存(上图是为了展示清楚结构)。

编码的转换

当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist 编码:

  1. 有序集合保存的元素数量小于 128 个;
  2. 有序集合保存的所有元素成员的长度都小于 64 字节;

不能满足以上两个条件的有序集合对象将使用 skiplist 编码,限值可通过配置文件修改。

有序集合命令的实现

命令ziplist 编码的实现方法zset 编码的实现方法
ZADD调用 ziplistInsert 函数, 将成员和分值作为两个节点分别插入到压缩列表。先调用 zslInsert 函数, 将新元素添加到跳跃表, 然后调用 dictAdd 函数, 将新元素关联到字典。
ZCARD调用 ziplistLen 函数, 获得压缩列表包含节点的数量, 将这个数量除以 2 得出集合元素的数量。访问跳跃表数据结构的 length 属性, 直接返回集合元素的数量。
ZCOUNT遍历压缩列表, 统计分值在给定范围内的节点的数量。遍历跳跃表, 统计分值在给定范围内的节点的数量。
ZRANGE从表头向表尾遍历压缩列表, 返回给定索引范围内的所有元素。从表头向表尾遍历跳跃表, 返回给定索引范围内的所有元素。
ZREVRANGE从表尾向表头遍历压缩列表, 返回给定索引范围内的所有元素。从表尾向表头遍历跳跃表, 返回给定索引范围内的所有元素。
ZRANK从表头向表尾遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。从表头向表尾遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。
ZREVRANK从表尾向表头遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。从表尾向表头遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。
ZREM遍历压缩列表, 删除所有包含给定成员的节点, 以及被删除成员节点旁边的分值节点。遍历跳跃表, 删除所有包含了给定成员的跳跃表节点。 并在字典中解除被删除元素的成员和分值的关联。
ZSCORE遍历压缩列表, 查找包含了给定成员的节点, 然后取出成员节点旁边的分值节点保存的元素分值。直接从字典中取出给定成员的分值。

类型检查与命令多态

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 会先检查输入键的类型是否正确, 然后再决定是否执行给定的命令。

类型特定命令所进行的类型检查是通过 redisObject 结构的 type 属性来实现的:

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

多态

Redis 除了会根据值对象的类型来判断键是否能够执行指定命令之外, 还会根据值对象的编码方式, 选择正确的命令实现代码来执行命令。

digraph {      label = "\n å¾ 8-19    LLEN å½ä»¤çæ§è¡è¿ç¨";      //      node [shape = box];      call_command [label = "客æ·ç«¯åé LLEN <key> å½ä»¤"];      check_type [label = "æå¡å¨æ£æ¥ \n é® key çå¼å¯¹è±¡\næ¯å¦å表对象", shape = diamond];      //execute_command [label = "å¯¹é® key æ§è¡ LLEN å½ä»¤"];      select_encoding [label = "对象çç¼ç æ¯ \n ziplist è¿æ¯ linkedlist ï¼", shape = diamond];      ziplist [label = "è°ç¨ ziplistLen 彿° \n è¿åå缩å表çé¿åº¦"];      linkedlist [label = "è°ç¨ listLength 彿° \n è¿åå端é¾è¡¨çé¿åº¦"];      type_error [label = "è¿åä¸ä¸ªç±»åé误"];      //      call_command -> check_type;      //check_type -> execute_command [label = "æ¯"];      check_type -> type_error [label = "å¦"];      //execute_command -> select_encoding;      check_type -> select_encoding [label = "æ¯"];      select_encoding -> ziplist [label = "ziplist \n ç¼ç "];      select_encoding -> linkedlist [label = "linkedlist \n ç¼ç "];  }

实际上, 我们可以将 DEL 、 EXPIRE 、 TYPE 等命令也称为多态命令, 因为无论输入的键是什么类型, 这些命令都可以正确地执行。DEL 、 EXPIRE 等命令和 LLEN 等命令的区别在于:前者是基于类型的多态,一个命令可以同时用于处理多种不同类型的键;而后者是基于编码的多态,一个命令可以同时用于处理多种不同编码。

内存回收

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

typedef struct redisObject {
    // ...

    // 引用计数
    int refcount;
} robj;

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

  • 在创建一个新对象时, 引用计数的值会被初始化为 1 ;
  • 当对象被一个新程序使用时, 它的引用计数值会被增一;
  • 当对象不再被一个程序使用时, 它的引用计数值会被减一;
  • 当对象的引用计数值变为 0 时, 对象所占用的内存会被释放。
函数作用
incrRefCount将对象的引用计数值增一。
decrRefCount将对象的引用计数值减一, 当对象的引用计数值等于 0 时, 释放对象。
resetRefCount将对象的引用计数值设置为 0 , 但并不释放对象, 这个函数通常在需要重新设置对象的引用计数值时使用。

对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段。

对象共享

除了用于实现引用计数内存回收机制之外, 对象的引用计数属性还带有对象共享的作用。

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

  1. 为键 B 新创建一个包含整数值 100 的字符串对象;
  2. 让键 A 和键 B 共享同一个字符串对象;

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

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

共享对象机制对于节约内存非常有帮助, 数据库中保存的相同值对象越多, 对象共享机制就能节约越多的内存。目前来说, Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从 0 到 9999 的所有整数值, 当服务器需要用到值为 0到 9999 的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象。

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

但是, Redis 不共享包含字符串的对象,因为当服务器考虑将一个共享对象设置为键的值对象时, 程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,对于验证过程来说保存的值越复杂,验证所需要时间越久,CPU时间片占有越多,所以由于CPU的限制, Redis 只对包含整数值的字符串对象进行共享。

对象的空转时长

除了前面介绍过的 type对象类型、 encoding编码 、 ptr泛型指针 和 refcount引用计数 四个属性之外, redisObject 结构包含的最后一个属性为 lru 属性, 该属性记录了对象最后一次被命令程序访问的时间

OBJECT IDLETIME命令可以打印出键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的 lru 时间计算得出的(OBJECT IDLETIME 命令的实现是特殊的, 这个命令在访问键的值对象时, 不会修改值对象的 lru 属性)。

键的空转时长的作用主要是为了内存的及时回收,如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者 allkeys-lru ,那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存。

总结

  • Redis 数据库中的每个键值对的键和值都是一个对象。
  • Redis 共有字符串、列表、哈希、集合、有序集合五种类型的对象, 每种类型的对象至少都有两种或以上的编码方式, 不同的编码可以在不同的使用场景上优化对象的使用效率。
  • 服务器在执行某些命令之前, 会先检查给定键的类型能否执行指定的命令, 而检查一个键的类型就是检查键的值对象的类型。
  • Redis 的对象系统带有引用计数实现的内存回收机制, 当一个对象不再被使用时, 该对象所占用的内存就会被自动释放。
  • Redis 会共享值为 0 到 9999 的字符串对象。
  • 对象会记录自己的最后一次被访问的时间, 这个时间可以用于计算对象的空转时间。

 

 

参考文章:

《Redis设计与实现》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值