Redis实现之对象(三)
集合对象
集合对象的编码可以是intset或者hashtable,intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。举个栗子,以下代码将创建一个图1-12所示的intset编码集合对象:
127.0.0.1:6379> SADD numbers 1 3 5 (integer) 3 127.0.0.1:6379> OBJECT ENCODING numbers "intset"
图1-12 inset编码的numbers集合对象
另一方面,hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL,以下的示例,将创建一个如图1-13所示的hashtable编码集合对象:
127.0.0.1:6379> SADD fruits "apple" "banana" "cherry" (integer) 3 127.0.0.1:6379> OBJECT ENCODING fruits "hashtable"
图1-13 hashtable编码的fruits集合对象
编码的转换
当集合对象可以同时满足以下两个条件时,对象使用intset编码:
- 集合对象保存的所有元素都是整数值
- 集合对象保存的元素数量不超过512个
不能满足以上两个条件对的集合对象需要使用hashtable编码,注意,第一个条件是无法修改的,但第二个条件的上限值可以修改,具体请看配置文件中关于set-max-intset-entries选项的说明
对于使用intset编码的集合对象来说,当使用intset编码所需的两个条件的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在整数集合中的所有元素都会被转移并保存到字典里面,并且对象的编码也会从intset变为hashtable
举个栗子,以下代码创建一个只包含整数元素的集合对象,该对象原来的编码为intset,但我们只要添加一个字符串元素,集合对象的编码转移操作就会被执行
127.0.0.1:6379> SADD numbers 1 3 5 (integer) 3 127.0.0.1:6379> OBJECT ENCODING numbers "intset" 127.0.0.1:6379> SADD numbers "seven" (integer) 1 127.0.0.1:6379> OBJECT ENCODING numbers "hashtable"
除此之外,如果我们创建一个包含512个整数元素的集合对象,那么对象的编码应该是intset。但是,只要我们再往集合添加一个整数元素,使得这个集合的元素变为513,那么对象的编码转换操作就会被执行:
127.0.0.1:6379> EVAL "for i=1, 512 do redis.call('SADD', KEYS[1], i) end" 1 integers (nil) 127.0.0.1:6379> SCARD integers (integer) 512 127.0.0.1:6379> OBJECT ENCODING integers "intset" 127.0.0.1:6379> SADD integers 10086 (integer) 1 127.0.0.1:6379> SCARD integers (integer) 513 127.0.0.1:6379> OBJECT ENCODING integers "hashtable"
集合命令的实现
因为集合键的值为集合对象,所以用于集合键的所有命令都是针对集合对象来操作的,表1-10列出了其中一部分集合键的命令,以及这些命令在不同编码的集合对象下的实现方法
命令 | 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函数,从字典中删除所有键为给定元素的键值对 |
有序集合对象
有序集合的编码可以是ziplist或者skiplist,ziplist编码的压缩列表中,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素保存元素的分值(score)。压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向
举个栗子,如果我们执行以下ZADD命令,那么服务器将创建一个有序集合对象作为price键的值:
127.0.0.1:6379> ZADD price 8.5 apple 5.0 banana 6.0 cherry (integer) 3 127.0.0.1:6379> OBJECT ENCODING price "ziplist"
price这个值对象如图1-14所示,而对象所使用的的压缩列表如图1-15所示
图1-14 ziplist编码的有序集合对象
图1-15 有序集合元素在压缩列表中按分值从小到大排列
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表
redis.h
typedef struct zset { dict *dict; zskiplist *zsl; } zset; typedef struct zskiplistNode { robj *obj; double score; struct zskiplistNode *backward; struct zskiplistLevel { struct zskiplistNode *forward; unsigned int span; } level[]; } zskiplistNode; typedef struct zskiplist { struct zskiplistNode *header, *tail; unsigned long length; int level; } zskiplist;
zset结构中的的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的obj属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的
除此之外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以在O(1)的时间复杂度内查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性
有序集合中每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。值得一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或分值,也不会因为浪费额外的内存
为什么有序集合需要同时使用跳跃表和字典来实现?在理论上,有序集合可以单独使用字典或者跳跃表其中一种数据结构来实现,但无论使用字典还是跳跃表,在性能上比起同时使用字典和跳跃表都会有所降低。举个例子,如果我们只是用字典来实现有序集合,那么虽然可以在O(1)的时间复杂度内查找成员对应的分值,但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围型操作——比如:ZRANK、ZRANGE等命令时,程序都需要对字典的所有元素进行排序,完成这种排序至少需要O(N logN)的时间复杂度,以及额外的O(N)内存空间(因为要创建一个数组来保存排序后的元素)
另一方面如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作时的所有优点都会被保留,但因为没有了字典,所以根据成员查找分值这一操作的时间复杂度将从O(1)上升至O(logN)。因为以上原因,为了让有序集合的查找和范围型操作都尽可能快地执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合
举个栗子,如果前面的price键创建的不是ziplist编码的有序集合对象,而是skiplist编码的有序集合对象,那么这个有序集合对象将会是图1-16所示的样子,而对象所使用的zset结构将会是图8-17所示的样子
图1-16 skiplist编码的有序集合对象
图1-17 有序集合元素同时被保存在字典和跳跃表中
编码的转换
当有序集合对象可以同时满足以下条件时,对象使用ziplist编码:
- 有序集合保存的元素数量小于128个
- 有序集合保存的所有元素的长度小于64字节
不能满足以上两个条件的有序集合对象将使用skiplist编码
# 对象包含了 128 个元素 127.0.0.1:6379> EVAL "for i=1, 128 do redis.call('ZADD', KEYS[1], i, i) end" 1 numbers (nil) 127.0.0.1:6379> ZCARD numbers (integer) 128 127.0.0.1:6379> OBJECT ENCODING numbers "ziplist" # 再添加一个新元素 127.0.0.1:6379> ZADD numbers 3.14 pi (integer) 1 # 对象包含的元素数量变为 129 个 127.0.0.1:6379> ZCARD numbers (integer) 129 # 编码已改变 127.0.0.1:6379> OBJECT ENCODING numbers "skiplist"
以下代码则展示了有序集合对象因为元素的成员过长而引发编码转换的情况:
# 向有序集合添加一个成员只有三字节长的元素 127.0.0.1:6379> ZADD blah 1.0 www (integer) 1 127.0.0.1:6379> OBJECT ENCODING blah "ziplist" # 向有序集合添加一个成员为 66 字节长的元素 127.0.0.1:6379> ZADD blah 2.0 oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo (integer) 1 # 编码已改变 127.0.0.1:6379> OBJECT ENCODING blah "skiplist"
有序集合命令的实现
因为有序集合键的值为哈希值,所以用于有序集合键的所有命令都是针对哈希对象来构建的,表1-11列出了其中一部分有序集合键命令,以及这些命令在不同编码的哈希对象下的实现方法
命令 | ziplist编码的实现方法 | zset编码的实现方法 |
ZADD | 调用ziplistInsert函数, 将成员和分值作为两个节点分别插入到压缩列表 | 先调用zslInsert函数,将新元素添加到跳跃表,然后调用dictAdd 函数,将新元素关联到字典 |
ZCARD | 调用ziplistLen函数,获得压缩列表包含节点的数量,将这个数量除以2得出集合元素的数量 | 访问跳跃表数据结构的length属性, 直接返回集合元素的数量 |
ZCOUNT | 遍历压缩列表,统计分值在给定范围内的节点的数量 | 遍历跳跃表,统计分值在给定范围内的节点的数量 |
ZRANGE | 从表头向表尾遍历压缩列表,返回给定索引范围内的所有元素 | 从表头向表尾遍历跳跃表,返回给定索引范围内的所有元素 |
ZREVRANGE | 从表尾向表头遍历压缩列表,返回给定索引范围内的所有元素 | 从表尾向表头遍历跳跃表,返回给定索引范围内的所有元素 |
ZRANK | 从表头向表尾遍历压缩列表,查找给定的成员,沿途记录经过节点的数量,当找到给定成员之后,途经节点的数量就是该成员所对应元素的排名 | 从表头向表尾遍历跳跃表,查找给定的成员,沿途记录经过节点的数量,当找到给定成员之后,途经节点的数量就是该成员所对应元素的排名 |
ZREVRANK | 从表尾向表头遍历压缩列表,查找给定的成员,沿途记录经过节点的数量,当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名 | 从表尾向表头遍历跳跃表,查找给定的成员,沿途记录经过节点的数量,当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名 |
ZREM | 遍历压缩列表,删除所有包含给定成员的节点,以及被删除成员节点旁边的分值节点 | 遍历跳跃表,删除所有包含了给定成员的跳跃表节点。 并在字典中解除被删除元素的成员和分值的关联 |
ZSCORE | 遍历压缩列表,查找包含了给定成员的节点,然后取出成员节点旁边的分值节点保存的元素分值 | 直接从字典中取出给定成员的分值 |