目录
对象的类型与编码
Redis 并没有直接使用数据结构来实现键值对数据库, 而是基于这些数据结构创建了一个对象系统, 这个系统包含以下五种:
- 字符串对象
- 列表对象
- 哈希对象
- 集合对象
- 有序集合对象
这五种类型的对象, 每种对象都用到了至少一种前面所介绍的数据结构。而使用对象的好处可以总结为以下两点:
- 可以在执行命令之前, 根据对象的类型来判断一个对象是否可以执行给定的命令。
- 可以针对不同的使用场景, 为对象设置多种不同的数据结构实现, 从而优化对象在不同场景下的使用效率。
每次创建一个键值对时,至少会创建两个对象:键对象和值对象。其中每个对象都由以下结构体来表示。
typedef struct redisObject {
//类型
unsigned type:4;
//编码
unsigned encoding:4;
//用于计算空转时长
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
//引用计数
int refcount;
//指针,指向数据结构
void *ptr;
} robj;
其中对象的类型(type)可以是以下几个之一:
(对于 Redis 数据库保存的键值对来说, 键总是一个字符串对象, 而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种)
对象的编码(encoding)可以是以下几种之一:
通过 encoding 属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提升了 Redis 的灵活性和效率, 因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率。
字符串对象
根据场景的不同,字符串对象的编码可以是以下三种:
- int
- embstr
- raw
如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long
类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr
属性里面(将 void*
转换成 long
), 并将字符串对象的编码设置为 int
。
int类型的字符串对象图示:
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 32
字节, 那么字符串对象将使用 embstr
编码的方式来保存这个字符串值。
embstr类型的字符串对象图示:
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 32
字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw
。
raw类型的字符串对象图示:
字符串对象的3种编码转换条件和过程:
字符串对象命令:
命令 | 功能 |
SET | 保存值 |
GET | 返回值 |
APPEND | 先变为raw编码,后在末尾增加值 |
INCRBY | 对int编码进行加法 |
INCRBYFLOAT | 浮点数加法 |
DECRBY | 整数值减法 |
STRLEN | 字符串长度(int类型拷贝后处理,不改变编码) |
SETRANGE | 按索引改变值(会变为raw) |
GETRANGE | 返回索引上的值(int类型拷贝后处理,不改变编码) |
列表对象
列表对象的编码可以是 以下两种:
- ziplist
- linkedlist
ziplist 编码的列表对象使用压缩列表作为底层实现, 每个压缩列表节点(entry)保存了一个列表元素。
linkedlist
编码的列表对象使用双端链表作为底层实现, 每个双端链表节点(node)都保存了一个字符串对象, 而每个字符串对象都保存了一个列表元素
注意, linkedlist
编码的列表对象在底层的双端链表结构中包含了多个字符串对象, 这种嵌套字符串对象的行为在后面的的哈希对象、集合对象和有序集合对象中都会出现, 字符串对象是 Redis 五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。
编码转换条件:
当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist
编码,不能满足这两个条件的列表对象需要使用 linkedlist
编码。
- 列表对象保存的所有字符串元素的长度都小于
64
字节; - 列表对象保存的元素数量小于
512
个;
哈希对象
哈希对象的编码可以是:以下两种
ziplist
hashtable
ziplist
编码的哈希对象使用压缩列表作为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾。
ziplist编码的哈希对象图示:
该压缩列表的细节如下:
hashtable
编码的哈希对象使用字典作为底层实现, 哈希对象中的每个键值对都使用一个字典键值对来保存。
其中键和值都是嵌入的字符串对象,如图:
编码转换:
当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist
编码,不能满足这两个条件的哈希对象需要使用 hashtable
编码。
- 哈希对象保存的所有键值对的键和值的字符串长度都小于
64
字节; - 哈希对象保存的键值对数量小于
512
个;
集合对象
集合对象的编码可以是 以下两种:
intset
hashtable
。
intset
编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。
hashtable
编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL
当集合对象可以同时满足以下两个条件时, 对象使用 intset
编码,不能满足这两个条件的集合对象需要使用 hashtable
编码。
- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过
512
个;
有序集合
有序集合的编码可以是以下这两种:
ziplist
skiplist
。
ziplist
编码情况下,集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。
压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。
其中压缩列表存储的细节如图:
而有序集合中的skiplist编码的情况下,有序集合对象使用 zset
结构作为底层实现, 一个 zset
结构同时包含一个字典和一个跳跃表。
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
图示:
字典和跳跃表:
命令中只有zadd和zscore会涉及使用字典,其余全用跳跃表。
当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist
编码,不能满足以上两个条件的有序集合对象将使用 skiplist
编码。
- 有序集合保存的元素数量小于
128
个; - 有序集合保存的所有元素成员的长度都小于
64
字节;
注意:在实际中, 字典和跳跃表会共享元素的成员和分值, 所以并不会造成任何数据重复, 也不会因此而浪费任何内存。
类型检查与命令多态
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 等命令只能对有序集合键执行;
类型检查与命令多态可以用下面这张图描述:
基于类型的多态:如 DEL 、 EXPIRE 、 TYPE,对任何类型的键都可以执行
基于编码的多态:如SPOP、ZRANK
内存回收/对象共享/对象空转时长
先说Redis中的对象共享机制,从redisObject 中的 int refcount 参数来说:
举个例子, 假设键 A 创建了一个包含整数值 100
的字符串对象作为值对象,如果这时键 B 也要创建一个同样保存了整数值 100
的字符串对象作为值对象, 那么服务器将让键 A 和键 B 共享同一个字符串对象,即执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数( refcount )增一。
Redis 系统中也基于引用计数(reference counting)构建了一个内存回收机制。
对象的引用计数信息会随着对象的使用状态而不断变化:
- 在创建一个新对象时,refcount = 0;
- 当对象被一个新程序使用时, refcount++;
- 当对象不再被一个程序使用时, refcount--;
- 当对象的refcount ==
0
时, 对象所占用的内存会被释放。
127.0.0.1:6379> set int1 100
OK
127.0.0.1:6379> object refcount int1
(integer) 2 //一个系统引用,一个用户引用
127.0.0.1:6379> hset ht1 a 100
(integer) 1
127.0.0.1:6379> object encoding ht1
"ziplist"
127.0.0.1:6379> object refcount int1
(integer) 2 //ziplist编码情况下,int1引用没增加,因为压缩列表中没有嵌套object
127.0.0.1:6379> hset ht1 b 2222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222
(integer) 1
127.0.0.1:6379> object encoding ht1
"hashtable"//转hashtable
127.0.0.1:6379> object refcount int1
(integer) 3 //这时int1引用已经增加,因为在转换过程中会重新编码
127.0.0.1:6379> hset ht1 c 100
(integer) 1
127.0.0.1:6379> object refcount int1
(integer) 4 //再增加1
继续说 redisObject 的 unsigned lru:
LRU(least recently used)是一种缓存置换算法。即在缓存有限的情况下,如果有新的数据需要加入,则要替换掉最不可能被访问的数据。
redisObject中的lru实际上是一个秒级时间戳。OBJECT IDLETIME 命令可以打印出给定键的空转时长, 是通过将当前时间减去键的值对象的 lru
时间计算得出的;
如果服务器打开了 maxmemory
选项, 并且服务器用于回收内存的算法为 volatile-lru
或者 allkeys-lru
, 那么当服务器占用的内存数超过了 maxmemory
时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存。
Redis初始实现LRU的算法很简单,即随机从dict中取出5个key,淘汰一个LRU值最小的。
Redis3.0以后,算法改为了随机选出的key都会放进一个pool(size:16)中,pool中的key按照LRU的大小排列。当pool满了的时候,放入新的就需要将pool中LRU最大的替换掉。淘汰的时候,直接将pool中LRU最小的淘汰即可。
作者对算法和samples大小的实验结果:
-----整理自《redis设计与实现(第二版)》