Redis实现之对象(二)
列表对象
列表对象的编码可以是ziplist或者linkedlist,ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。举个栗子,如果我们执行RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值:
127.0.0.1:6379> RPUSH numbers 1 "three" 5 (integer) 3
如果numbers键的键值对使用的是ziplist编码,这个值对象将会是图1-5所示的样子
图1-5 ziplist编码的numbers列表对象
另一方面,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。举个栗子,如果前面说的numbers键创建的列表对象使用的不是ziplist编码,而是linkedlist编码,那么numbers键的值对象将是图1-6所示的样子
图1-6 linkedlist编码的numbers列表对象
注意,linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现,字符串对象时Redis五种类型的对象中的唯一一种会被其他四种类型对象嵌套的对象
注:为了简化字符串对象的表示,我们在图1-6使用了一种带有StringObject字样的格子来表示一个字符串对象,而StringObject字样下面的是字符串对象所保存的值。比如图1-7代表的就是一个包含字符串"three"的字符串对象,它是图1-8的简化表示:
图1-7 简化的字符串对象表示
图1-8 完整的字符串对象表示
编码转换
当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:
- 列表对象保存的所有字符串元素的长度都小于64字节
- 列表对象保存的元素数量小于512个
不能满足以上两个条件的列表需要使用linkedlist编码,当然,以上两个条件的上限是可以修改的,具体看配置文件中关于list-max-ziplist-value选项和list-max-ziplist-entries选项的说明
对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码也会从ziplist变为linkedlist。以下代码展示了列表对象因保存了长度太大的元素而进行编码转换的情况
# 所有元素的长度都小于 64 字节 127.0.0.1:6379> RPUSH blah "hello" "world" "again" (integer) 3 127.0.0.1:6379> OBJECT ENCODING blah "ziplist" # 将一个 65 字节长的元素推入列表对象中 127.0.0.1:6379> RPUSH blah "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww" (integer) 4 # 编码已改变 127.0.0.1:6379> OBJECT ENCODING blah "linkedlist"
除此之外,以下代码展示了列表对象因为保存的元素对象过多而进行编码转换的情况:
# 列表对象包含 512 个元素 127.0.0.1:6379> EVAL "for i=1,512 do redis.call('RPUSH', KEYS[1], i) end" 1 "integers" (nil) 127.0.0.1:6379> LLEN integers (integer) 512 127.0.0.1:6379> OBJECT ENCODING integers "ziplist" # 再向列表对象推入一个新元素,使得对象保存的元素数量达到 513 个 127.0.0.1:6379> RPUSH integers 513 (integer) 513 # 编码已改变 127.0.0.1:6379> OBJECT ENCODING integers "linkedlist"
列表命令的实现
因为列表键为列表对象,所以用于列表键的所有命令都是针对列表对象来构建的,表1-8列出了其中一部分列表键的命令,以及这些命令在不同编码的列表对象下的实现方法
命令 | 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函数,定位到双端链表指定索引上的节点,然后通过赋值操作更新节点的值 |
哈希对象
哈希对象的编码可以是ziplist或者hashtable,ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表的表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:
- 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后
- 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对被放在压缩列表的表尾
举个栗子,如果我们执行以下HSET命令,那么服务器将创建一个列表对象作为profile键的值:
127.0.0.1:6379> HSET profile name "Tom" (integer) 1 127.0.0.1:6379> HSET profile age 25 (integer) 1 127.0.0.1:6379> HSET profile career "Programmer" (integer) 1 127.0.0.1:6379> OBJECT ENCODING profile "ziplist"
如上述示例所见,profile键的值对象使用的是ziplist编码,这个值对象是图1-9所示的样子,其中对象所使用的压缩列表如图1-10所示
图1-9 ziplist编码的profile哈希对象
图1-10 profile哈希对象的压缩列表底层实现
另一方面,hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:
- 字典的每个键都是一个字符串对象,对象中保存了键值对的键
- 字典的每个值都是一个字符串对象,对象中保存了键值对的值
举个栗子,如果前面的profile键创建的不是ziplist编码的哈希对象,而是hashtable编码的哈希对象,那么这个哈希对象应该是如图1-11所示的样子
图1-11 hashtable编码的profile哈希对象
编码转换
当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
- 哈希对象保存的键值对数量小于512个
不能满足这两个条件的哈希对象需要使用hashtable编码,以上两个条件的上限值是可以修改的,具体请看配置文件中关于hash-max-ziplist-value选项和hash-max-ziplist-entries选项的说明
对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会执行,原本保存在压缩列表里的所有键值对都会被转移并保存到字典里面,对象的编码也会从ziplist变为hashtable。以下代码展示了哈希对象因为键值对的键长度太大而引起编码转换的情况:
# 哈希对象只包含一个键和值都不超过 64 个字节的键值对 127.0.0.1:6379> HSET book name "Mastering C++ in 21 days" (integer) 1 127.0.0.1:6379> OBJECT ENCODING book "ziplist" # 向哈希对象添加一个新的键值对,键的长度为 66 字节 127.0.0.1:6379> HSET book long_long_long_long_long_long_long_long_long_long_long_description "content" (integer) 1 # 编码已改变 127.0.0.1:6379> OBJECT ENCODING book "hashtable"
除了键的长度太大会引起编码转换外,值的长度太大也会引起编码转换
# 哈希对象只包含一个键和值都不超过 64 个字节的键值对 127.0.0.1:6379> HSET blah greeting "hello world" (integer) 1 127.0.0.1:6379> OBJECT ENCODING blah "ziplist" # 向哈希对象添加一个新的键值对,值的长度为 68 字节 127.0.0.1:6379> HSET blah story "many string ... many string ... many string ... many string ... many" (integer) 1 # 编码已改变 127.0.0.1:6379> OBJECT ENCODING blah "hashtable"
最有,再展示哈希对象因为包含的键值对数量过多而引起的编码转换
# 创建一个包含 512 个键值对的哈希对象 127.0.0.1:6379> EVAL "for i=1, 512 do redis.call('HSET', KEYS[1], i, i) end" 1 "numbers" (nil) 127.0.0.1:6379> HLEN numbers (integer) 512 127.0.0.1:6379> OBJECT ENCODING numbers "ziplist" # 再向哈希对象添加一个新的键值对,使得键值对的数量变成 513 个 127.0.0.1:6379> HMSET numbers "key" "value" OK 127.0.0.1:6379> HLEN numbers (integer) 513 # 编码改变 127.0.0.1:6379> OBJECT ENCODING numbers "hashtable"
哈希命令的实现
因为哈希键的值为哈希对象,所以用于哈希键的所有命令都是针对哈希键对象来构建的,表1-9列出了其中一部分哈希键的命令,以及这些命令在不同编码的哈希键对象下的实现方法:
命令 | 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函数返回字典的值 |