Redis简介
- Redis 是完全开源的,遵守 BSD 协议,是一个高性能的 key-value 数据库。
- 性能极高 – Redis能读的速度是110000次/s,写的速度是81000次/s 。
- 丰富的数据类型 – Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。
- 原子 – Redis的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过MULTI和EXEC指令包起来。
- 丰富的特性 – Redis还支持 publish/subscribe, 通知, key 过期等等特性。
Redis中的数据结构
字符串
Redis的字符串是自己构建的叫简单动态字符串(simple dynamic string,SDS)的抽象类型,并默认使用。C语言的字符串在Redis中只用在了一些无需对字符串值进行修改的地方,如日志等。当Redis需要的是一个可以被修改的字符串值时,就会使用SDS来表示字符串值,如Redis的字符串数据结构,以及Redis的键(key)
举例1:
redis> SET message "hello world"
键(key):message。底层保存着字符串"message"的SDS。
值(value):hello world。底层保存着字符串"hello world"的SDS。
举例2:
redis> RPUSH message "hello" "world" "name" "apple"
键(key):message。底层保存着字符串"message"的SDS。
值(value):列表。列表中包含的4个字符串,每个字符串的底层保存着该字符串的SDS。
除了用来保存字符串之外,SDS还被用作缓冲区(buffer):AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区,都是由SDS实现的。具体细节后面写到。
SDS的定义
可以理解成一个对象。对象中包含3个字段。int len,int free,char buf[]。
class string{
// 记录buf数组中已使用字节的数量,等于SDS所保存的字符串长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用来保存字符串,注意是字节数组,不是字符数组
char buf[];
}
值得注意的是buf数组的最后1个字节为'\0'。SDS遵循C语言字符串以空字符结尾的惯例,保存空字符的1字节不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都由SDS自动完成。对于使用来说,是完全透明的,不需要去管它。遵循空字符结尾这一惯例的好处是SDS可以直接重用一部分C语言字符串函数库里面的函数。
举例1:
redis> SET message "test"
"test"字符串的构成:
1.len属性为4。表示字符串长度是4.
2.free属性为0。表示没有分配任何的未使用空间。
3.buf数组为't' 'e' 's' 't' '\0'。(buf实际是字节数组,举例为了清晰所以这么写)
buf数组实际保存的是一系列的二进制数据。
C语言的字符串为什么末尾是’\0’
因为c语言中没有字符串类型,所以借助字符数组来存储字符串,为了区别字符串,需要在字符数组的末尾添加ASCII为0,即'\0',来作为字符串的结束标志,并且不计入字符串长度。
Redis使用SDS的好处
1、获取字符串长度,无需遍历字符数组,直接取len属性即可。
2、杜绝缓冲区溢出。当需要对SDS进行修改时,首先会检查SDS的空间是否满足被修改的需求(比如将message字符串修改成message redis字符串,buf数组需要扩容),如果不满足会自动将SDS的空间扩展至所需大小,然后才执行实际的修改操作,所以使用SDS不需要手动修改SDS空间大小,也不会出现缓冲区溢出问题(溢出的概念我理解的就是字符串长度变更后,调用一些函数对buf数组的影响)。
3、减少修改字符串时带来的内存重分配次数。字符串的拼接、截断等操作,都需要重新分配底层数组的空间大小,Redis作为数据库,如果频繁的来分配这个空间大小,那么对性能肯定会造成影响。为了解决这种问题,SDS通过未使用空间(free属性)解除了字符串长度和底层数组长度之间的关联,在SDS中,buf数组的长度不一定就是字符串+’\0’的长度,数组里可以包含未使用字节。
空间预分配:用于优化字符串的增长(扩容)操作。当对一个SDS修改操作,并需要对SDS进行空间扩展时,不仅会为SDS分配修改所需要的空间,还会为SDS分配额外的未使用空间(free)。未使用空间由公式决定:
(1)对SDS修改后,SDS的长度(len)小于1MB,那么会分配和len同样大小的未使用空间,这时len和free相同。
(2)对SDS修改后,SDS的长度(len)大于1MB,那么会分配1MB的未使用空间。如修改后len为30MB,那么free就为1MB。
惰性空间释放:用于优化字符串的缩短操作。当需要缩短SDS保存的字符串时,不会立即收缩多出来的字节,而是使用free属性将这些字节数量记录起来,等待再次使用。如果将来需要增长时,可能会派上用场。
4、二进制安全。C语言的字符串中的字符必须符合某种编码(如ASCII),并且除了末尾之外,字符串里不能包含空字符,否则最先被程序读入的空字符串将被误认为是字符串结尾,这使得C语言的字符串只能保存文本数据,而不能保存图片、音频、视频、压缩文件这样的二进制数据。所以使用SDS数据写进去什么样,读出来就是什么样。
5、兼容部分C语言字符串函数。
链表
链表提供了高效的节点重排能力,以及顺序性的访问方式,并且可以通过增删节点来灵活地调整链表的长度。作为一种常用的数据结构,链表内置在很多高级编程语言里,因为Redis使用的C语言并没有内置这种数据结构,所以Redis构建了自己的链表实现。Redis在发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还是用链表保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区。
链表和链表节点的实现
和java的Linked很像。
链表节点的实现
class ListNode{
//前置节点
ListNode prev;
//后置节点
ListNode next;
//节点的值
void* value;
}
虽然仅仅使用多个ListNode结构就可以构成链表,但使用list,操作起来会更方便:
链表的实现
class list{
//表头节点
ListNode head;
//表尾节点
ListNode tail;
//节点数量
long len;
//函数:复制链表节点所保存的值
void *dup();
//函数:释放链表节点所保存的值
void *free();
//函数:对比链表节点所保存的值和另一个输入值是否相等
int *match();
}
链表特性
双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度为O(1)
无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表头节点和尾节点的复杂度为O(1)
带链表长度计数器:程序使用list结构的len属性对链表长度计数,获取链表长度的复杂度为O(1)
多台:链表节点使用void*来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点设置类型特定函数,所以链表可以用于保存各种不同类型的值。
字典
一种用于保存键值对(key-value pair)的抽象数据结构。一个键(key)可以和一个值(value)进行关联,每个键都是独一无二,可以通过键来查找值,更新值,删除值等等。C语言并没有这种数据结构,所以Redis构建了自己的实现。Redis的数据库就是使用字典来做底层实现的,对数据库的增删改查操作也是构建在对字典的操作之上的。Redis的字典使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
举例1:
redis> SET msg "hello"
创建了一个key是msg,value是hello的键值对,这个键值对就是保存在字典里面。
除了用来表示数据库,字典还是哈希键(hash)的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。
举例2:
website(key)是一个包含很多个键值对的哈希键,例如:
redis(item) -> redis.io(value)
mariadb(item) -> mariadb.org(value)
mongodb(item) -> mongodb.org(value)
website(key)键的底层就是一个字典,字典中包含了很多键值对的元素。
哈希表
class dictht{
//哈希表数组
dictEntry **table;
哈希表大小
long size;
//哈希表大小掩码,用于计算索引值,总是=size-1
long sizemask;
//该哈希表已有节点数量
long used;
}
table属性是一个数组,数组中的每个元素都是指向一个dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
size属性记录了哈希表的大小,也是table数组的大小。
used属性记录了哈希表目前已有节点(键值对)的数量。
sizemask属性的值总是=size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。
哈希表节点
哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对
class dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64
} v;
//指向下个哈希表节点,形成单向链表
dictEntry *next;
}
key:保存着键值对中的键
v:保存着键值对中的值,键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数
next:指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突的问题
字典
class dict{
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash 索引,当rehash不再进行时,值为-1
int rehashidx;
}
type属性和privdata属性是针对不通类型的键值对,为创建多态字典而设置的
type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
而privdata属性则保存了需要传给那些类型特定函数的可选参数
ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只是用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有进行rehash,那么它的值为-1。
class dictType{
//计算哈希值的函数
int *hashFunction;
//复制键的函数
void *keyDup;
//复制值得函数
void *valDup;
//对比键的函数
int *keyDestructor;
//销毁值的函数
void *valDestructor;
}
哈希算法
当要将一个新的键值对添加到字典时,先根据键值对的键计算出哈希值的索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。Redis计算哈希值和索引值的方法:
使用字典设置的哈希函数,计算键key的哈希值 hash = dict -> type -> hashFunction(key); 使用哈希表的sizemask属性(size-1)和哈希值,计算出索引值,根据情况不同,ht[x]可以是ht[0]或者ht[1] index = hash & dict -> ht[x].sizemask;
举例:于上图字典来说,如果将一个键值对k0和v0添加到字典里面:
1.hash= dict -> type -> hashFunction(k0);
计算键k0的哈希值。假设计算结果哈希值为8,接下来:
2.index = hash & dict -> ht[0].sizemash = 8 & 3 = 0;
计算出键k0的索引值是0,这表示包含键值对k0和v0的节点应该被防止到哈希表数组的索引0位置上。如下图。
当字典被用作数据库的底层实现,或者哈希建的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。这种算法的有点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。MurmurHash算法目前的最新版本为MurmurHash3,而Redis使用的是MurmurHash2。
解决键冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上时,称这些键发生了冲突。Redis的哈希表使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单项链表,被分配到同一个索引上的多个节点可以用这个单项链表连接起来,这跟JDK1.7的HashMap底层类似。
rehash
随着操作的不断执行,哈希表保存的键值对会主键地增多或减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,会对哈希表的大小进行相应的扩展和收缩。扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典哈希表执行rehash步骤如下:
- 为字典ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(即ht[0].used属性(哈希表已有节点数量)的值)。如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2的n次方幂。如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次方幂。
- 将保存在ht[0]中的所有键值对rehash到ht[1]哈希表的指定位置上。
- 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
举例:假设对上图字典的ht[0]进行扩展操作,步骤如下:
1.ht[0].used当前的值为4,4 * 2 = 8,而8(2的3次方)恰好是第一个大于等于4的2的n次方,所以程序会将ht[1]的哈希表大小设置为8。为ht[1]在分配空间之后的样子如下图:
2.将ht[0]包含的四个键值对都rehash到ht[1]。如下图:
3.释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表。如下图:
哈希表的扩展与收缩
当以下条件任意一个满足时,执行对哈希表扩展操作:
1.服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1.
2.服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5.
其中哈希表的负载因子可以通过公式计算得出:
负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
渐进式rehash
上面说扩展或收缩哈希表将ht[0]里面的所有键值对rehash到ht[1]里面,但是这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成。原因在于如果ht[0]里只保存这4个键值对,那么服务器可以再瞬间就将这些键值对全部rehash到ht[1];但是如果哈希表里保存的键值对有百万、千万甚至更多时,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。为了避免rehash对服务器性能造成影响,不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢滴地rehash到ht[1]。详细步骤如下:
1.为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
2.在字典中维持一个索引计数器变量rehashidx,并将它设置为0,表示rehash工作正式开始。
3.在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增1.
4.随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash到ht[1],这时程序将rehashidx属性设为-1,表示rehash操作已完成。
渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash带来的庞大计算量。
渐进式rehash执行期间的哈希表操作
在执行rehash过程中,同事会操作ht[0]和ht[1]两个哈希表,所以在渐进式rehash期间,字典的增删改查操作会在两个哈希表上进行。如查找,会现在ht[0]里找,没有就会到ht[1]里找。另外,在渐进式rehash期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。
跳跃表
跳跃表是一种有序的数据结构,它通过每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis使用跳跃表作为有序集合的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表作为有序集合的底层实现。Redis在两个地方使用了跳跃表,一个是有序集合,另一个是集群节点中用作内部数据结构,除此之外,跳跃表在Redis中没有其他用途。
跳跃表的实现
上图为一个跳跃表结构,左侧为zskiplist结构,该结构包含以下属性:
header:指向跳跃表的表头节点。
tail:指向跳跃表的表尾节点。
level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)
length:记录跳跃表的长度,即跳跃表目前包含节点的数量(表头节点不计算在内)
位于zskiplist结构右方的4个是zskiplistNode结构,该结构包含以下属性:
层(level):节点中用L1、L2、L3等标记节点的各个层,L1第一层,L2第二层以此类推。每个层都有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的举例。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
后退(backward)指针:节点中用BW标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
分值(scope):各个节点中1.0,2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
成员对象(obj):各个节点中o1、o2、o3是节点所保存的成员对象。
注意表头节点和其他节点的结构是一样的,表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性都不会被用到,所以图中省略了这部分,只显示表头节点的各个层。
跳跃表节点
class zdkiplistNode{
//层
class zskiplistLevel{
//前进指针
zskiplistNode *forward;
//跨度
int span;
} level[];
//后退指针
zskiplistNode *backward;
//分值
double scope;
//成员对象
Obj obj;
}
层:跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。每次创建一个跳跃表节点的时候,程序都根据幂次定律随机生成1个介于1-32之间的值作为level数组的大小,这个大小就是层的高度。
前进指针:每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点,遍历时直到碰到NULL结束遍历。
图中虚线为遍历操作。遍历时,首先访问第一个节点(表头),然后从第四层的前进指针移动到表中第二个节点。在第二个节点时,沿着第二层的前进指针移动到表中的第三个节点。在第三个节点时,同样沿着第二层的前进节点移动到表中的第四个节点。当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,则已经达到了跳跃表的末尾,则结束遍历。一般遍历时会先从最上层开始遍历,逐步向下层遍历。
跨度:用于记录两个节点之间的距离。两个节点之间的跨度越大,他们相距的就越远;指向NULL的所有前进指针的跨度都为0,因为他们没有连向任何节点。
跨度很容易以为跨度和遍历操作有关,但实际不是这样,遍历操作只是用前进指针就可以完成,跨度实际是用来计算排位(rank)的,在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表的排位。
举例1:
上图虚线标记了在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经过的层,查找过程只经过了1个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3。
举例2:
上图虚线标记了再跳跃表中查找分支为2.0、成员对象为o2的节点时,沿途经过的层,查找过程经过了2个跨度为1的节点,因此可以算出,目标节点在跳跃表中的排位为2。
后退指针:节点的后退指针(backward)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
分值和成员:节点的分值(scope)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大排序。节点的成员对象(obj)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。在同一跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的,分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。
跳跃表
仅靠多个跳跃表节点就可以组成一个跳跃表,但通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速的获取跳跃表节点的数量(即跳跃表长度)等信息。
class zskiplist{
//表头节点和表尾节点
skiplistNode *header *tail;
//表中节点的数量
long length;
//表中层数最大的节点的层数
int level;
}
整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
整数集合的实现
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或int64_t的整数值,并且保证集合中不会出现重复元素。
class intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}
contents数组是证书集合的底层实现:证书集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值得大小从小到大有序排列,并不包含任何重复项。
length属性记录了证书集合包含的元素数量,即contents数组的长度。
虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值。
如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t数组,数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)
如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t数组,数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647)
如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t数组,数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)
升级
每当要将一个新元素添加到整数集合中,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。升级整数集合并添加新元素步骤如下:
1.根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
2.将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
3.将新元素添加到底层数组中。
举例:上图假设现有一个INTSET_ENC_INT16编码的整数集合,集合中包含三个int16_t类型的元素(图6-3)。因为每个元素都占用16位空间,所以整数集合底层数组的大小为316=48位,图6-4展示了整数集合的三个元素在这48位里的位置。现在,假设将类型为int32_t的整数值65535添加到整数集合里面,因为65535的类型int32_t比整数集合当前所有元素的类型都要长,所以将65535添加到整数集合之前,需要先对整数集合进行升级。
根据新类型的长度,以及集合元素的数量(包括要添加的新元素在内),对底层数组进行空间重分配。
整数集合目前3个元素,再加上新元素65535,整数集合需要分配4个元素的空间,因为每个int32_t整数值需要占用32位空间,所以在空间重分配之后,底层数组的大小将是324=128位(图6-5)。虽然进行了空间重分配,但数组原有的3个元素还是int16_t类型,这些元素还保存在数组的前48位里面,所以接下来就是将这3个元素转换成int32_t类型,并将转换后的元素放置到正确的位上面,而且放置过程中,需要维持底层数组的有序性质不变。
1.因为元素3在1、2、3、65535四个元素中排第三,所以它将被移动到contents数组的索引2位置上,即数组64位至95位的空间内,如图6-6。
2.因为元素2在1、2、3、65535四个元素中排第二,所以它将被移动到contents数组的索引1位置上,即数组的32位至63位的空间内,如图6-7。
3.因为元素1在1、2、3、65535四个元素中排第一,所以它将被移动到contents数组的索引0位置上,即数组的0位至31位的空间内,如图6-8。
4.因为元素65535在1、2、3、65535四个元素中排第四,所以它将被移动到contents数组的索引3位置上,即数组96位至127位的空间内,如图6-9。
5.程序将整数集合encoding属性的值从INTSET_ENC_INT16改为INTSET_ENC_INT32,并将length属性的值从3改为4,如图6-10。
因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)。其他类型的升级,如int16升级到64,int32升级到64,过程和上面展示的升级过程类似。
新元素的摆放位置:因为引发升级,则新元素的长度肯定比集合原有元素的长度大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素;在新元素小于所有现有元素时,新元素会被放置在底层数组的开头(索引0);在新元素大于所有现有元素时,新元素会被放置在底层数组的末尾(length-1)。 升级有2个好处: 一是提升整数集合的灵活性。因为C语言是静态类型语言,为了避免类型错误,通畅不会将两种不同类型的值放在同一个数据结构中。所以证书集合可以通过自动升级底层数组来适应新元素,而不必担心出现类型错误。 二是尽可能地节约内存。如果让一个数组同事保存int16_t,int32_t,int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组,不过这样的话,如果有int16_t或int32_t的元素都将使用int64_t类型的空间去保存他们,从而出现浪费内存的情况。而整数集合现在的做法既可以让集合同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存。
降级
整数集合不支持降级操作,一旦对数组做了升级,编码就会一直保持升级后的状态。
压缩列表
压缩列表(ziplist)是列表键(zset)和哈希键(hash)的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
同整数集合一样压缩列表也不是基础数据结构,而是 Redis 自己设计的一种数据存储结构。它有点儿类似数组,通过一片连续的内存空间,来存储数据。不过,它跟数组不同的一点是,它允许存储的数据大小不同。
听到“压缩”两个字,直观的反应就是节省内存。之所以说这种存储结构节省内存,是相较于数组的存储思路而言的。我们知道,数组要求每个元素的大小相同,如果我们要存储不同长度的字符串,那我们就需要用最大长度的字符串大小作为元素的大小(假设是20个字节)。存储小于 20 个字节长度的字符串的时候,便会浪费部分存储空间。
redis> RPUSH lst 1 3 5 10086 "hello" "world"
当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。
redis> HMSET profile "name" "Jack" "age" 28 "job" "Programmer"
redis>OBJECT ENCODING profile
输出:ziplist
上图展示了压缩列表的各个组成部分及各个组成部分的类型、长度以及用途。
压缩列表节点的构成
每个压缩列表节点可以保存一个字节数组或者一个整数值,每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成。
previous_entry_length:该属性以字节为单位,记录了压缩列表中前一个节点的长度。如果前一个节点的长度小于254字节,那么使用1字节来保存这个长度值。如果前一个节点的长度大于等于254字节,那么使用5字节来保存这个长度值。
图7-5展示了一个包含1字节长previous_entry_length属性的压缩列表节点,属性的值为0x05,表示前一个节点长度为5字节。
图7-6展示了一个包含5字节长previous_entry_length属性的压缩节点,属性值为0xFE00002766,其中值的最高位字节0xFE表示这是一个5字节长的previous_entry_length属性,之后的4字节0x00002766(十进制为10086)才是前一节点的实际长度。
因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
举例:指针c起始地址,zhizhenc减当前节点的previous_entry_length属性的值,就可以得出一个指向前一个节点起始地址的指针p。压缩列表从表尾向表头遍历就是使用这一原理实现的。
encoding:该属性记录了节点的content属性所保存数据的类型以及长度。
content:该属性保存节点的值,节点值可以是一个字节数组或者整数,值类型和长度由节点的encoding属性决定。
连锁更新
假设现在有多个连续的、长度介于250字节到253字节之间的节点e1至en。
因为e1至en的所有节点长度都小于254字节,所以每个节点的previous_entry_length属性都为1字节长的,此时将一个长度大于等于254字节的新节点new插入,作为e1的前置节点:
因为e1节点的previous_entry_length属性为1字节,它没办法保存新节点new的长度,所以需要对压缩列表执行空间重分配,并将e1节点的previous_entry_length属性从1字节扩展为5字节。若e1更改了,那么e2同样需要记录e1的长度,也需要更改,后面的e4,e5…en都需要更改previous_entry_length属性为5字节长,这种情况成为“连锁更新”。除了添加新节点可能引起连锁更新外,删除节点也可能会引发连锁更新。
尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的:
- 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才被触发,实际中这种情况并不多见。
- 其次,及时出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响。