Redis 底层数据结构

文章目录

  • 一、引言
  • 二、简单动态字符串
    • 2.1 内存管理
    • 2.2 简单动态字符串与C字符串的区别
      • 2.2.1 常数复杂度获取字符串长度
      • 2.2.2 杜绝缓冲区溢出
      • 2.2.3 减少修改字符串时带来的内存重分配次数
      • 2.2.4 二进制安全
      • 2.2.5 兼容部分C字符串函数
    • 2.3 常用API
  • 三、链表
    • 3.1 链表的特性
  • 四、字典
    • 4.1 数据结构
    • 4.2 rehash
    • 4.3 渐进式rehash
    • 4.4 字典API
  • 五、跳跃表
    • 5.1 跳跃表的实现
    • 5.2 跳跃表节点
      • 5.2.1 层
      • 5.2.2 前进指针
      • 5.2.3 跨度
      • 5.2.4 后退指针
      • 5.2.5 分值和成员
    • 5.3 跳跃表
    • 5.4 跳跃表API
  • 六、整数集合
    • 6.1 整数集合的实现
    • 6.2 升级
    • 6.3 整数集合API
  • 七、压缩列表
    • 7.1 压缩列表的构成
    • 7.2 压缩列表节点的构成
      • 7.2.1 previous_entry_length
      • 7.2.2 encoding
      • 7.2.3 content
    • 7.3 连锁更新
    • 7.4 压缩列表API
  • 八、对象
    • 8.1 类型
    • 8.2 字符串对象
    • 8.3 列表对象
      • 8.3.1 编码转换
    • 8.4 哈希对象
      • 8.4.1 编码转换
    • 8.5 集合对象
      • 8.5.1 编码的转换
    • 8.6 有序集合对象
      • 8.6.1 编码的转换
    • 8.7 内存回收
    • 8.8 对象共享

一、引言

Redis 是一个开源的的数据结构存储系统,它可以用作数据库、缓存和消息代理。由于其高效的数据处理能力和灵活的数据结构,Redis 在许多场景中得到了广泛的应用,了解 Redis 的底层数据结构对于深入理解其性能和功能至关重要。
Redis 的底层数据结构主要包括字简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表等。本文将详细介绍 Redis 的底层数据结构,帮助读者更好地理解和使用 Redis。

二、简单动态字符串

简单动态字符串(Simple Dynamic String,简称SDS)是Redis底层实现中使用的一种字符串表示方式,它具有自我调整和内存优化功能。SDS的结构如下:

struct sdshdr {
	// 记录buf数组中已使用字节的数量
	// 等于SDS所保存字符串的长度
	int len;
	//记录buf数组中未使用字节的数量
	int free;
	// 字符数组,用于保存字符串
	char buf[];
}

SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面

2.1 内存管理

Redis对SDS的内存管理进行了优化。当字符串长度小于16个字节时,Redis会尝试将字符串直接存储在SDS结构中。如果字符串长度超过16个字节,Redis会使用额外的内存来存储字符串。此外,当未使用的空间大于16个字节时,Redis会尝试回收未使用的空间,以减少内存浪费。

2.2 简单动态字符串与C字符串的区别

2.2.1 常数复杂度获取字符串长度

C字符串并不记录自身的长度信息,获取一个C字符串的长度,必须遍历整个字符串,对遇到的字符进行计数,直到遇到代表字符串结尾的空字符为止,复杂度为O(n);而SDS在len属性中记录了SDS的本身长度,复杂度为O(1)。

2.2.2 杜绝缓冲区溢出

C字符串不记录自身长度容易造成缓冲区溢出,SDS的空间分配策略完全杜绝了发生缓冲区的可能性。当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。

2.2.3 减少修改字符串时带来的内存重分配次数

通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略:

  1. 空间预分配,当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间
  2. 惰性空间释放,当SDS的API需要缩短SDS保存的字符串时,程序并不会立即使用内存分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,等待将来使用。

2.2.4 二进制安全

C字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符。Redis使用字节数组保存一系列的二进制数据,使用len属性的值而不是空字符判断字符串是否结束。

2.2.5 兼容部分C字符串函数

2.3 常用API

sdsnew(const char *init):创建一个新的SDS,并以C字符串init进行初始化。
sdsempty():创建一个空的SDS。
sdslen(const sds s):返回SDS中存储的字符串的长度。
sdsavail(const sds s):返回SDS中未使用的字节数。
sdsdup(const sds s):复制一个SDS,并返回副本。
sdscpy(sds s, const char *t):将C字符串t复制到SDS s 中。
sdscat(sds s, const char *t):将C字符串t追加到SDS s 的末尾。
sdscatsds(sds s, const sds t):将另一个SDS t 追加到SDS s 的末尾。
sdscmp(const sds s1, const sds s2):比较两个SDS的内容。
sdsfree(sds s):释放一个SDS的内存。

三、链表

Redis的链表是一种双向链表,每个节点都包含了一个指向下一个节点和上一个节点的指针。这种设计使得Redis的链表具有高效的插入、删除和搜索操作。
节点结构:每个节点都包含了一个指向下一个节点的指针(next)和一个指向前一个节点的指针(prev)。此外,节点还包含了存储数据的空间。

typedef struct listNode {
	// 前置节点
	struct listNode *prev;
	// 后置节点
	struct listNode *next;
	// 节点的值
	void *value;
}

链表结构:多个节点组合在一起,形成了Redis中的链表。链表的头部节点包含了一个指向第一个节点的指针(head)和一个指向最后一个节点的指针(tail)。

typedef struct list {
	// 表头节点
	listNode *head;
	// 表尾节点
	listNode *tail;
	// 链表所包含的节点数量
	unsigned long len;
	// 节点值复制函数
	void *(*dup)(void *ptr);
	// 节点值释放函数
	void (*free) (void *ptr);
	// 节点值对比函数
	int (*match)(void *ptr, void *key);
} list;

3.1 链表的特性

  • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是0(1)。
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针:程序获取链表的表头节点和表尾节点的复杂度为O(1)。
  • 带链表长度计数器:程序使用list结构的1en属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
  • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

四、字典

Redis的字典是一个哈希表,用于存储键值对数据。在Redis中,字典的键是唯一的,而值可以是任意类型。与传统的哈希表不同,Redis的字典提供了丰富的操作命令和特性,使得我们能够更加方便地管理和查询数据。

4.1 数据结构

Redis字典所使用的哈希表的结构定义如下:

typedef struct dictht {
	// 哈希表数组
	dictEntry **table;
	// 哈希表大小
	unsigned long size;
	// 哈希表大小掩码,用于计算索引值,总是等于 size-1
	unsigned long sizemask;
	// 该哈希表已有节点的数量
	unsigned long used;
} dictht;

哈希表节点使用dictEntry结构标识,每个dictEntry结构都保存着一个键值对:

typedef struct dictEntry {
	// 键
	void *key;
	// 值
	union {
		void *val;
		uint64_t u64;
		int64_t s64;
	} v;
	// 指向下个哈希表节点,形成链表
	struct dictEntry *next;
} dictEntry;

Redis中的字典由dict结构表示:

typedef struct dict{
	// 类型特定函数
	dictType *type;
	// 私有数据
	void *privdata;
	// 哈希表
	dictht ht[2];
	// rehash索引,当rehash不再进行时,值为-1
	int trehashidx;
} dict;

当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。哈希表使用链地址法来解决冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。

4.2 rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,也就是进行rehash。
rehash的步骤如下:
1、为字典中的ht[1]哈希表分配空间

  • 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2n
  • 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2n

例如执行扩展操作,如果ht[0].used为5,那么第一个大于等于ht[0].used*2的2n为16,也就是ht[1]的大小为16。
2、将保存在ht[0)中的所有键值对rehash到ht[I]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
3、当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

4.3 渐进式rehash

由于哈希表中可能保存着大量的键值对,如果要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。因为,为了避免rehash对服务器性能造成影响,服务器会分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。
以下是哈希表新进式rehash的详细步骤:

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
  3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

4.4 字典API

dictCreate 创建一个新的字典
dictAdd 将给定的键值对添加到字典里面
dictReplace 将给定的键值对添加到字典里面,如果键已经存在于字典,那么用新值取代原有的值
dictFetchValue 返回给定键的值
dictGetRandomKey 从字典中随机返回一个键值对
dictDelete 从字典中删除给定键所对应的键值对
dictRelease 释放给定字典,以及字典中包含的所有键值对

五、跳跃表

跳跃表(skipList)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。

5.1 跳跃表的实现

Redis的跳跃表有zskiplistNode和zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。

5.2 跳跃表节点

跳跃表节点的定义如下:

typedef struct zskiplistNode {
	// 后退指针
	struct zskiplistNode *backward;
	// 分值
	double score;
	// 成员对象
	robj *obj;
	// 层
	struct zskiplistLevel {
		// 前进指针
		struct zskiplistNode *forward;
		// 跨度
		unsigned int span;
	} level[];
} zskiplistNode;

5.2.1 层

跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度。

5.2.2 前进指针

每个层都由一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。

5.2.3 跨度

层的跨度用于记录两个节点之间的距离,两个节点之间的跨度越大,它们相距得就越远。指向NULL的所有前进指针的跨度都为0,因为他们没有连向任何节点。

5.2.4 后退指针

节点的后退指针用于从表尾向表头方向访问节点,因为每个节点都只有一个后退指针,所以每次只能后退至前一个节点。

5.2.5 分值和成员

节点的分支是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排列。节点的成员对象是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。

5.3 跳跃表

通过zskiplist结构来持有跳跃表节点,程序可以更方便地对整个跳跃表进行处理,zskiplist的结构定义如下:

typedef struct zskiplist {
	// 表头节点和表尾节点
	struct skiplistNode *header, *tail;
	// 表中节点的数量
	unsigned long length;
	// 表中层数最大的节点的层苏
	int level;
} zskiplist;

5.4 跳跃表API

zsICreate 创建一个新的跳跃表
zslFree 释放给定跳跃表,以及表中包合的所有节点
zslInsert 将包含给定成员和分值的新节点添加到跳妖表中
zslDelete 删除跳跃表中包含给定成员和分值的节点
zslGetRank 返回包含给定成员和分值的节点在跳跃表中的排位
zslGetElementByRank 返回跳跃表在给定排位上的节点

六、整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包括整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

6.1 整数集合的实现

整数集合的结构如下:

typedef struct intset {
	// 编码方式
	unint32_t encoding;
	// 集合包含的元素数量
	unint32_t length;
	// 保存元素的数组
	int8_t contents[];
} intset;

虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组真正的类型取决于encoding属性的值。

6.2 升级

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加集合里面。
升级整数集合并添加新元素共分为三步进行:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里面。

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升记后的状态。升级的好处有以下两点:

  1. 因为整数集合可以通过自动升记底层数组来适应新元素,所以可以随意地将int16_t、int32_t、int64_t类型的整数添加到集合中,而不担心出现类型错误。
  2. 整数集合的升级做法可以让集合能同时保存三种不同的值,又可以确保升级操作只会在需要的时候进行,可以尽量节约内存。

6.3 整数集合API

intsetNew 创建一个新的整数集合
intsetAdd 将给定元素添加到整数集合里面
intsetRemove 从整数集合中移除给定元素
intsetFind 检查给定值是否存在于集合
intsetRandom 从整数集合中随机返回一个元素
intsetGet 取出底层数组在给定索引上的元素
intsetLen 返回整数集合包含的元素个数
intsetBlobLen 返回整数集合占用的内存字节数

七、压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis会使用压缩列表来做列表键的底层实现。

7.1 压缩列表的构成

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存组成的顺序型数据结构。压缩列表各个组成部分的详细说明:

属性 类型 长度 用途
zlbytes uint32_t 4字节 记录整个压缩列表占用的内在字节数:在对压缩列表进行内存重分配,或者计算zlend的位置时使用
zltail uint32_t 4字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址
zllen uint16_t 2字节 记录了压缩列表包含的节点数量
entryX 列表节点 不定 压缩列表包含的各个节点,节点的长度由节点保存的内容决定
zlend uint8_t 1字节 特殊值0xFF(十进制255),用于标记压缩列表的末端

7.2 压缩列表节点的构成

每个压缩列表节点可以保存一个字节数组或者一个整数值,每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成。

7.2.1 previous_entry_length

节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节。因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址计算出前一个节点的起始地址

7.2.2 encoding

节点的encoding属性记录了节点的content属性所保存数据的类型以及长度。

  • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码,数组的长度由编码除去最高两位之后的其他记录;
  • 一字节长,值的最高位以11开头的是整数编码,整数值的类型和长度由编码除去最高两位之后的其他记录。

7.2.3 content

节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。

7.3 连锁更新

前面说过,每个节点的previous_entry_length属性都记录了前一个节点的长度:

  • 如果前一节点的长度小于254字节,那么previous_entry_length属性需要用1字节长的空间来保存这个长度值。
  • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性需要用5字节长的空间来保存这个长度值。

如果在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点。当我们新插入一个长度大于等于254字节的节点时,后面节点的previous_entry_length属性会从原来的1字节扩展为5字节长,这会导致后面节点的长度也大于253字节,由此引发连锁反应:程序需要不断对压缩列表执行空间重分配操作,称之为“连锁更新”。尽管连锁更新的复杂度较高,但是造成性能问题的几率还是很低的:

  • 首先,压缩列表要有多个连续的、长度介于250字节到253字节之间的节点;
  • 即使出现连锁更新,只有被更新的节点数量不多,也不会对性能造成影响。

7.4 压缩列表API

ziplistNew 创建一个新的压缩列表
ziplistPush 创建一个包含给定值的新节点,并将这个新节点添加到压缩列表的表头或者表尾
ziplistInsert 将包含给定值的新节点插入到给定节点之后
ziplistIndex 返回压缩列表给定索引上的节点
ziplistFind 在压缩列表中查找并返回包含了给定值 的节点
ziplistNext 返回给定节点的下一个节点
ziplistPrev 返回给定节点的前一个节点
ziplistGet 获取给定节点所保存的值
ziplistDelete 从压缩列表中删除给定的节点
ziplistDeleteRange 酬除压缩列表在给定索引上的连续多个节点
ziplistBlobLen 返回压缩列表目前占用的内存字节数
ziplistLen 返回压缩列表目前包含的节点数量

八、对象

8.1 类型

对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。

8.2 字符串对象

字符串对象的编码可以是int、raw或者embstr。

  • 如果字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串的编码设置为int;
  • 如果字符串对象保存的是字符串值,并且这个字符串的长度大于39字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw;
  • 如果字符串对象保存的是字符串值,并且这个字符串的长度小于等于39字节,那么字符串将使用embstr编码的方式来保存。

embstr编码的字符串对象实际上是只读的,embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象。

8.3 列表对象

列表对象的编码可以是ziplist或者linkedlist。ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点都保存了一个列表元素。linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点都保存了一个字符串对象,而每个字符串对象保存了一个列表元素。

8.3.1 编码转换

当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:

  • 列表对象保存的所有字符串元素的长度都小于64字节;
  • 列表对象保存的元素数量小于512个;不能满足这两个条件的列表对象需要使用linkedlist编码。

8.4 哈希对象

哈希对象的编码可以是ziplist或者hashtable。
ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的建值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入压缩列表表尾,然后再将保存了值的压缩列表节点推入压缩列表表尾,因此:

  • 保存同一键值对的两个节点总是紧挨在一起的;
  • 先添加到哈希对象中的键值对会被放在表头方向,后添加的放在表尾方向。

hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对使用一个字典键值对来保存:

  • 字典的每个键都是一个字符串对象,对象保存了键值对的键;
  • 字典的每个值都是一个字符串对象,对象保存了键值对的值。

8.4.1 编码转换

当哈希对象可以同时满足以下两个条件,哈希对象使用ziplist编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
  • 哈希对象保存的键值对数量小于512个;不能满足这两个条件的哈希对象需要使用hashtable编码。

8.5 集合对象

集合对象的编码可以是intset或者hashtable:

  • intset编码的集合对象使用整数结合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面;
  • hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值全部被设置为NULL。

8.5.1 编码的转换

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

  • 集合对象保存的所有元素都是整数值;
  • 集合对象保存的元素数量不超过512个。不能满足这两个条件的整数集合需使用hashtable编码。

8.6 有序集合对象

有序集合的编码可以是ziplist或者skiplist。ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素保存元素的分值。skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表,数据结构如下:

typedef struct zset {
	zskiplist *zsl;
	dict *dict;
} zset;

虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分支,也不会因此浪费额外的内存。

8.6.1 编码的转换

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

  • 有序集合保存的元素数量小于128个;
  • 有序集合保存的所有元素成员的长度都小于64字节。不能满足以上两个条件的有序集合对象将使用skiplist编码。

8.7 内存回收

Redis在自己的对象系统中构建了一个引用计数技术实现的内存回收机制,类似jvm的引用计数法。

8.8 对象共享

除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。也就是说,当有多个键对应的值对象是一样的时,可以让多个键共享同一个值对象,需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

目前,Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0-9999的所有整数值,当服务器需要用到值为0-9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。

你可能感兴趣的:(Redis,redis)