.png)
关于本文,我是有点犹豫。对象系统值得写一篇文章吗?从技术上来讲,当然是值。但对于大部分人使用来说,它都是隐身的,你很少注意它而已。
写的话,顺序放在哪里?在42张图,真正搞懂redis数据类型的底层一文里面其实就提到了,那么自然就是本文重点讲对象系统,也可以回去复习复习。
一 回顾数据结构
简单动态字符串(SDS)
双端链表
字典
压缩列表
整数集合
Redis 并没有直接使用这些数据结构来实现KV数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合这五种类型对象,然后里面每个对象都使用到了至少一种前边的数据结构。
二 思考一个问题
Redis 中的对象,大都是通过多种数据结构来实现的,为啥会这样设计呢?用一种固定的数据结构来实现,不是更加简单些吗,对吧
Redis这样设计有两个好处:
- 可以自由改进内部编码,而对外的数据结构和命令是没有影响的,这样一旦开发出更优秀的内部编码,无需改动外部数据结构和命令,例如Redis3.2版本提供了
quicklist
,它结合了ziplist
和linkedlist
两者的优点,为列表类型提供了一种更加优秀的内部编码,而对外部用户来说基本感知不到的,这一点比较像程序设计中的分层架构。 - 多种内部编码可在不同场景下可以发挥各自的优势,从而优化对象在不同场景下的使用效率。例如
ziplist
比较节省内存,但是在列表元素比较多的情况下,性能会有所下降,这时候 Redis 会根据配置选项将列表类型的内部实现转换linkedlist
。 (后续文章将根据具体对象介绍);
通过这五种不同类型的对象,Redis 在执行命令之前,它会根据对象类型来判断一个对象是否可以执行完给定的命令。使用对象的另一个好处是,可以根据不同的使用场景,给对象设置多种不同的数据结构实现,来优化对象在不同场景下的使用效率;
三 对象的类型编码
Redis 使用对象来表示数据库中的键和值,每次当我们在Redis的数据库里面新创建一个键值对时候,我们至少会创建两个对象,我想你们已经猜到了。一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。
下面 SET 在数据库将创建一个新的键值对,其中键值对的键是一个包含了字符串"msg
"的对象,而键值对的值是一个包含了字符串"hello world
"的对象;
127.0.0.1:6379> SET msg "hello world"
OK
Redis 中的每个对象都是同一个模子刻出来的,就是 redisObject 结构来表示,该结构中保存数据相关的三个属性分别是:type、encoding、ptr
;
Redis 的 redisObject 定义如下:
/* redis.h */
typedef struct redisObject {
//类型 共有5种常见的值类型
unsigned type:4;
unsigned notused:2;
//编码 标名底层数据结构的类型
unsigned encoding:4;
unsigned lru:LRU_BITS;
//引用计数
int refcount;
//存储结构指针
void *ptr;
} robj;
对象的 type 属性记录了对象的类型,这个属性的值是下面常量中的一个:
对于 Redis
数据库保存的键值对 来说,键总是一个字符串对象,但是 值 则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象里面的其中一种,因此:
- 当我们称呼一个数据库键为 “字符串键” 时,我们指的是 “这个数据库键所对应的值为字符串对象”;
- 当我们称呼一个数据库键为 “列表键” 时,我们指的是 “这个数据库键所对应的值为列表对象”;
TYPE命令的实现方式也与此类似,当我们对一个数据库键执行 TYPE 命令,命令返回结果为数据库键对应的值对象类型,并不是键对象类型:
#键为字符串对象,值为字符串对象
127.0.0.1:6379> SET msg "hello world"
OK
127.0.0.1:6379> TYPE msg
string
# 键为字符串对象,值为列表对象
127.0.0.1:6379> RPUSH numbers 1 3 5
(integer) 3
127.0.0.1:6379> TYPE numbers
list
# 键为字符串对象,值为哈希对象
127.0.0.1:6379> HMSET profile name Tome age 25 career Programmer
OK
127.0.0.1:6379> TYPE profile
hash
# 键为字符串对象,值为集合对象
127.0.0.1:6379> SADD fruits apple banana cherry
(integer) 3
127.0.0.1:6379> TYPE fruits
set
# 键为字符串对象,值为有序集合对象
127.0.0.1:6379> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
127.0.0.1:6379> TYPE price
zset
四 编码和底层实现
Redis 的 redisObject 定义:
/* redis.h */
typedef struct redisObject {
//类型 共有5种常见的值类型
unsigned type:4;
unsigned notused:2;
//编码 标名底层数据结构的类型
unsigned encoding:4;
unsigned lru:LRU_BITS;
//引用计数
int refcount;
//存储结构指针
void *ptr;
} robj;
上面代码中对象的ptr指针指向的是对象的底层数据结构,而这些数据结构由对象的 encoding
属性决定。encoding
属性记录对象使用的编码,也即是说这个对象使用了什么数据结构是作为对象的底层实现,那么这个属性的值可以是下面列出的常量的其中一个,一一对应:
列出了 TYPE
命令在面对不同类型的值对象时所产生的输出:
每种类型的对象都至少使用两种不同的编码,下表列出了类型的对象可以使用的编码:
我们可使用 OBJECT ENCODING
命令可以查看一个数据库键的值对象的编码:
127.0.0.1:6379> SET msg "hello wrold"
OK
127.0.0.1:6379> OBJECT ENCODING msg
"embstr"
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"
下面列出了不同编码的对象所对应的 OBJECT ENCODING
命令输出:
通过 encoding
属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了 Redis
的灵活性和效率。
因为 Redis 根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率
举个栗子
在列表对象包含的元素比较少时,Redis
用压缩列表作为列表对象的底层来做:
因为压缩列表比双端链表更节约内存,并且在元素比较少时,在内存中是以 连续块形式保存的压缩列表,比起双端链表可以 更快被载入到缓存中,随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转换成功能更强、比较适合 保存大量元素的双端链表;
其他类型的对象也会通过使用多种不同的编码来进行类似的优化;
Redis 的键对象都是字符串对象,而Redis的值对象主要有字符串、哈希、列表、集合、有序集合几种。
分别对应的内部编码和底层数据结构下图所示:
如果读者熟悉 Redis 的命令的话, 就会发现,Redis 的命令设计维度不是单一的。
比如有一类命令只能对指定的数据类型执行。比如 ZADD 及各种 ADD。
而有一些命令是可以对所有类型操作的,比如 TYPE DEL 等等。
为了确保命令可以被正确的执行,Redis 需要进行命令的检查,因为相信用户不会乱用是十分蠢的。
在所有命令被执行之前,Redis 会先检查输入的键的类型是否与命令匹配,这个检查就是用 redisObject 中的 type字段进行的:
如果匹配,则继续执行命令,如果不匹配则返回特定的错误信息。
除了进行类型检查之外,Redis 还应用了对象的类型进行命令的多态。
设想一下,列表对象可以使用 LLEN 命令来求出当前元素的个数,而在以前,列表对象的实现有可能是压缩列表,也可以是双端链表,那么对于他们而言,求出长度的方法肯定是不一样的。
正确步骤
Redis 会首先进行类型检查,之后根据当前对象的编码来决定当前命令应该调用哪个数据结构的 API
以此来实现命令的多态。
五 内存回收
学习 Java
的同志们看到这里是不是倍感亲切,仿佛看到了亲人一样。
众所周知,c
语言是没有自动化的内存管理的,但是 Redis
这么大的系统又不可能完全手动的控制内存使用,所以需要一套自动化的内存回收机制:
Redis 在自己的对象系统中,基于比较熟悉的引用计数实现了内存回收。
在 redisObject 对象中,还有一个额外的书序 refcount
- 创建对象时,引用计数为 1.
- 当对象被一个新程序使用时,引用计数+1.
- 当对象被一个程序抛弃的时候,引用计数-1;
- 当对象的引用计数为 0, 对象会被回收,它所占用的内存被释放掉。
对于这一块的具体实现我也没看,但是引用计数的原理想必各位都很清楚了,如果不清楚的话随便 google 一下JVM 内存回收基本上都会顺手讲到引用计数的。
六 对象共享
除了用于使用基于引用计数的内存回收之外,对象的引用计数的属性,还可以用来做一些对象共享的工作。
设想一下,首先你创建了一个 kye=a, value=100
的对象,过一会你又创建了个 key=b, value=100
的对象,如此循环往复。内存会无线增大,但是其实保存的只是同一个信息。
这些对象理论上来讲是完全可以进行共享的,即,首先我创建一个 value=100
的对象放在这里,每当你新创一个上面那样的对象时,我就把指针指过来就好了。
Redis
有选择性的这样子做了,当它共享之前,会先给对应的对象的引用计数+1, 之后把指针指过来。
为什么说是有选择性的呢?因为Redis
只会缓存0-9999
的数字字符串,如果你创建的键值对的值是这个,Redis 就会直接使用共享对象了。
为什么不多缓存一点呢?最好是把系统中所有相同的值全缓存起来,这样子最省内存了。Redis 不是最缺内存了吗?
是的,这样子当然是省内存,但是 Redis 是一个高性能的内存数据库呀,性能这一块,Redis 是不会这样做的。
想要判断两个对象的值是否相同,如果都是整数,只需要O(1)
时间复杂度。那如果都是字符串,那么需要O(N)
时间复杂度。 如果都是复杂对象(比如 hash), 那么可能需要 O(N2)
时间复杂度
但是 Redis 为了更好的性能,放弃了缓存更加复杂的对象。
七 对象淘汰
RedisObject 还有一个属性叫做 unsigned lru:32
;.
从名字我们就可以看出来它是做什么的了,它记录了当前对象最后一次被访问的时间。
这个时间会在 Redis 的内存使用满了之后,Redis
会进行对象的淘汰,其中有一种算法是 LRU
. 会用到对象上一次被访问的时间。
我们也可以手动的查看某一个对象的空转时长。空转时长=当前时间-最后一次访问时间.
总结
1)Redis 并没有直接使用数据结构来实现KV数据库,而是基于这些数据结构创建了一个对象系统。这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合这五种类型,然后里面每个对象都用到了至少一种前边的数据结构。
2)可以自由改进内部编码,而对外的数据结构和命令是没有影响的,多种内部编码可在不同场景下可以发挥各自的优势,从而优化对象在不同场景下的使用效率。
3)redisObject 结构来表示,该结构中保存数据相关的三个属性分别是:type、encoding、ptr
;
4)Redis 会首先进行类型检查,之后根据当前对象的编码来决定当前命令应该调用哪个数据结构的 API
以此来实现命令的多态。
5)引用计数实现了内存回收,并利用属性来对象共享,以及内存满之后LRU
算法进行对象的淘汰。