目录
1.Redis特性
Redis是一个远程内存数据库,它不仅性能强劲,而且还具有复制特性以及为解决问题而生的独一无二的数据模型。Redis提供了5种不同类型的数据结构,各式各样的问题都可以很自然地映射到这些数据结构上。它可以将存储在内存的键值对数据持久化到硬盘,可以使用复制特性来扩展读性能,还可以使用客户端分片来扩展写性能。
分片是一种将数据划分为多个部分的方法,对数据的划分可以基于键包含的ID、基于键的散列值,或者基于以上两者的某种组合。通过对数据进行分片,用户可以将数据存储到多台机器里面,也可以从多台机器里面获取数据,这种方法在解决某些问题时可以获得线性级别的性能提升。
高性能键值缓存服务器memcached也经常被拿来与redis进行比较:这两者都可以用于存储键值映射,性能也相差无几,但是Redis除了能存储普通的字符串以外,还可以存储其他4种数据结构,而memcached只能存储普通的字符串键。“当服务器关闭的时,服务器存储的数据将何去何从?” Redis拥有两种不同形式的数据持久化方法,它们都可以用小而紧凑的格式将存储在内存的数据写入硬盘:第一种持久化的方法为时间点转储,转储操作既可以在指定时间段内有指定数量的写操作执行,又可以通过调用转储到硬盘命令来执行;第二种持久化方法将所有修改了数据库的命令都写到一个只追加文件里面,用户可以根据数据的重要程度,将只追加写入设置为从不同步、每秒同步一次或者每写入一个命令就同步一次。
为了扩展Redis的读性能,并为Redis提供故障转移支持,Redis实现了主从复制的特性:执行复制的从服务器会连接上主服务器,接收主服务器发送的整个数据库的初始副本;之后主服务器执行的写命令,都会被发送给所有连接着的从服务器去执行,从而实时地更新从服务器的数据集。因为从服务器包含的数据会不断地进行更新,所以客户端可以向任意一个从服务器发送读请求,以此来避免对主服务器进行集中式的访问。
2.Redis数据结构
Redis所有的数据结构都是以唯一的key字符串作为名称,然后通过这个唯一的key值来获取相应的value数据。不同类型的数据结构的差异在于value的结构是不一样的。Redis可以存储键与5种不同数据结构类型之间的映射,这5种数据结构类型分别为:STRING、LIST、SET、HASH和ZSET(有序集合)。
结构类型 | 结构存储的类型 | 结构的读写能力 |
---|---|---|
STRING | 整数、字符串或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作;对整数和浮点数执行自增或者自减操作。 |
LIST | 一个链表,链表上的每个节点都包含了一个字符串 | 从链表的两端推入或者弹出元素;根据偏移量对链表进行修剪;读取单个或者多个元素;根据值查找或者移除元素。 |
SET | 包含字符串的无序收集器,并且被包含的每个字符串都是独一无二、各不相同的 | 添加、获取、移除单个元素;检查一个元素是否存在于集合中;计算交集、并集、差集;从集合里面随机获取元素。 |
HASH | 包含键值对的无序散列表 | 添加、获取、移除单个键值对;获取所有键值对 |
ZSET(有序集合) | 字符串成员与浮点数分值之间的有序映射,元素的排列顺序由分值的大小决定 | 添加、获取、删除单个元素;根据分值范围(range)或者成员来获取元 |
2.1 字符串
Redis的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于Java的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。内部为当前字符串实际分配的空间一般要高于实际字符串的长度。当字符串的长度小于1M时,扩容都是扩一倍,如果超过1M,扩容时一次最多扩1M的空间。需要注意的是,字符串的最大长度是512M。
2.1.1 字符串的基本操作
redis关于字符串的命令主要有三个:GET 获取存储在给定键中的值;SET 设置存储在给定键中的值;DEL删除存储在给定键中的值(这个命令可以用于所有的类型)。
MyRedis:0>set name hello
OK
MyRedis:0>get name
hello
MyRedis:0>exists name
1
MyRedis:0>del name
1
MyRedis:0>get name
NULL
MyRedis:0>
我们也可以批量对多个字符串进行读写,这样可以节省网络的耗时开销:
MyRedis:0>set name1 hello
OK
MyRedis:0>set name2 world
OK
MyRedis:0>mget name1 name2 name3
1) hello
2) world
3)
MyRedis:0>mset name1 man name2 woman name3 lady
OK
MyRedis:0>mget name1 name2 name3
1) man
2) woman
3) lady
MyRedis:0>
可以对key设置过期时间,到点自动删除,这个功能常用来控制缓存的失效时间。
MyRedis:0>set name hello
OK
MyRedis:0>get name
hello
MyRedis:0>expire name 5 #设置过期时间 5s
1
MyRedis:0>get name
NULL
MyRedis:0>setex name 5 world #创建的时候设置过期时间为5s
OK
MyRedis:0>get name
NULL
MyRedis:0>setnx name java #如果name不存在就执行创建set
1
MyRedis:0>get name
java
MyRedis:0>setnx name python #name已经存在 创建不成功,可以用作分布式锁
0
MyRedis:0>get name
java
如果value的值是一个整数,还可以对其进行自增操作,自增的范围值和long是相同的。
MyRedis:0>set age 10
OK
MyRedis:0>incr age
11
MyRedis:0>incrby age 5
16
MyRedis:0>incrby age -6
10
MyRedis:0>set age 9223372036854775807 #设置为最大值
OK
MyRedis:0>get age
9223372036854775807
MyRedis:0>incr age
ERR increment or decrement would overflow
字符串是由多个字节组成,每个字节又由8个bit组成,因此可以将字符串看成很多bit的组合,这便是bitmap【位图】的数据结构。
2.2 列表(list)
Redis的列表相当于Java语言里面的LinkedList,这意味着list的插入和删除操作非常快,时间复杂度为O(1),但是索引定位会很慢,时间复杂度为O(n)。当列表弹出了最后一个元素之后,该数据结构会自动被删除,内存被回收。Redis的列表结构常用来做异步队列使用,将需要延后处理的任务结构体序列化成字符串塞进redis的列表,另一个线程从这个列表中轮询数据进行处理。
Redis中的列表命令主要有以下几个:
- lpush和rpush命令分别用于将元素推入到列表的左端和右端。
- lpop命令和rpop命令分别用于从列表的左端和右端弹出元素。
- lindex命令用于获取列表在给定位置上的一个元素。
- lrange命令用于获取列表在给定范围上的所有的元素。
2.2.1 右边进左边出:队列
MyRedis:0>rpush books python java go
3
MyRedis:0>llen books
3
MyRedis:0>lpop books
python
MyRedis:0>lpop books
java
MyRedis:0>lpop books
go
MyRedis:0>lpop books
NULL
在向列表中推入新的元素之后,该命令会返回列表当前的长度。从列表中使用lpop弹出一个元素之后,被弹出的元素将不再存在于列表。
2.2.2 右边进右边出:栈
MyRedis:0>rpush books python java go
3
MyRedis:0>rpop books
go
MyRedis:0>rpop books
java
MyRedis:0>rpop books
python
MyRedis:0>rpop books
NULL
2.2.3 慢操作
lindex相当于Java链表的get(int index)方法,它需要对链表进行遍历,性能随着参数index增大而变差。ltrim定义了两个参数start_index和end_index定义了一个区间,在该区间内的值ltrim要保留,区间之外的删除。我们可以通过ltrim来实现一个定长的列表。index为负数,表示倒数第几个元素。
MyRedis:0>rpush books python java go
3
MyRedis:0>lindex books 1
java
MyRedis:0>lrange books 0 -1 #获取全部的元素
1) python
2) java
3) go
MyRedis:0>ltrim books 1 -1
OK
MyRedis:0>lrange books 0 -1
1) java
2) go
MyRedis:0>ltrim books 1 0 #区间范围为负,表示清空了全部的列表元素
OK
MyRedis:0>llen books
0
2.3 集合(set)
Redis的集合相当于Java语言里面的HashSet,它内部的键值对是无序的唯一的。Redis的集合和列表都可以存储多个字符串,它们之间的不同之处在于,列表可以存储多个相同的字符串,而集合则通过使用散列表来保证自己存储的每个字符串都是各不相同的。集合的主要命令如下:
- sadd将给定的元素添加到集合。
- smembers返回集合包含的所有元素。
- sismember检查给定的元素是否存在于集合中。
- srem:如果给定的元素存在于集合中,那么移除这个元素。
在尝试将一个元素添加到集合的时候,命令返回1表示元素添加成功,而返回0则表示这个元素已经存在于集合中。在使用命令移除集合中的元素时,命令会返回被移除元素的数量:
MyRedis:0>sadd books python
1
MyRedis:0>sadd books java
1
MyRedis:0>sadd books python
0
MyRedis:0>sadd books java go
1
MyRedis:0>smembers books
1) java
2) go
3) python
MyRedis:0>sismember books java
1
MyRedis:0>sismember books C
0
MyRedis:0>scard books
3
MyRedis:0>spop books
java
MyRedis:0>srem books python
1
MyRedis:0>smembers books
1) go
2.4 散列(hash)
Redis的散列可以存储多个键值对之间的映射,相当于Java语言里面的HashMap,它是无序字典,内部实现结构上同Java的HashMap也是是一致的,都是使用数组+链表二维结构。第一维hash的数组位置碰撞时,就会将碰撞的元素使用链表串联起来。
不同的是Redis字典的值只能是Redis的字符串结构,另外它们的rehash的方式不一样,因为Java中HashMap在字典很大的时,rehash是一个耗时的操作,需要一次全部rehash。Redis中为了高性能,不能堵塞服务,所以采用了渐进式rehash策略。
渐进式rehash会在rehash的同时,保留新旧两个hash结构,查询时会同时查询两个hash结构,然后在后续的定时任务中以及hash的子指令中,循序渐进地将旧hash的内容一点点迁移到新的hash结构中。
散列的主要命令如下:
- hset:在散列里面关联起给定的键值对。
- hget:获取指定散列键的值。
- hgetall:获取散列包含的所有键值对。
- hdel:如果给定的键存在于散列里面,那么就删除。
MyRedis:0>hset books java "think in Java" #命令行的字符串如果包含空格,要用引号括起来
1
MyRedis:0>hset books python "python cookbook"
1
MyRedis:0>hset books go "concurrency in go"
1
MyRedis:0>hgetall books #entries() key和value间隔出现
1) java
2) think in Java
3) python
4) python cookbook
5) go
6) concurrency in go
MyRedis:0>hlen books
3
MyRedis:0>hget books java
think in Java
MyRedis:0>hset books python "learning go programming" #因为更新操作,所以返回0
0
MyRedis:0>hget books python
learning go programming
MyRedis:0>hmset books java "effective java" python "learning pathon" golang "modern golang programming" #批量set
OK
和字符串一样,hash结构中的单个子key也可以用于进行计数,它对应的指令是hincrby,和incr使用基本一样:
MyRedis:0>hset user-zhangsan age 19
1
MyRedis:0>hincrby user-zhangsan age 1
20
MyRedis:0>hget user-zhangsan age
20
2.5 有序集合(zset)
有序集合和散列一样,都用于存储键值对。它类似于Java的SortedSet和HashMap的结合体,有序集合的键被称为成员,每个成员都是各不相同的;而有序集合的值则被称为分值,分值必须为浮点数,代表了value的排序权重。它的内部实现用的是一种叫做“跳跃列表”的数据结构。有序集合是Redis里面唯一一个既可以根据成员访问元素,又可以根据分值以及分值的排列顺序来访问元素的结构。有序集合的主要命令如下:
- zadd:将一个带有给定分值的成员添加到有序集合里面。
- zrange:根据元素在有序排列中所处的位置,从有序集合里面获取多个元素。
- zrangebyscore:获取有序集合在给定分值范围内的所有元素。
- zrem:如果给定成员存在于有序集合,那么移除这个成员。
MyRedis:0>zadd zset 92.5 语文 //添加元素的时候分值在前面,成员在后面
1
MyRedis:0>zadd zset 98.2 数学
1
MyRedis:0>zadd zset 96 英语
1
MyRedis:0>zrange zset 0 -1 withscores //元素会按照分值大小进行排序
1) 语文
2) 92.5
3) 英语
4) 96
5) 数学
6) 98.2
MyRedis:0>zrangebyscore zset 95 100 withscores //根据分值来获取有序集合中的一部分元素
1) 英语
2) 96
3) 数学
4) 98.2
MyRedis:0>zrem zset 数学 //删除元素
1
MyRedis:0>zrange zset 0 -1 withscores
1) 语文
2) 92.5
3) 英语
4) 96
3. 通用法则
list/set/hash/zset这四种数据结构是容器型数据结构,它们共享下面两条通用法则:
- create if not exists:如果容器不存在,就创建一个再进行操作。比如rpush操作刚开始时没有列表,Redis会自动创建一个,然后再rpush进去新的元素。
- drop if no elements:如果容器里元素没有了,那么就立即删除元素,释放内存。
4.过期时间
Redis中所有的数据结构都可以设置过期时间,时间到了,Redis会自动删除相应的对象。比如一个hash结构的过期是整个hash对象的过期,而不是其中的某个子key。需要注意的地方是如果一个字符串已经设置了过期时间,然后调用了set方法修改了它,它的过期时间就会消失。
MyRedis:0>set name hello
OK
MyRedis:0>expire name 600
1
MyRedis:0>ttl name
591
MyRedis:0>set name word
OK
MyRedis:0>ttl name
-1
4.1 实现原理
Redis会将每个设置过期时间的key放入到一个独立的字典中,以后会定时遍历这个字典来删除到期的key。除了定时遍历之外,它还会使用惰性策略来删除过期的key,所谓惰性策略就是在客户端访问这个key的时候,redis对key的过期时间进行检查,如果过期了就立即删除。定时删除是集中处理,惰性删除是零散处理。
4.2 定时扫描策略
Redis默认会每秒进行10次过期扫描,过期扫描不会遍历过期字典中所有的key,而是采用了一种简单的贪心策略。
- 从过期字典中随机20个key
- 删除这20个key中已经过期的key
- 如果过期key的比率超过1/4,就重复1步骤。
为了保证过期扫描不会出现循环过度,导致线程卡死的现象,算法还增加了扫描时间的上线,默认不会超过25ms。
设想一个大型的Redis实例中所有的key在同一时间过期了,会出现什么情况呢?
Redis会持续循环扫描过期字典,直到过期字典中过期的key变的稀疏。与此同时,内存管理器也需要频繁的回收内存页,这也会产生一定的CPU消耗,这就会导致线上读写请求出现明显的卡顿现象。所以开发人员一定要注意过期时间,如果有大量的key过期,要给过期时间设置一个随机范围,而不是全部在同一时间过期。
4.3 从库的过期策略
从库不会进行过期扫描,从库对过期的处理是被动的。主库在key到期时,会在AOF文件里增加一条del指令,同步到从库,从库通过执行这条del指令来删除过期的key。因为指令同步是异步进行的,所以主库过期的key的del指令没有及时同步到从库的话,会出现主从数据不一致的的情况。
5.底层实现原理
5.1 字符串实现原理——SDS
Redis的字符串叫做(Simple Dynamic String)。它是一个带长度信息的结构体:
struct SDS<T>{
T capacity; //数组容量
T len; //数组长度
byte flags; //特殊标志位
byte[] content;//数组内容
}
content中存储了真正的字符串内容,capacity表示所分配数组的长度,len表示字符串的实际长度。Redis规定字符串的长度不能超过512M字节。创建字符串时len和capacity长度一样,不会多分配冗余空间,这是因为大多数场景下我们不会使用append操作来修改字符串。当对字符串进行append操作时,如果数组没有多余的空间时,数组会按照以下原则进行扩容。
当字符串的长度小于1M时,扩容都是扩一倍,如果超过1M,扩容时一次最多扩1M的空间。
当SDS的字符串缩短时,SDS数组中多余的空间也不会马上被回收,而是暂时留着以防再用的时候进行多余的内存分配。
5.2 压缩列表
Redis为了节约内存空间使用,zset和hash在元素在较少的情况下,会采用压缩列表(ziplist)进行存储。它将所有的元素紧挨着一起存储,分配的是一块连续的内存,元素之间没有任何冗余空隙。压缩链表的数据结构如下:
struct ziplist<T>{
int32 zlbytes; //整个压缩列表占用字节数
int32 zltail_offset;//最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; //元素个数
T[] enties; //元素列表,挨个挨个紧凑存储
int8 zlend//标志压缩列表的结束,值恒为0xFF
}
示意图如下:
压缩列表为了支持双向遍历,所以才会有ztail_offset这个字段,用来快速定位到最后一个元素,然后倒着遍历。entry块随着容纳的元素类型不同,也会有不一样的结构。
struct entry{
int<var> prevlen;//前一个entry的字节长度
int<var> encoding;//元素类型编码
optional byte[] content;//元素内容
}
prevlen字段表示前一个entry的字节长度,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于254时,使用一个字节表示;如果达到或超过254,那就使用5个字节来表示。第一个字节是0xFE(254),剩余4个字节表示字符串的长度
encoding字段存储了元素内容的编码类型信息,该字段决定了content内容的形式。
5.2.1 增加元素
因为压缩列表都是紧凑存储,没有冗余空间。意味着插入一个新的元素就需要重新扩展内存。扩展内存可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内存的拷贝。
如果压缩列表占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以ziplist不适合存储大型字符串,存储的元素也不宜过多。
5.2.2 级联更新
前面提到每个entry都会有一个prevlen字段存储前一个entry的长度。如果内容小于254字节,prevlen用1个字节存储,否则就是5个字节。这意味如果某个entry经过了修改操作从253字节变成254字节,那么它的下一个entry的prevlen字段就要更新,从1个字节扩展到5个字节;如果这个entry的长度本来也是253字节,那么后面entry的prevlen字段还得继续更新。
如果压缩列表里面每个entry恰好都存储了253字节的内容,那么第一个entry内容的修改就会导致后续所有entry的级联更新,这是一个比较耗费计算资源的操作。
5.3 快速列表
Redis早期版本存储list列表数据结构使用的是压缩列表和普通的双向链表linkedlist,也就是元素少时用ziplist,元素多时用linkedlist。后续版本对list数据结构进行个改造,使用quicklist(快速列表)代替了ziplist和linkedlist。
quicklist是ziplist和linkedlist的混合体,它将linkedlist按段切分,每一段使用ziplist来紧凑存储,多个ziplist之间使用双向指针串接起来。
quicklist内部默认单个ziplist长度为8k字节,超出了这个字节数,就会新起一个ziplist。ziplist的长度由配置参数list-max-ziplist-size决定。
5.3.1 压缩深度
为了进一步节约空间,Redis还会对ziplist进行压缩存储,使用LZF算法压缩。压缩的实际深度由配置参数list-conpress-depth决定,默认的压缩深度是0,也就是不压缩。为了支持快速的push/pop操作,quicklist的首尾两个ziplist不压缩,此时深度就是1。如果深度为2,就表示quicklist的首尾第一个ziplist以及首尾第二个ziplist都不压缩。
5.4 字典
字典(dict)是Redis服务器中出现最为频繁的复合型数据结构,除了hash结构的数据会用到字典外,整个Redis数据库的所有key和value也组成了一个全局字典,还有带过期时间的key集合也是一个字典。zset集合中存储value和score值的映射关系也是通过dict结构实现的。
5.4.1 内部结构
dict结构内部包括两个hashtable,通常情况下只有一个hashtable是有值的。但是在dict扩容缩容时,需要分配新的hashtable,然后进行渐进式搬迁,这时候两个hashtable存储的分别是旧的hashtable和新的hashtable。待搬迁结束后,旧的hashtable被删除,新的hashtable取而代之。
hashtable的结构和Java的HashMap几乎是一样的,都是通过分桶的方式解决hash冲突。第一维是数组,第二维是链表。数组中存储的是第二维链表的第一个元素的指针。
5.4.2 渐进式rehash
大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂到新的数组下面,这是一个O(n)级别的操作,作为单线程的Redis表示很难承受这样耗时的过程。所以Redis使用渐进式rehash小步搬迁。
字典的扩容和缩容都需要将ht[0]上的所有键值对rehash到ht[1]哈希表中。执行步骤如下:
- 为ht[1]分配空间
- 将字典中的rehashidx设置为0,表示rehash正式开始,rehash期间,不会阻塞CRUD等操作。
- 当ht[0]所有的键值对都rehash到ht[1]时,将rehashidx的属性设置为-1,表示rehash完成。
渐进式rehash的过程中,字典同时使用ht[0]和ht[1]两个哈希表,渐进式rehash期间,字典的删除、查找、更新操作会在2个哈希表上进行。但是,在渐进式rehash期间,新添加到字典的键值对会保存到ht[1]哈希表中,而ht[0]中不会做任何操作,保证只减不增。
5.4.3 hash函数
hashtable的性能好不好完全取决于hash函数的质量。Redis的字典默认的hash函数是siphash,该算法即使在输入key很小的情况下,也可以产生随机性特别好的输出,而且它的性能也非常突出。
5.4.4 hash攻击
如果hash函数存在偏向性,黑客就可以利用这种偏向性对服务器进行攻击。存在偏向性的hash函数在特定的模式下会导致hash第二维链表长度极为不均匀,甚至所有的元素都集中到个别链表中,导致查询效率急剧下降,从O(1)退化成O(n)。这就是所谓的hash攻击。
5.4.5 缩容扩容条件
正常情况下,当hash表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的2倍。
当hash表因为元素的逐渐删除变的越来越稀疏时,Redis会对hash表进行缩容来减少hash表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的10%。
5.4.5 set的结构
Redis里面set的结构底层实现也是字典,只不过所有的value都是NULL,其他的特性和字典一模一样。
5.5 跳跃列表
Redis的zset是一个复合结构,一方面它需要一个hash结构来存储value和score的对应关系,另一方面需要提供按照score来排序的功能,还需要能够指定score的范围来获取value列表的功能,这就需要另外一个结构“跳跃列表”。
5.5.1 基本结构
上图就是跳跃列表的示意图,图中只画了四层,Redis的跳跃表公有64层,意味着最多可以容纳2^64个元素。每一个kv块对应的数据结构如下:
struct zslnode{
string value;
double score;
zslnode*[] forwards;//多层连接指针
zslnode* backward;//回溯指针
}
struct zsl{
zslnode* header; //跳跃列表头指针
int maxLevel; //跳跃列表当前的最高层
map<string,zslnode*> ht; //hash结构的所有键值对
}
kv header的结构也是类似的,只不过value字段是无效的NULL值,score是Double.MIN_VALUE,用来垫底的。kv之间使用指针串起来形成了双向链表结构,它们是有序排列的,从小到大。不同的kv层高可能不一样,层数越高的kv越少。同一层的kv会使用指针串起来,每一层元素的遍历都是从kv header出发。
5.5.2 查找过程
插入删除操作都需要先定位到相应的位置节点(定位到最后一个比“我”小的元素,也就是第一个比“我”大的元素的前一个),定位的效率一般比较差,复杂度为O(n)。也许我们会想到二分查找,但是二分查找的结构只能是有序数组。跳跃列表有了多层结构之后,这个定位算法的事件复杂度将会降低到O(lg(n))。
如图所示,我们如果要定位那个紫色的kv,需要从header的最高层开始遍历找到第一个节点(最后一个比“我”小的元素),然后从这个节点开始降一层再遍历找到第二个节点(最后一个比“我”小的元素),然后一直降到最底层进行遍历就找到了期望的节点(最底层的最后一个比“我”小的元素)。
我们将中间经历过的一系列节点称之为“搜索路径”,它是从最高层一直到最底层的每一层最后一个比“我”小的元素节点列表。有了这个搜索路径我们就可以插入这个新节点了。对于每个新插入的节点,都需要调用一个随机的算法给它分配一个合理的层数。分配算法如下:
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
从代码中可以看出,每一层的晋升概率主要依赖于ZSKIPLIST_P,改值默认设置为25%,所以层数晋级的概率为25%。
跳跃列表会记录当前的最高层数maxLevel,遍历时从这个maxLevel开始一层一层的往下遍历。
5.5.2 插入过程
首先在搜索合适插入点的过程中将“搜索路径”找到,然后就可以开始创建新的节点了。创建的时候需要给这个节点随机分配一个层数,再将搜索路径上的节点和这个新节点通过前后指针串联起来。如果分配的新节点的高度高于当前跳跃列表的最大高度,就需要更新一下跳跃列表的最大高度。
5.5.3 删除过程
删除过程和插入过程类似,都需要先把这个“搜索路径”找到,然后对每个层的相关节点都重新排一下前向后向指针就可以了。
5.5.4 更新过程
当我们调用zadd方法时,如果对应的value不存在,那就是插入过程。如果这个value已经存在,只需要调整一下score的值,此时需要走一个更新的流程。假设这个新的score值不会带来排序位置上的改变,就不需要调整位置,直接修改元素的score值即可。如果排序位置改变了,就需要调整位置。
Redis调整位置的策略简单又粗暴,直接先删除这个元素,然后再插入这个元素,需要经过两次路径搜索。
5.5.5 score值都相同
在一个极端情况下,zset中所有的score值都一样,zset的查找性能是否会退化为O(n)呢?答案是不会的,因为zset的排序元素不只看score值,如果score的值相同还需要再比较value值(字符串比较)。
5.6 紧凑列表
Redis5.0引入了一个新的数据结构紧凑列表(listpack),它是对压缩列表(ziplist)结构的改进,在存储空间上会更加节省,结构也很精简。压缩列表的数据结构如下:
struct listpack<T> {
int32 total_bytes;//占用的总字节数
int16 size;//元素个数
T[] entries;//紧凑排列的元素列表
int8 end;//同zlend一样,恒为0xFF
}
listpack跟ziplist的结构几乎一模一样,只是少了一个zltail_offset字段。ziplist通过这个字段来定位出最后一个元素的位置,用于逆序遍历。而listpack可以通过其他方式来定位出最后一个元素,所以zltail_offset字段就省掉了。下面来看一下元素的结构entry。
struct lpentry {
int<var> encoding;
optional byte[] content;
int<var> length;
}
元素的结构和ziplist的元素结构也很类似,都是包含三个字段。不同的是长度字段放在了元素的尾部,而且存储的不是上一个元素的长度,是当前元素的长度。正是因为长度放在了尾部,所以可以省去了zltail_offset字段来标记最后一个元素的位置,这个位置可以通过total_bytes字段和最后一个元素的长度字段计算出来。
5.6.1 级联更新
listpack的设计彻底消灭了ziplist存在的级联更新行为,元素与元素之间完全独立,不会因为一个元素的长度变长就导致后续元素内容的变更。
5.6.2 取代ziplist
listpack的设计目的是用来取代ziplist,不过当下还没有做好替换ziplist的准备,因为存在很多兼容性问题。ziplist在Redis的数据结构中使用太广泛了,替换起来复杂度非常高,目前它只使用在了新增加的Stream数据结构中。