引言
前几天刚看完《Redis设计与实现》这本书籍,该书籍也较完整地记录了Redis的底层设计与相关功能的实现细节,对于更深入了解Redis,该书的确有很大帮助,故记录一些重要知识点。
数据结构与对象
很多人对于Redis的数据类型基本都了解,一般可分为五大数据类型:String、哈希(Hash)、列表(List)、集合(Set)、有序集合(Zset),Redis数据库里面的每个键值对都是由对象(Object)组成的,其中:
- 数据库键总是由一个字符串对象(Object)构成
- 数据库键的值则可以是由字符串对象、列表对象、哈希对象、集合对象、有序集合对象五种其中的一种
究其这些数据类型的底层实现细节,则具体由以下这些组成:
简单动态字符串(SDS)
由于Redis是由C语言编写的,但Redis并没有直接使用C语言传统的字符串表示(一般以空字符结尾的字符数组),而是构建了一种动态字符串(SDS)的抽象类型,并作为默认字符串表示
例如:在客户端执行
- set k1 v1
Redis在数据库中创建一个新的键值对:其中
键为字符串对象,对象底层实现是保存着一个字符串“k1”的SDS
值为字符串对象,对象底层实现是保存着一个字符串“v1”的SDS
同理:执行以下操作
- RPUSH k1 "v1" "v2" "v3"
键为字符串对象,对象底层实现是保存着一个字符串“k1”的SDS
值为列表对象,列表对象包含三个字符串对象,这三个字符串对象分别由三个SDS实现
SDS的定义
底层数据结构
struct sdshdr{
int len; SDS所保存的字符串的长度
int free; buf[]数组中未使用的空间
char buf[]; 字符数组
}
SDS与C字符串的区别
- 计数方式不同
C语言对字符串长度的统计,就完全来自遍历,从头遍历到末尾,直到发现空字符就停止,以此统计出字符串的长度,这样获取长度的时间复杂度来说是0(n),但SDS在len属性中就记录了SDS本身的长度,所以获取SDS长度的复杂度为O(1)
- 防止缓冲区溢出
C是不记录字符串长度的,所以在一旦我们调用了拼接的函数,对两个字符串进行拼接,如果没有提前计算好内存,是会产生缓存区溢出的,导致其他区的内容被意外修改,而SDS的空间分配策略就杜绝了发生缓冲区溢出的可能性,在拼接前判断free的长度是否可以放得下,如果长度够就直接执行,如果不够,那就先进行扩容。
- 减少修改字符串时带来的内存重分配次数
由于C字符串并不记录自身的长度,所以对于一个包含N个字符的C字符串来说,其底层实现是由一个N+1长度的字符数组组成的,而每次增长或缩短一个C字符串时,需要对字符串进行频繁的拼接和截断操作,进行一次内存的重分配操作,如果我们写代码忘记了重新分配内存,就可能造成缓冲区溢出,以及内存泄露。
而Redis为了避免C字符串这样的缺陷,就分别采用了两种解决方案:
(1)空间预分配
当我们对SDS进行扩展操作的时候,Redis会为SDS分配好内存,并且根据特定的规则,分配多余的free空间,还有多余的1字节空间(这1字节也是为了存空字符),这样就可以避免我们连续执行字符串添加所带来的内存分配消耗,减少分配次数
(2)惰性空间释放
惰性空间释放用于优化SDS的字符串的缩短操作,当执行一个字符串缩减的操作后,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是用free属性来将这些字节的数量记录起来,并等待将来的使用
- 二进制安全
C语言是通过判断空字符去判断一个字符的长度的,所以除了字符串的末尾之外,字符串里面不能包含字符串,但是有很多数据结构经常会穿插空字符在中间,比如图片,音频,视频,压缩文件的二进制数据。而SDS就不存在这个问题了,由于其数据结构保存了字符串的长度,所以直接判断长度即可,所以Redis可以适用于各种不同的处理场景
链表
链表作为一种常用的数据结构,内置在很多高级语言里面,因为Redis是使用C语言,并没有内置这种数据结构,所以Redis构建了自己的链表实现
当一个列表键包含了数量较多的元素,或者列表包含的元素都是比较长的字符串的时候,Redis就会使用链表作为列表键的底层实现
数据结构:listNode
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
// 节点的值
void *value;
}listNode;
多个listNode通过前驱指针和后继指针来组成一个双端链表
数据结构:list
typedef struct list{
//链表头节点
listNode *head;
//链表尾节点
listNode *tail;
//链表长度
unsigned long len;
}list;
特点:
- 节点通过prev和next指针实现双端链表
- 表头节点的pre与表尾节点的next都指向NULL,不是个循环链表,对链表的访问会以NULL结束
- list带表头与表尾指针,程序可以快速的获取表头与表尾。
- 通过len属性可以快带的获取链表的长度。
字典
字典,又称为符号表、映射,是一种用于保存键值对的抽象数据结构
在字典中,一个键(key)可以和一个值(value)进行关联,每一个键都是独一无二的,程序可以在字典中根据键查找与之关联的值
例如:执行以下命令
set k1 "v1"
在数据库中创建一个键为k1,值为v1的键值对,这个键值对就保存在代表数据库的字典里面
除了用来表示数据库之外,字典也是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时候,Redis就会使用字典作为哈希键的底层实现
数据结构:哈希表的节点结构
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_t u64;
int64_t s64;
}v;
//指向一个哈希表的节点
struct dictEnty *next;
}dictEnty
数据结构:哈希表结构
typedef struct dictht{
//哈希表的数组
dictEntry **table;
//哈希表的大小
unsigned long size;
//哈希表的掩码,用于计算索引值
unsigned long sizemask;
//已有的节点数
unsigned long used;
}dictht
哈希算法
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,根据索引值,将包含新键值对的哈希表节点放到哈希数组的指定索引上
解决键冲突
当两个或以上的键被分配到同一索引上时,就发生了键冲突,Redis的哈希表使用链地址法来解决键冲突,原理和hashmap的底层原理类似
跳跃表
跳跃表(Skiplist)是一种有序数据结构,他通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的
Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含较多的元素,又或者有序集合中的成员是较长的字符串时候,Redis就会使用跳跃表来作为有序集合的底层实现
使用跳跃表原因
首先,因为 zset 要支持随机的插入和删除,所以它不宜使用数组来实现,关于排序问题,也很容易就想到红黑树/ 平衡树这样的树形结构,为什么 Redis 不使用这样一些结构呢
- 性能考虑: 在高并发的情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及局部
- 实现考虑: 在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观;
具体细节实现:跳跃表实现,这篇文章详细介绍了跳跃表底层实现细节
整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现
数据结构:
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}
contents数组是整数集合的底层实现:整数集合的每个元素都是该数组的一个数组项,各个项在数组中按值的大小从小到大有序的排列,并且数组中不包含任何重复项
contents数组的真正类型取决于encoding的编码方式,而编码方式的选择取决于整数值的大小
升级
当要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有的所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面。
降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直按照升级后的编码方式保存数据
压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。
- 当一个列表键只包含少量列表项,并且每个列表项要么是小整数值,要么就是长度较短的字符串时,Redis就会使用压缩列表来做列表键的底层实现
- 当一个哈希键只包含少量的键值对,并且每个键和值要么是小整数值,要么就是长度较短的字符串时,Redis就会使用压缩列表来做哈希键的底层实现
压缩列表是Redis为了节省内存而开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值
对象
Redis数据库基于上面的数据结构创建了一个对象系统,这个系统包括:字符串对象,列表对象,哈希对象,集合对象,有序集合对象。在 Redis里每新建一个键值对会创建两个对象,分别为键对象与值对象,每个对象都用到了至少一种前面所介绍的数据结构,每个对象都由redisObject结构表示:
typedef struct redisObject{
//类型
unsigned type;
//编码,底层数据结构
unsigned encoding;
//指向底层实现数据结构的指针
void *ptr;
//引用计数器 通过OBJECT REFCOUNT 可以查看
int refcount;
//空转时长 通过OBJECT IDLETIME 可以查看
unsigned lru;
}robj;
其中type值只有五种类型,分别为:string,list,hash,set,zset,可以通过TYPE key得到对象的类型。
而encoding用于标识所使用的底层的数据结构,可能通过OBJCET ENCODING key
来查看某个值对象的底层结构。encoding可能的输出为:int(long类型整数),embstr(embstr编码的简单动态字符串), raw(简单动态字符串),ht(字典),linkedlist(双端链表),ziplist(压缩列表),intset(整数集合),skiplist(跳跃表和字典)。
每种类型的对象都至少使用了两种不同的编码
字符串对象
字符串的编码可能是int, embstr, raw这三种之中的一种。
1、为int的情况是存的这个整数值可以用long类型(浮点数不在这个范围内)来表示。
2、当存的值是字符串,且长度小于39字节的时候,采用的是embstr结构来存储,
3、其它情况采用的是raw方式存储。
embstr与raw的区别为:embstr专门用于存储短字符串,主要是为了在创建对象的时候只需要调用一次内存分配函数,而raw会调用两次内存分配函数
列表对象
列表对象的编码可能是双端链表(linkedlist)或者压缩列表(ziplist)。
当列表对象同时满足所有字符串的长度都小于64字节且元素数量小于512个时,采用的是压缩列表的方式。其它情况采用双端列表来进行存储。
哈希对象
哈希对象的编码可以是压缩列表(ziplist)或者hashtable 。
只有当哈希对象保存的键值对的键和值的字符串长度都小于64字节且对象保存的键数量小于512个,才使用压缩列表的方式进行存储,其它情况采用的是hashtable。
当采用压缩列表来存储时有如下特点:
- 保存同一个键值对的两个节点总是紧挨在一起,键的节点在前,值的节点在后。
- 增加键值对放到压缩列表表尾。
集合对象
集合对象的编码可以是intset或者hashtable来实现。
使用intset编码的条件为集合中的所有元素全为整数值且对象保存的元素数量不超过512 个。其他情况都是采用hashtable的编码方式来保存的数据值
有序集合对象
有序集合对象可以采用压缩列表(ziplist)或者跳跃表加字典的方式来实现。
当保存的元素小于128个且每个元素长度都小于64个字节采压缩列表的方式来保存,压缩列表内在元素按分值大小进行排序,分值小的靠近表头,每个元素占用两个节点,第一个节点保存值,第二个节点保存分值。其它情况用的是跳跃表+字典的方式保存
内存回收
由于C语言并不具备自动内存回收的功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)计数实现内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,来进行对象的自动回收。
Redis的持久化
持久化概念
Redis 的数据全部存储在内存中,如果突然宕机或者进程意外结束,那么保存在内存中的数据就会全部丢失,因此必须有一套机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的 持久化机制,它会将内存中的数据库状态保存到磁盘中。
Redis的两种持久化机制
一、RDB持久化
Redis 快照是最简单的 Redis 持久性模式。Redis持久化既可以手动执行,也可以根据服务器配置选项定期执行。该功能可以将某个时间点上的数据库状态保存到一个RDB文件中,也就是俗称的快照,快照作为包含整个数据集的单个 .rdb 文件生成。
因为RDB文件是保存在磁盘中的,所以即使Redis服务器进程意外退出,只要RDB文件还存在磁盘中,Redis服务器就可以用该RDB文件来进行还原数据库。
RDB文件的创建和载入
有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE
- SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完成,在服务器进程阻塞期间,服务器不会处理任何命令请求
- BGSAVE命令则不会阻塞Redis服务器进程,该命令会派生出一个子进程,然后由子进程负责RDB文件的创建,服务器进程(父进程)继续处理请求
那就存在一个问题,当在创建RDB文件期间,客户端这时候有个请求修改了数据库的数据时,那么该如何解决这一情况呢?
这时候操作系统多进程 COW(Copy On Write) 机制
就发挥作用,当子进程做数据持久化,它不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化写到磁盘中。父进程不一样,它必须持续服务客户端请求,然后对内存数据结构进行不间断的修改。
当子进程持久化时,父进程有修改请求,这时候就使用操作系统的 COW 机制来进行数据段页面的分离
。数据段是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复制一份分离出来,然后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的,还是进程产生时那一瞬间的数据。
二、AOF持久化
AOF(Append Only File - 仅追加文件)
,它的工作方式非常简单:每次执行修改内存中数据集的写操作时,都会记录 该操作,也就是保存追加服务器所执行的写命令来记录数据库状态。假设 AOF 日志记录了自 Redis 实例创建以来 所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例顺序执行所有的指令,也就是 「重放」,来恢复 Redis 当前实例的内存数据结构的状态。
当 Redis 收到客户端修改指令后,会先进行参数校验、逻辑处理,如果指令没问题,就立即将该指令文本存储到 AOF 日志中,也就是说,先执行指令再将日志存盘。
AOF重写
因为AOF持久化时通过保存被执行的写命令来记录数据库状态的,随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件会越来越大。为了解决AOF文件体积膨胀的问题,Redis提供了文件重写
功能,新旧两个AOF文件所保存的数据库状态相同,但新的AOF文件不会包含任何浪费空间的冗余命令
AOF重写过程
实际上,AOF文件重写并不是对现有的AOF文件进行任何读取,分析或者写入操作。
AOF重写其原理就是开辟一个子进程对内存中数据库状态进行遍历,并转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化期间,父进程接收的请求会被放进一个AOF重写缓冲区
,待序列化完毕后再将操作期间发生的操作从AOF重写缓冲区取出,追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,
Redis 4.0 混合持久化
重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。
Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化
。将 rdb 文件
的内容和增量的 AOF 日志文件
存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小:
于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。
事件
Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件
- 文件事件
Redis服务器通过套接字与客户端(或其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象,服务器与客户端的通信会产生相应的文件事件,而服务器通过监听并处理这些事件来完成一系列网络通信操作 - 时间事件
Redis服务器中的一些操作需要在给定的事件点执行,而时间事件就是服务器对这类定时操作的抽象
文件事件:
Redis基于Reactor模式开发了自己的网络事件处理器,这个处理器称为文件事件处理器
1、文件事件处理器采用I/O多路复用
程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器
2、当被监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字关联的事件处理器进行处理这些事件
时间事件
Redis的时间事件可分为两类
- 定时事件:让一段程序在指定的时间之后执行一次
- 周期性事件:让一段程序每隔指定时间就执行一次。
总结:
1、文件事件和时间事件之间是合作关系,服务器会轮流处理这两类事件,并且处理事件的过程中也不会进行抢占
2、时间事件的实际处理时间通常会比设定的到达时间晚一些