游戏开发工具

Redis数据对象与底层实现

引言

如果要了解redis的数据结构,可以从两个不同的层面来讨论它:

第一个层面,是从使用者的角度,这一层面也是Redis暴露给外部的调用接口,比如:string,list,hash,set,sorted set。

第二个层面,是从内部实现的角度,属于更底层的实现,比如:dict,sds,ziplist,quicklist,skiplist,intset。

在之前的文章中我们介绍了Redis的几种底层数据结构,本文我们介绍下基础数据类型是如何关联到这些数据结构上的。

redisObject

1、redisObject的作用

在redis的命令中,用于对键进行处理的命令占了很大一部分,而对于键所保存的值的类型(键的类型),键能执行的命令又各不相同。如: LPUSH 和 LLEN 只能用于列表键, 而 SADD 和 SRANDMEMBER 只能用于集合键, 等等; 另外一些命令, 比如 DEL、 TTL 和 TYPE, 可以用于任何类型的键;但是要正确实现这些命令, 必须为不同类型的键设置不同的处理方式: 比如说, 删除一个列表键和删除一个字符串键的操作过程就不太一样。

以上的描述说明, Redis 必须让每个键都带有类型信息, 使得程序可以检查键的类型, 并为它选择合适的处理方式. 比如说, 集合类型就可以由字典和整数集合两种不同的数据结构实现, 但是, 当用户执行 ZADD 命令时, 他/她应该不必关心集合使用的是什么编码, 只要 Redis 能按照 ZADD 命令的指示, 将新元素添加到集合就可以了。


这说明, 操作数据类型的命令除了要对键的类型进行检查之外, 还需要根据数据类型的不同编码进行多态处理。

为了解决以上问题, Redis 构建了自己的类型系统, 这个系统的主要功能包括:

1、redisObject 对象。

2、基于 redisObject 对象的类型检查。

3、基于 redisObject 对象的显式多态函数。

4、对 redisObject 进行分配、共享和销毁的机制。


2、redisObject数据结构

/* 对象类型 */
#define OBJ_STRING 0 // 字符串
#define OBJ_LIST 1 // 列表
#define OBJ_SET 2 // 集合
#define OBJ_ZSET 3 // 有序集
#define OBJ_HASH 4 // 哈希表


/* 对象编码 */
#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* 注意:版本2.6后不再使用. */
#define OBJ_ENCODING_LINKEDLIST 4 /* 注意:不再使用了,旧版本2.x中String的底层之一. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */


#define LRU_BITS 24

/* Redis 对象 */
typedef struct redisObject 
{
	// 类型
	unsigned type:4;
	// 编码方式
	unsigned encoding:4;
	// LRU - 24位, 记录最末一次访问时间(相对于lru_clock)
	unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */
	// 引用计数
	int refcount;
	// 指向底层数据结构实例
	void *ptr;
} robj;


redisObject 几个最重要的属性如下:

type:记录了对象所保存的值的类型,其值为下面几个常量之一

/* 对象类型 */
#define OBJ_STRING 0 // 字符串
#define OBJ_LIST 1 // 列表
#define OBJ_SET 2 // 集合
#define OBJ_ZSET 3 // 有序集
#define OBJ_HASH 4 // 哈希表


encoding:记录了对象所保存的值的编码,其值为下面几个常量之一

/* 对象编码 */
#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* 注意:版本2.6后不再使用. */
#define OBJ_ENCODING_LINKEDLIST 4 /* 注意:不再使用了,旧版本2.x中String的底层之一. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */

ptr:一个指针,指向实际保存值的数据结构,这个数据结构由type和encoding属性决定。举个例子, 如果一个redisObject 的type 属性为OBJ_LIST , encoding 属性为OBJ_ENCODING_QUICKLIST ,那么这个对象就是一个Redis 列表(List),它的值保存在一个QuickList的数据结构内,而ptr 指针就指向quicklist的对象。

在内存中的结构如下图:

1.jpg

 lru: 记录了对象最后一次被命令程序访问的时间。

基于该属性,Redis引入了一个叫做空转时长的概念,当前时间减去键的值对象的lru时间,就是该键的空转时长。Object idletime命令可以打印出给定键的空转时长


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


refcount:记录当前对象被引用了多少次,对象被创建时记录为1,当对一个对象进行共享时,redis将这个对象的refcount加一。当使用完一个对象后,或者消除对一个对象的引用之后,程序将对象的refcount减一。当对象的refcount降至0 时,这个RedisObject结构,以及它引用的数据结构的内存都会被释放。


关于引用计数,这里涉及到了对象共享,redis一般会把一些常见的值放到一个共享对象中,这样可使程序避免了重复分配的麻烦,也节约了一些CPU时间,redis预分配的值对象如下:

(1)各种命令的返回值,比如成功时返回的OK,错误时返回的ERROR,命令入队事务时返回的QUEUE,等等。(注意只有常用字符串,因为字符串的比较时间复杂度接近O(n))

(2)包括0 在内,小于REDIS_SHARED_INTEGERS的所有整数(REDIS_SHARED_INTEGERS的默认值是10000)


命令的类型检查和多态

当执行一个处理数据类型命令的时候,redis执行以下步骤

1、根据给定的key,在数据库字典中查找和他相对应的redisObject,如果没找到,就返回NULL;

2、检查redisObject的type属性和执行命令所需的类型是否相符,如果不相符,返回类型错误;

3、根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的数据结构;

4、返回数据结构的操作结果作为命令的返回值。


redis对象与底层结构对应关系

经过上文和《Redis:底层数据结构》的介绍,我们对Redis的对象机制和底层数据结构有了一些了解,下面我们来介绍下Redis中基本数据类型的实现方式(即基于何种基础结构实现)。


Redis提供的基础数据类型如下:

数据类型可以存储的值操作应用场景
STRING字符串、整数或者浮点数

对整个字符串或者字符串的其中一部分执行操作

对整数和浮点数执行自增或者自减操作

做简单的键值对缓存
LIST列表

从两端压入或者弹出元素

对单个或者多个元素进行修剪,只保留一个范围内的元素

存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的数据
SET无序集合

添加、获取、移除单个元素,检查一个元素是否存在于集合中,计算交集、并集、差集,从集合里面随机获取元素

交集、并集、差集的操作,比如交集,可以把两个人的粉丝列表整一个交集
HASH包含键值对的无序散列表

添加、获取、移除单个键值对,获取所有键值对,检查某个键是否存在

结构化的数据,比如一个对象
ZSET有序集合

添加、获取、删除元素,根据分值范围或者成员来获取元素,计算一个键的排名

去重但可以排序,如获取排名前几名的用户


1、String

 字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,其它几种数据类型构成的元素也是字符串。注意字符串的长度不能超过512M。


String类型可以存储数值和字符串,当存储字符串时,看过前文的朋友大概可以猜出时基于SDS实现的,而当存储的值是整数值时,String会判断其大小是否可以由long表示,可以的话直接保存,否则按字符串的模式保存。Redis通过3种不同的编码实现String的不同实现,这34种编码反方式分别为int,embstr和raw。

 三种实现方式

int 编码:保存的是可以用 long 类型表示的整数值(浮点数类型也是作为字符串保存的,在需要的时候再将其转换成浮点数类型)

1.jpg

embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)

2.jpg

raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)   

3.jpg

我们看到虽然同样是基于SDS结构实现的String字符串,却分为了embst和raw两种类型,从上图中我们可以看出embst存储的值比raw小,且embst的内存是直接和redisObject连接在一起的,而raw则是通过指针进行的连接,这是因为embstr编码是专门用来保存短字符串的一种优化编码。

embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。

因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。

而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。


编码的转换

(1)数值转字符串:当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。

(2)字符串修改:对于 embstr 编码,由于 Redis 没有对其编写任何的修改程序(embstr 是只读的),在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。

List

list 列表,它是简单的字符串列表,按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边),它的底层实际上是个链表结构。

1.jpg

列表对象的编码目前有三种,quicklist、zipmap、linkedlist,不过后两种并没有被使用,因此list类型的编码其实被固定为了quicklist,quicklist在前文中已经做了详细介绍,这里不多赘述。

Hash

哈希对象的编码可以是 ziplist 或者 hashtable;对应的底层实现有两种, 一种是ziplist, 一种是dict。

两种实现

1、编码为ziplist时的内存分布: 

2.jpg

 当使用ziplist作为底层实现时,新增的键值对是保存到压缩列表的表尾。


2、编码为hashtable时的内存分布: 

3.jpg

当使用dict作为底层实现时,哈希对象中的每个键值对都使用一个字典键值对(键与值均是以sds的形式存储的)。


其实使用dict作为Hash的实现已经足够了,就像我们java中的HashMap,但是Redis为了能够节省内存,将ziplist也提供为了hash的实现之一,就是为了能够在元素个数少、元素长度小的场景下尽量的压缩内存占用,达到节省空间的目的。

编码转换

当同时满足下面两个条件时,使用ziplist(压缩列表)编码:

1、列表保存元素个数小于512个

2、每个元素长度小于64字节

不能满足这两个条件的时候使用 hashtable 编码。以上两个条件也可以通过Redis配置文件zset-max-ziplist-entries 选项和 zset-max-ziplist-value 进行修改。

Set

实现方式

 集合对象的底层结构有2种,分别是intset和dict。 显然当使用intset作为底层实现的数据结构时, 集合中存储的只能是数值数据, 且必须是整数; 而当使用dict作为集合对象的底层实现时, 是将数据全部存储于dict的键中, 值字段闲置不用。

intset(注意只能存储整数):

1.jpg


dict(注意这里和Hash种的区别,Hash中key和value都有值,这里只有key有值):

2.jpg

编码转换

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

1、集合对象中所有元素都是整数

2、集合对象所有元素数量不超过512

不能满足这两个条件的就使用 hashtable 编码。第二个条件可以通过配置文件的 set-max-intset-entries 进行配置。

Zset

zset作为有序集合,使用score对元素进行排序。有序集合的底层实现依然有两种, 一种是使用ziplist作为底层实现, 另外一种比较特殊, 底层使用了两种数据结构: dict与zskiplist。


实现方式

使用ziplist时:

1.jpg


ziplist可以有效的节省内存,当数据量不大的时候使用ziplist是个不错的选择,参考上文在hash中使用ziplist作为底层实现的设置,分别使用2个相邻的节点作为key和value,在有序集合中redis将他们调整为了data和score,同时做好去重和排序的工作。

使用dict+zskiplist时:

2.jpg


我们从上图中可以看出,这里将dict中的table数组作为了zskiplistNode节点的指针,将两种结构结合了起来。

这么做的原因在于:

(1)假如我们单独使用 字典,虽然能以 O(1) 的时间复杂度查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序。

(2)假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作的复杂度变为了O(logN)。

将两者结合起来后,技能利用到dict的快速查找能力,又能利用到zskiplist自身有序的特点。

编码转换

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

1、保存的元素数量小于128;

2、保存的所有元素长度都小于64字节。

不能满足上面两个条件的使用 skiplist 编码。以上两个条件也可以通过Redis配置文件zset-max-ziplist-entries 选项和 zset-max-ziplist-value 进行修改。