目录
一、前言
二、对象处理机制
1、redisObject 数据结构,以及 Redis 的数据类型
2、 命令的类型检查和多态
3、对象共享
4、引用计数以及对象的销毁
三、对象的处理
1、Redis是如何处理字符串对象的?
2、Redis是如何处理列表对象的?
3、Redis是如何处理哈希对象的?
4、Redis是如何处理集合对象的?
5、Redis是如何处理有序集合对象的?
6、Redis是如何处理bitmap对象的?
7、Redis是如何处理hyperloglog对象的?
8、Redis是如何处理地理位置对象的?
9、Redis是如何处理流对象的?
10、Redis是如何处理模块对象的?
四、小结
一、前言
既然 Redis 的键值对可以保存不同类型的值,那么很自然就需要对键值的类型进行检查以及多态处理。
为了让基于类型的操作更加方便地执行,Redis 创建了自己的类型系统。
在这一部分,我们将对 Redis 所使用的对象系统进行了解,并分别观察字符串、哈希表、列表、集合和有序集类型的底层实现。
二、对象处理机制
在 Redis 的命令中,用于对键(key)进行处理的命令占了很大一部分,而对于键所保存的值的类型(后简称“键的类型” ),键能执行的命令又各不相同。
比如说,LPUSH 和 LLEN 只能用于列表键,而 SADD 和 SRANDMEMBER 只能用于集合 键,等等。
另外一些命令,比如 DEL 、TTL 和 TYPE ,可以用于任何类型的键,但是,要正确实现这些命令,必须为不同类型的键设置不同的处理方式:比如说,删除一个列表键和删除一个字符串 键的操作过程就不太一样。
以上的描述说明,Redis 必须让每个键都带有类型信息,使得程序可以检查键的类型,并为它 选择合适的处理方式。
另外,在前面介绍各个底层数据结构时有提到,Redis 的每一种数据类型,比如字符串、列表、 有序集,它们都拥有不只一种底层实现(Redis 内部称之为编码,encoding),这说明,每当对 某种数据类型的键进行操作时,程序都必须根据键所采取的编码,进行不同的操作。
比如说,集合类型就可以由字典和整数集合两种不同的数据结构实现,但是,当用户执行 ZADD 命令时,他/她应该不必关心集合使用的是什么编码,只要 Redis 能按照 ZADD 命令的 指示,将新元素添加到集合就可以了。
这说明,操作数据类型的命令除了要对键的类型进行检查之外,还需要根据数据类型的不同编 码进行多态处理。
为了解决以上问题,Redis 构建了自己的类型系统,这个系统的主要功能包括: • redisObject 对象。
• 基于redisObject对象的类型检查。
• 基于redisObject对象的显式多态函数。
• 对redisObject进行分配、共享和销毁的机制。 以下小节将分别介绍类型系统的这几个方面。
Note: 因为 C 并不是面向对象语言,这里将 redisObject 称呼为对象一是为了讲述的方便, 二是希望通过模仿 OOP 的常用术语,让这里的内容更容易被理解,redisObject 实际上是只 是一个结构类型。
1、redisObject 数据结构,以及 Redis 的数据类型
redisObject 是 Redis 类型系统的核心,数据库中的每个键、值,以及 Redis 本身处理的参数,都表示为这种数据类型。 redisObject 的定义位于 redis.h :
/*
* Redis 对象
*/
typedef struct redisObject {
// 类型
unsigned type:4; // 对齐位
unsigned notused:2; // 编码方式
unsigned encoding:4;
// LRU 时间(相对于 server.lruclock)
unsigned lru:22; // 引用计数
int refcount; // 指向对象的值
void *ptr;
} robj;
type 、encoding 和 ptr 是最重要的三个属性。
type 记录了对象所保存的值的类型,它的值可能是以下常量的其中一个(定义位于 redis.h):
/*
* 对象类型
*/
#define REDIS_STRING 0 // 字符串
#define REDIS_LIST 1 // 列表
#define REDIS_SET 2 // 集合
#define REDIS_ZSET 3 // 有序集
#define REDIS_HASH 4 // 哈希表
encoding 记录了对象所保存的值的编码,它的值可能是以下常量的其中一个(定义位于 redis.h):
/*
* 对象编码
*/
#define REDIS_ENCODING_RAW 0 // 编码为字符串
#define REDIS_ENCODING_INT 1 // 编码为整数
#define REDIS_ENCODING_HT 2 // 编码为哈希表
#define REDIS_ENCODING_ZIPMAP 3 // 编码为 zipmap
#define REDIS_ENCODING_LINKEDLIST 4 // 编码为双端链表
#define REDIS_ENCODING_ZIPLIST 5 // 编码为压缩列表
#define REDIS_ENCODING_INTSET 6 // 编码为整数集合
#define REDIS_ENCODING_SKIPLIST 7 // 编码为跳跃表
ptr 是一个指针,指向实际保存值的数据结构,这个数据结构由 type 属性和 encoding 属性决 定。
举个例子,如果一个 redisObject 的 type 属性为 REDIS_LIST ,encoding 属性为 REDIS_ENCODING_LINKEDLIST ,那么这个对象就是一个 Redis 列表,它的值保存在一个双 端链表内,而 ptr 指针就指向这个双端链表;
另一方面,如果一个 redisObject 的 type 属性为 REDIS_HASH ,encoding 属性为 REDIS_ENCODING_ZIPMAP ,那么这个对象就是一个 Redis 哈希表,它的值保存在一个 zipmap 里,而 ptr 指针就指向这个 zipmap ;诸如此类。
下图展示了 redisObject 、Redis 所有数据类型、以及 Redis 所有编码方式(底层实现)三者 之间的关系:
这个图展示了 Redis 各种数据类型,以及它们的编码方式。
Note: REDIS_ENCODING_ZIPMAP 没有出现在图中,因为从 Redis 2.6 开始,它不再是任何数据类型的底层结构。
2、 命令的类型检查和多态
有了 redisObject 结构的存在,在执行处理数据类型的命令时,进行类型检查和对编码进行多态操作就简单得多了。 当执行一个处理数据类型的命令时,Redis 执行以下步骤:
-
根据给定key,在数据库字典中查找和它像对应的redisObject,如果没找到,就返回 NULL。
-
检查redisObject的type属性和执行命令所需的类型是否相符,如果不相符,返回类 型错误。
-
根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的 数据结构。
-
返回数据结构的操作结果作为命令的返回值。
作为例子,以下展示了对键 key 执行 LPOP 命令的完整过程:
3、对象共享
有一些对象在 Redis 中非常常见,比如命令的返回值 OK 、ERROR 、WRONGTYPE 等字符外,一些小范围的整数,比如个位、十位、百位的整数都非常常见。
为了利用这种常见情况,Redis 在内部使用了一个 Flyweight 模式 :通过预分配一些常见的值 对象,并在多个数据结构之间共享这些对象,程序避免了重复分配的麻烦,也节约了一些 CPU 时间。
Redis 预分配的值对象有如下这些:
-
各种命令的返回值,比如执行成功时返回的OK,执行错误时返回的ERROR,类型错误时返回的 WRONGTYPE ,命令入队事务时返回的 QUEUED ,等等。
-
包 括 0 在 内, 小 于 redis.h/REDIS_SHARED_INTEGERS 的 所 有 整 数(REDIS_SHARED_INTEGERS 的默认值为 10000)
因为命令的回复值直接返回给客户端,所以它们的值无须进行共享;另一方面,如果某个命令 的输入值是一个小于 REDIS_SHARED_INTEGERS 的整数对象,那么当这个对象要被保存进数据 库时,Redis 就会释放原来的值,并将值的指针指向共享对象。作为例子,下图展示了三个列表,它们都带有指向共享对象数组中某个值对象的指针:
三个列表的值分别为:
-
• 列表 A :[20130101, 300, 10086] ,
-
• 列表 B :[81, 12345678910, 999] ,
-
• 列表 C :[100, 0, -25, 123] 。
Note: 共享对象只能被带指针的数据结构使用。 需要提醒的一点是,共享对象只能被字典和双端链表这类能带有指针的数据结构使用。
像整数集合和压缩列表这些只能保存字符串、整数等字面值的内存数据结构,就不能使用共享 对象。
4、引用计数以及对象的销毁
当将 redisObject 用作数据库的键或者值,而不是用来储存参数时,对象的生命期是非常长 的,因为 C 语言本身没有自动释放内存的相关机制,如果只依靠程序员的记忆来对对象进行追 踪和销毁,基本是不太可能的。
另一方面,正如前面提到的,一个共享对象可能被多个数据结构所引用,这时像是“这个对象被 引用了多少次? ”之类的问题就会出现。
为了解决以上两个问题,Redis 的对象系统使用了引用计数技术来负责维持和销毁对象,它的 运作机制如下:
• 每个redisObject结构都带有一个refcount属性,指示这个对象被引用了多少次。
-
• 当新创建一个对象时,它的refcount属性被设置为1。
-
当对一个对象进行共享时,Redis 将这个对象的 refcount 增一。
-
当使用完一个对象之后,或者取消对共享对象的引用之后,程序将对象的refcount减 一。
-
当对象的refcount降至0时,这个redisObject结构,以及它所引用的数据结构的内 存,都会被释放。
三、对象的处理
1、Redis是如何处理字符串对象的?
在Redis中,字符串对象是指存储在键值对中的值,Redis使用简单动态字符串(Simple Dynamic String,SDS)数据结构来表示字符串对象。SDS是一种C语言的字符串抽象,类似于C语言中的字符串,但是它比C字符串更灵活并且可以动态修改大小。
当一个字符串被存储在Redis中时,Redis会根据字符串的长度来自动选择使用普通字符串对象或者SDS对象。如果一个字符串的长度小于等于39字节,那么Redis会使用普通字符串对象来表示。普通字符串对象是一个包含了指向字符数组的指针和字符串长度的结构体。如果一个字符串的长度大于39字节,那么Redis会使用SDS对象来表示。SDS对象是一个结构体,包含了字符串的长度、空余空间、字符数组和其他属性。
SDS字符串对象相较于C字符串具有以下优势:
- O(1)复杂度的常数时间获取字符串长度,而C字符串需要遍历整个字符串才能获取长度;
- 自动扩展和缩小字符串的空间,避免了缓冲区溢出和内存浪费;
- 记录了字符串的使用场景,可以根据场景对字符串进行优化处理;
- 支持二进制安全,可以存储任意二进制数据。
Redis的字符串对象在内存中以字节数组的形式存储,这使得Redis可以快速地对字符串进行读写操作。此外,Redis还提供了一系列的命令和操作符来处理字符串对象,如获取字符串长度、追加字符串、截取字符串、替换字符串等。通过这些操作,Redis可以高效地处理和操作字符串数据。
2、Redis是如何处理列表对象的?
Redis处理列表对象是通过使用链表数据结构来实现的。在Redis中,列表对象实际上是一个双向链表,每个节点都包含一个指向前一个节点和后一个节点的指针,以及存储实际数据的值。
当我们执行列表操作(例如插入、删除、更新等)时,Redis会根据需要操作链表来实现。例如,当我们执行LPUSH命令向列表的头部插入一个元素时,Redis会创建一个新的节点,并将其放置在头部的位置。之前的头节点将被指向新节点。
由于Redis使用链表来存储列表对象,所以在操作链表时,插入、删除、更新等操作都是常数时间复杂度(O(1))的。这使得Redis非常适合处理需要频繁插入、删除和更新操作的列表场景。
需要注意的是,虽然Redis的列表对象实际上使用链表实现,但它并不是一个完整的链表数据结构,它只实现了链表的基本操作,如插入、删除、更新等。如果需要更复杂的链表操作,可能需要在客户端或服务器端编写自定义逻辑来实现。
3、Redis是如何处理哈希对象的?
Redis中的哈希对象是指一个键值对集合,其中键是一个字符串,值是由字段和字段值组成的映射。Redis使用哈希对象来表示和存储一些类似于对象的数据结构,例如用户信息、商品信息等。
Redis使用一种称为"ziplist"的压缩列表来存储小型哈希对象。ziplist是一种连续的内存结构,可以高效地存储多个键值对。对于小型哈希对象,Redis将整个哈希对象存储在一个ziplist中。
对于较大的哈希对象,Redis会使用一种称为"hashtable"的哈希表来存储。哈希表是一种散列数据结构,可以更高效地处理较大的键值对集合。Redis使用MurmurHash2算法来计算键的哈希值,并使用开链法处理哈希碰撞。
Redis提供了一系列的命令来操作哈希对象,例如HSET、HGET、HDEL等。通过这些命令,可以向哈希对象添加字段、获取字段的值,以及删除字段等操作。
总结起来,Redis使用ziplist和hashtable两种数据结构来处理哈希对象,具体的选择取决于哈希对象的大小。通过使用压缩列表和哈希表,Redis能够高效地处理不同大小的哈希对象,提供快速的读写操作。
4、Redis是如何处理集合对象的?
在Redis中,集合对象是一种无序的、不重复的数据结构。Redis提供了一系列的命令来处理集合对象。以下是一些常用的集合操作命令:
- SADD key member [member ...]:向集合中添加一个或多个成员。
- SREM key member [member ...]:从集合中移除一个或多个成员。
- SCARD key:获取集合的成员数量。
- SMEMBERS key:返回集合中的所有成员。
- SISMEMBER key member:判断指定成员是否在集合中。
- SINTER key [key ...]:返回多个集合的交集。
- SUNION key [key ...]:返回多个集合的并集。
- SDIFF key [key ...]:返回多个集合的差集。
- SRANDMEMBER key [count]:随机获取集合中的一个或多个成员。
- SPOP key [count]:随机移除并返回集合中的一个或多个成员。
Redis的集合对象是基于哈希表实现的,所以集合的添加、删除、查找等操作的时间复杂度都是O(1)。此外,Redis还提供了一些集合操作的命令,如求交集、并集、差集等,这些操作的时间复杂度取决于操作的集合数量。
5、Redis是如何处理有序集合对象的?
在Redis中,有序集合(Sorted Set)是一个集合,其中的每个成员都关联着一个分数,分数用于对成员进行排序。有序集合的成员是唯一的,但分数可以重复。
Redis使用一种叫做跳跃表(Skip List)的数据结构来实现有序集合。跳跃表是一个有序的链表,并且通过一些特殊的指针,使得查找操作可以快速跳到链表的某个位置。
在跳跃表中,每个节点都保存了一个成员以及其对应的分数。通过比较成员的分数,可以进行排序。每个节点还保存了一个向右的指针,指向链表中下一个节点。此外,每个节点还会有一个向下的指针,指向同一层中的下一个节点。这些向下的指针可以在查找时快速跳过一些节点,从而提高查找的效率。
由于跳跃表是有序的,所以在插入、删除或更新有序集合中的成员时,只需要对跳跃表进行相应的操作即可。这些操作的时间复杂度通常是O(log N),其中N是有序集合中的成员数量。
总之,Redis使用跳跃表来实现有序集合,通过成员的分数进行排序,并提供高效的插入、删除和查找操作。
6、Redis是如何处理bitmap对象的?
Redis使用位图对象(bitmap)来表示一系列的二进制数据。每个bit位可以被设置为0或1。Redis提供了一系列的命令来操作位图对象,包括设置、获取、计数、位运算等。
Redis使用字符串来存储位图对象,每个bit位对应字符串中的一个字符。Redis会将多个bit位存储在同一个字符串中,以节省内存空间。Redis使用字节序来存储位图对象,从左到右依次存储每个bit位。每个字节可以存储8个bit位。
Redis提供了以下命令来操作位图对象:
SETBIT key offset value
:设置指定位的值,offset表示位的偏移量,value为0或1。如果key不存在,会自动创建一个新的位图对象。GETBIT key offset
:获取指定位的值,offset表示位的偏移量。BITCOUNT key [start end]
:获取指定范围内的位为1的个数,start和end表示范围的起始和结束位的偏移量。BITOP operation destkey key [key ...]
:对多个位图对象进行位运算,并将结果保存到destkey。operation可以是AND、OR、XOR和NOT。- 其他还有一些位图相关的命令,如BITPOS, BITFIELD等。
通过以上命令,可以方便地对位图对象进行各种操作,如统计位为1的个数、对多个位图对象进行位运算、判断某个位是否为1等。位图对象在实际应用中常用于统计、计数、过滤、去重等场景。
7、Redis是如何处理hyperloglog对象的?
Redis在处理hyperloglog对象时,使用一种名为HyperLogLog算法的基数估计算法。这种算法可以用于估计一个集合中不重复元素的数量。
具体而言,Redis中的hyperloglog对象实际上是一个稀疏的位数组,由底层的二进制字符串表示。这个位数组的长度是固定的,并且通常较短,以节省内存空间。
在向hyperloglog对象添加元素时,Redis会通过一个哈希函数将元素映射到一个位数组的索引位置,并将对应位置的位设置为1。这样,hyperloglog对象就可以通过统计位数组中为1的位的数量来估计集合中的不重复元素数量。
当用户需要获取估计的集合大小时,Redis会对位数组进行一定的处理,计算其中为1的位的数量,并使用HyperLogLog算法进行修正和估计。这个修正过的估计值会返回给用户。
需要注意的是,HyperLogLog算法是一种概率性算法,所以它提供的估计结果不是精确的。但是,它的估计结果通常非常接近实际的集合大小,并且相对于传统的存储方式,它占用的内存空间要大大减少。
8、Redis是如何处理地理位置对象的?
Redis处理地理位置对象的主要方式是使用Geo数据结构。Redis的Geo数据结构使用了有序集合(sorted set),其中地理位置对象被存储为成员(member),而经度和纬度被存储为分数(score)。
通过使用Geo数据结构,Redis提供了一系列的地理位置操作,包括添加地理位置对象、获取地理位置对象的经纬度、计算两个地理位置之间的距离等。
具体地,Redis的Geo数据结构包括以下命令:
- GEOADD:添加地理位置对象到有序集合中。
- GEORADIUS和GEORADIUSBYMEMBER:根据给定的经纬度和半径,获取在指定范围内的地理位置对象。
- GEOPOS:获取地理位置对象的经纬度。
- GEODIST:计算两个地理位置之间的距离。
通过这些命令,Redis可以高效地处理地理位置对象,并提供丰富的地理位置操作功能。
9、Redis是如何处理流对象的?
Redis是一个键值存储数据库,它将数据存储为键值对。在Redis中,可以将流对象存储为一个字符串类型的值。
当需要处理流对象时,可以使用Redis提供的一些命令和数据结构。以下是几个常用的处理流对象的方式:
-
字符串操作:使用Redis提供的字符串操作命令,例如GET和SET命令,可以将流对象存储为字符串,并且可以对字符串进行增删改查的操作。
-
列表操作:使用Redis提供的列表数据结构,例如LPUSH和RPUSH命令,可以将流对象存储为列表中的元素,可以通过索引对列表进行读取和修改。
-
哈希操作:使用Redis提供的哈希数据结构,例如HSET和HGET命令,可以将流对象存储为哈希表中的字段和值,可以通过字段进行读取和修改。
-
集合操作:使用Redis提供的集合数据结构,例如SADD和SMEMBERS命令,可以将流对象存储为集合中的元素,可以对集合进行交集、并集和差集等操作。
除了上述的基本操作,Redis还提供了更高级的功能,如发布/订阅模式、事务、Lua脚本等,可以更灵活地处理流对象的存储和操作。
总的来说,Redis通过将流对象存储为字符串、列表、哈希表或集合等不同的数据结构来处理流对象,并且提供了一系列的命令和数据结构,使得对流对象的操作更加方便和高效。
10、Redis是如何处理模块对象的?
在Redis中,模块对象是通过Module API来处理的。Redis模块允许开发者通过编写C语言代码来创建自定义的模块,为Redis添加新的功能和命令。
模块对象在Redis中被表示为一个结构体,包含一些成员变量和方法。其中,成员变量用于存储模块的状态和数据,而方法用于实现模块的功能和处理请求。
Redis提供了一组函数和宏来注册模块对象并与其进行交互。开发者可以使用RedisModule_CreateCommand
函数注册自定义命令,使用RedisModule_Call
函数调用命令,使用RedisModule_ReplyWith...
系列函数回复客户端请求。
模块对象可以在Redis的运行时加载和卸载。在加载模块时,Redis会调用模块的初始化函数来进行一些必要的初始化工作。在卸载模块时,Redis会调用模块的清理函数来释放资源和回收内存。
通过模块对象,开发者可以实现自定义的数据类型、数据结构和命令,扩展Redis的功能和性能。
四、小结
-
Redis 使用自己实现的对象机制来实现类型判断、命令多态和基于引用计数的垃圾回收。
-
一种 Redis 类型的键可以有多种底层实现。
-
Redis 会预分配一些常用的数据对象,并通过共享这些对象来减少内存占用,和避免频繁 地为小对象分配内存。