Redis之所以执行速度很快,主要依赖于以下几个原因:
1.纯内存操作
避免大量访问数据库,减少直接读取磁盘数据,redis将数据储存在内存里面,读写数据的时候都不会受到硬盘 I/O 速度的限制,所以速度快;
2.单线程操作
避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
3.采用了非阻塞I/O多路复用机制
用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。这样用户可以注册多个socket,然后不断地调用select读取被激活的socket,redis服务端将这些socket置于队列中,然后,文件事件分派器,依次去队列中取,转发到不同的事件处理器中,提高读取效率。
采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作,从而提高效率。
4.灵活多样的数据结构
redis内部使用一个redisObject对象来表示所有的key和value。redisObject主要的信息包括数据类型、编码方式、数据指针、虚拟内存等。它包含String,Hash,List,Set,Sorted Set五种数据类型,针对不同的场景使用对应的数据类型,减少内存使用的同时,节省网络流量传输。
5.持久化
由于redis的数据都存放在内存中,如果没有配置持久化,redis重启后数据就全丢失了,于是需要开启redis的持久化功能,将数据保存到磁盘上,当redis重启后,可以从磁盘中恢复数据。redis提供两种方式进行持久化,一种是RDB持久化(原理是将redis在内存中的数据库记录定时 dump到磁盘上的RDB持久化),另外一种是AOF(append only file)持久化(原理是将redis的操作日志以追加的方式写入文件)。持久化似乎和redis的速度快并没有直接关系,但是这保证的redis数据的安全性和可靠性,也起到数据备份的作用。
SDS
Redis是基于C语言实现的但是并没有使用C语言的传统字符来实现存储,而是针对String类型专门做了一个叫**简单动态字符串(simple dynamic string,SDS)**的抽象类型, 并将 SDS 用作 Redis的默认字符串(String)表示。
127.0.0.1:6379> set name context
OK
比如新建了一个key-value,这个操作其实将key和value分别包装了为2个不同的SDS。
127.0.0.1:6379> RPUSH name2 context1 context2 context3
(integer) 3
上面这个情况,一共包含了4个SDS,分别是name2 context1 context2 context3
那么包装为SDS有什么好处呢?为什么Reids要这样设计呢?就要从SDS的数据结构解析起
SDS的数据结构
struct sdshdr{
// 记录buf数组已使用字节数量
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
SDS数据结构由free剩余空间,len字节长度,buf字节数组组成。
SDS数据结构解析
1.为什么要有free字段。
作用1:作为数据库属性避免内存
在我们平时编码过程是不是很少对字符串进行添加修改操作,倘若真的需要一个字符串直接声明一个开辟一个新的内存空间,此后长时间不操作这个空间了。但是对于我们数据库来说,我们却要经常对数据进行修改追加。倘若这样的操作在内存当中势必造成不必要的QBS。因此在Redis中采用了空间换时间的做法,free代表冗余字段,在context写入时,会开辟相同空间的冗余free空间。
当context的数据内容小于1MB,则buf的字节长度为2len+1。
举例:新增context,则free存储为5,len为5,buf对应的空间节点长度为5+5+1。
当context的数据内容大于1MB,则buf的字节长度为len+1MB.length。
举例:新增一个context的30MB,则buf长度为30MB.length。
不必担心浪费内存问题,在SDS中提供了相应的API,当内存不够用时就会释放空间。
作用2:防止内存溢出
在原本的C语言当中,倘若2个相邻的内存空间C1和C2
如果开发者忘记了对C1的内存空间进行扩容则会发生C2的内存空间上存储的是C1的追加内容,比如追加-hello。这时候就造成了内存溢出的问题。在C语言中我们称之为野指针。
但是在Redis当中,会对追加的内容进行判断,倘若小于free字段就会进行存储,大于则会增加free进行存储。
2.为什么要有len字段。
优化时间复杂度
Reids为了优化查询效率将查询的时间复杂度从O(n)降为了O(1)。
举例:比如插入reids这个字段,因为R E I D S 这个5个字节会存在不同的内存分片当中,当我查询的长度的时候,就是O(5)的时间复杂度,但是如果我记录了len,那么时间复杂度就是O(1)。避免了查询时间和查询长度所用的时间一样,长期占用内存空间。
定义buff字节结束位置。
在传统C语言当中以空格’\0’来结束字符串的结束,,但是这样会对中间包含空格的数据产生误读,比如以下
这个数据结构读取出来就是Red,造成了误读。所以在C语言中被要求字符串当中不能包含空格。
而在redis当中len记录的长度和buff的起始位置加+len的偏移量和’\0’加以判断字符串的结尾。
还是上图,读出来的是"Red is"
3.为什么是buff字段。
二进制安全
虽然数据库一般用于保存文本数据, 但使用数据库来保存二进制数据的场景也不少见因此, 为了确保 Redis 可以适用于各种不同的使用场景, SDS 的 API 都是二进制安全的(binary-safe),这也就是为什么是buff字段的原因,因为每个字节都是存储的对应的二进制。这样保证了没有对字符串的过滤,包装。保证了安全性。
另外扩展了redis,对图片,音频,视频等存储的扩展。
小结SDS
1.二级制安全
2.优化时间复杂度
3.减少内存开销,用空间换取时间
List
基础准备:
ziplist,在我们传统的list内存分配是翻倍分配,比如存储的内存为10个,这时最加一个,变为了11个。但是List数组内存会分配为20个,这时候造成了9个内存空间的浪费。
ziplist就是按需分配,需要11个内存空间则分配11个空间。
List的本质就是一个双向链表在基础当中我已经提到过了,那么他是怎么实现的呢,我们看下图
链表和链表节点的实现
在图最左边竖着的是quickList是整个List的结构,存储了长度,头标签栈地址,尾标签栈地址和个数。
List的结构图
Copytypedef struct quicklist {
quicklistNode *head; /*指向头节点(左侧第一个节点)的指针。*/
quicklistNode *tail; /*指向尾节点(右侧第一个节点)的指针。*/
unsigned long count; /* 所有ziplist数据项的个数总和。 */
unsigned long len; /* 所有ziplist数据项的个数总和。 */
int fill : QL_FILL_BITS; /* : 16bit,ziplist大小设置 */
unsigned int compress : QL_COMP_BITS; /*16bit,节点压缩深度设置 */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
quicklistNode结构
Copytypedef struct quicklistNode {
struct quicklistNode *prev; //上一个node节点
struct quicklistNode *next; //下一个node
unsigned char *zl; //保存的数据 压缩前ziplist 压缩后压缩的数据
unsigned int sz; /* ziplist的存储字节大小 */
unsigned int count : 16; /* ziplist 存储字节长度*/
unsigned int encoding : 2; /*表示ziplist是否压缩了,1代表没有压缩 RAW==1 or LZF==2 */
unsigned int container : 2; /*存储类型 NONE==1 or ZIPLIST==2,是一个预留字段*/
unsigned int recompress : 1; /* 这个节点是否是ziplist */
unsigned int attempted_compress : 1; /* 节点是否能压缩 1代表不能,这个值只对Redis的自动化测试程序有用,我们不用管*/
unsigned int extra : 10; /*其它扩展字段。 */
} quicklistNode;
中间Node通过前尾地址进行标记。
注解:前面标有*的都是指向的内存地址不是实际的值。
多个 listNode 可以通过 prev 和 next 指针组成双端链表。
Redis 的链表实现的特性可以总结如下:
双端: 链表节点带有 prev 和 next 指针, 获取某个节点的前置节点和后置节点的复杂度都是O(1) 。
无环: 表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL , 对链表的访问以 NULL为终点。
带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针, 程序获取链表的表头节点和表尾节点的复杂度为 O(1) 。
带链表长度计数器: 程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为 O(1) 。
多态: 链表节点使用 void* 指针来保存节点值, 并且可以通过 list 结构的 dup 、 free 、match 三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。
常用操作
1.从头或者尾插入
当我们执行插入操作时,首先会通过quicklist去寻找node节点,找到node节点之后会有2个情况:
1.如果插入的数据没有超过ziplist数组长度,则直接插入ziplist当中。
2.当超过时,会新建一个新的Node节点,修改pre额next节点地址来进行保存
2.从中间插入
1.当插入位置所在的ziplist大小没有超过限制时,直接插入到ziplist中就好了。
2.当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小没有超过限制,那么就转而插入到相邻的那个quicklist链表节点的ziplist中。倘若context的内容空间不够,则插入到上一个nodelist中的尾当中,或者下一个node的头当中,只不过下一个需要移动内存。
3.当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小也超过限制,这时需要新创建一个quicklist链表节点插入
3.查找
直接根据Node节点存储的ziplist数组查找值。
4.删除
quicklist 在区间删除时,会先找到 start 所在的 quicklistNode,计算删除的元素是否小于要删除的count,如果不满足删除的个数,则会移动至下一个 quicklistNode 继续删除,依次循环直到删除个数和count相同为止。
5.ZIPList
压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
zlbytes:记录整个压缩列表占用的内存字节数:在对压缩列表进行内
存重分配, 或者计算 zlend 的位置时使用。
zltail
字段 | 解释 | |
---|---|---|
zlbytes | Value记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。 | |
zltail | 偏移量,用于获取内存地址的结束位置 | |
zllen | 记录了压缩列表包含的节点数量: 当这个属性的值小于UINT16_MAX ( 65535 )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。 | |
entryX | 保存的节点地址 | |
zlend | 特殊符号,0xFF,标记节点的末尾 |
entryX节点的组成
字段 | 解释 | |
---|---|---|
previous_entry_length | Value记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。 | |
encoding | 偏移量,用于获取内存地址的结束位置 | |
content | 记录了压缩列表包含的节点数量: 当这个属性的值小于UINT16_MAX ( 65535 )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。 |
遍历ziplist数组
因为记录了前一个节点的长度,因此从表尾向表头的遍历过程就是双指针维持空窗,在前面用了lend字段去标识最后一个节点,所以只要获得了最后一个content的地址减去前一个地址长度,就是当前内存地址,依次内推遍历所有content。
如图:
Hash
Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 而每个哈希表节点就保存了字典中的一个键值对。
字典
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
Hash表结构
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
Hash表节点
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
这3者关系如图,即字典包含Hash表,表包含节点,节点包含键值对。
哈希算法
当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
解决键冲突
Hash算法,不同的key计算出来的地址也不一定完全不同,可能计算在了同一个key上,我们称之为键值冲突。
在Redis当中使用链地址法来解决键冲突:即每个Hash表节点上有个next节点,多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用
这个单向链表连接起来, 这就解决了键冲突的问题。
另外在Hash节点当中只有next属性记录了表头位置,为了效率考虑,因此插入的位置都链表的表头。
Hash表的刷新(rehash)
基础准备:负载因子
负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。
所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。
反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成浪费,但是此时索引效率高。
当随着对Hash表的操作,键值对越来越多或越来越少,为了让Hash的负载因子维持在一个合理的范围,需要对Hash进行收缩或者扩展操作。
1.首先需要进行计算:
扩展操作:扩展的ht[1]的大小为ht[0]的节点数量的2的次方,即ht[0].used*2
收缩操作:即ht[1].used=ht[0].used
2.将ht[0]的键值对重新计算Hash复制到ht[1]上
3.释放掉ht[0],将ht[1]变为变为ht[0],并新建ht[1]一个全新的表。
渐进式Hash表的刷新(rehash)
上面阐述了Hash表为了保证查询的效率和空间碎片的平衡,重新rehash了来,但是当数量过大,比如上千万的rehash,进行rehash操作就导致内存泄露或者数据库宕机问题。
因此真正的rehash过程是渐进式的,具体步骤如下:
1.为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
2.在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
3.在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对rehash 到 ht[1] ,当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
4.随着rehash不断的推进,最终会在某个时间进行刷新完成。并将rehashidx标记为-1。
渐进式Hash表的刷新(rehash)时操作
在rehash期间进行操作时,删除,修改和查找操作都会在ht[0],ht[1]同时操作一遍,如果ht[0]没有查找到的值,会在ht[1]里再一次查找。
在rehash操作期间的新增操作都只会在ht[1]里进行,这样保证了ht[0]的值只减不增。
Set
Hashtable
如图所示,Set的本质其实就是一个Hashtable,是String类型的无需集合,基于Hashtable实现,时间复杂度为O(1)
ZSet
跳跃表
跳跃表数据结构如上图
zskiplist
在zskiplist右边的是节点zskiplistNode,该结构属性包含如下:
跳跃表节点对象
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
层(level):
数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。每次就创建一个新的跳跃表的时候,程序会根据幂次定律生成一个1到32之间的数字,这个数字就代表了层的高度。
如图
为什么要有层级
拥有层级是为了快速查找,二分法查找。
只不过在这里层级变成了我们的数组下标,
1.先从最高层级开始寻找层级中间开始寻找。
2.如果不在上层级,则在下层级。下层级则将toplevel=midlevel
3.然后又从0-toplevel这个层级中分割。看需要寻找的数值大于还是小于这个区间,
4.直到想要找的数等于当前层级对应的数值。
注意,这里的层高又算法生成,不是图上所示中间层高,自寻查询幂次定律
跨度
层的跨度,用于记录两个节点之间的距离:
两个节点之间的跨度越大, 它们相距得就越远。
指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点。
用于计算在节点的排位位,比如:
从节点A到到节点B的距离为2,节点B到C的距离为3,则该数据存储在此跳跃表中的排位为5。
有了排位就可以对数据进行排序。
backward
后退指针,每个层级数组之间的只有一个后退指针,用于从表尾向表头查找,实际用处,二分查找数据。
score和obj
score是一个double的浮点数据类型,决定了数据存储哪一个节点数组,从小到大排列数据。obj是一个指针,指向字符串,而字符串对象里则有一个SDS的值。
在一个跳跃表对象当中,成员对象必须是唯一的,但是分值却是可以相同的。
当分值相同的时候,会根据成员对象的长度决定所处的字节数组。
比如,当三个对象的score都是11时,那么当对象length小的会排在靠近表头的字节数组里,length大的则靠近表尾。
注意:
1.层高不代表Node的个数,层高1-32,不代表只有32个节点。节点层高可以相同,是由算法生成出来了的,层高越大,Node数量越少,层高越小,Node数量越多。
2.层高里不存存储任何数据。obj里才有数据
3.层高不代表层级。
最后,以下图路径讲解跳跃表流程。就从第一个节点的L4开始举例。细小红线
1.header-找到第一个节点L4
2.根据前进指针指针找到第二个Node节点的L4
3.到达L4后,当前指针对应的层高不属于1层级,则往下寻找,在2层高找到L1的层级。
4.找到Node3节点,继续找到Node4节点
5.最后找到NULL结束。
跳跃表的gif动图讲解。
参考文章:
Redis 底层中的 SDS 以及 对象系统简介
Redis源码剖析和注释(七)— 快速列表(quicklist)
Redis底层数据结构之Hash
Redis Zset类型跳跃表算法实现
跳跃表详解
https://blog.csdn.net/qq_35059264/article/details/118023710