REDIS设计与实现笔记整理
文章目录
- REDIS设计与实现笔记整理
- Redis数据结构
- 简单动态字符串
- 链表
- 字典
- 跳跃表
- 压缩列表
- Redis对象
- 有序集合
- 内存回收
- 对象共享
- 数据库
- 服务器中的数据库
- 过期键删除策略
- RDB(redis database)
- AOF(append only file)
- AOF重写
- 事件
- 文件事件
- 时间事件:
- redis服务器中一次命令请求的过程
- 复制
- 心跳检测
- Sentinel(哨兵)
Redis数据结构
简单动态字符串
- redis没有直接用C语言的传统字符串表示(空字符串结尾的字符数组),而是自己构建了SDS(Simple Dynamic String)的抽象类型,用作sds的默认字符表示。
struct sdshdr {
//记录buf数组已使用的字节数量
//等于SDS保存字符串长度
int len;
//记录未使用字节数
int free;
//字符串数组,保存字符串
char buf[];
}优点:
- 可常熟复杂度获取字符串长度,O(N)–>O(1),不卡性能
- 底层api在增长字符串时,SDS会检查free,内存不够会扩展,杜绝缓冲区溢出
- 空间预分配,减少修改次数:SDS小于1MB,分配同样长的free,若SDS大于1MB,分配1MB的free
- SDS的api都是二进制安全的,可以保存一系列二进制数据(不同于C,必须以ASCII编码,并且除了字符串末尾之外,里面不能有空字符,只能保存文数据,存不了图片音频压缩文件等)
链表
列表键的底层实现之一就是链表
type struct list {
//listNode是双端列表结构
listNode *head;
listNode *tail;
unsigned long len;
//返回值为void*,参数为*void,的dup函数指针,dup函数用于复制链表保存的值
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
} list;多态性:用void*来保存节点值,通过list结构的dup、free、match三个属性为节点值设定特定的函数,所以链表可以用于保存不同类型的值。
字典
用于保存键值对的抽象数据结构,redis字典用哈希表底层实现。
哈希表的实现如下
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值,等于size - 1
//一般情况下当size为2此幂时 hash%n等价于 hash & (n - 1)这么算索引值
//index = hash & dict -> ht[0].sizemask
//该哈希表已有结点的数量
unsigned long used;
}dictht
哈希表节点用dictEntry,dictEntry* [4]表示这个数据结构(数组)中包含了4个指向dictEntry的指针,结构如下。
typedef struct dictEntrty {
//键
void* key;
//值
union {
void* val;
uint64_t u64;
int64_t s64;
} v;
//指向下个哈希表节点,形成链表,防范哈希冲突
struct dictEntry *next;
}dictEntrtyredis字典实现

- 使用链地址法解决键冲突,新添加的键直接放在链头,达到n(1)。
- ht[]中有2个数据,多的一个用于rehash,大小大于等于ht[0].used的2的n次(或n+1)幂进行收缩或扩张。相当于是重新分配内存空间后,根据hash值与sizemask算新的索引值,将旧的hash表渐进式迁移(数据量可能巨大)过来后,旧表为空。
跳跃表
有序的数据结构,是有序集合的底层实现之一,每个节点中维持多个指向其他节点的指针达到快速访问,下图o1 o2 o3是成员对象。时间复杂度平均O(logN),最坏O(N):n+n/2+n/4…

- 若新增节点,如何确定层数?
int random_level() {
int k = 1;
while(random(0, 1)) {
k++;
}
return k;
}压缩列表

充分利用连续的内存空间,可用来编码列表或者哈希。
- 当一个列表只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表的底层实现。
- 当一个哈希只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希的底层实现。
Redis对象
redis基于这些数据结构创建了一个对象系统,使用对象来表示数据库中的键和值,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。每个对象的结构如下

- unsigned type:4 type占位域中的4位 encoding占4位,共1字节,节省空间。
- type指明是五种数据对象的哪一种
- encoding表明了该对象的底层编码形式,每种对象至少用了两种编码
- 通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率,比如在列表对象包含的元素比较少时,Redis使用压缩列表作为列表对象的底层实现:节约内存,更快加载到缓存。又比如在哈希对象所保存的键值对的字符串长度都小于64,且键值对数量小于512,选择用压缩列列表而不是哈希表来实现。
有序集合
有序集合这里展开一下。
Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。
不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。
有序集合的成员是唯一的,但分数(score)却可以重复。
- 有序集合元素同时保存在字典和跳表,前者O(1)满足查找分值,后者O(1)完成排序
- 由于某一种数据对象的type可能是不同的数据结构,所以在对键实行诸如LLEN等操作时,redis会根据底层数据结构调用不同API,多态。

内存回收
C语言不具备自动回收内存功能,redis构建了个引用计数,类似智能指针
每个对象的引用计数信息由redisObject结构的refcount属性记录:

对象共享
假设键A创建了一个包含整数值100的字符串对象作为值对象,,假设键A创建了一个包含整数值100的字符串对象作为值对象,服务器让AB公用一个对象,可以节约内存,引用计数+1。下图为引用计数是3的共享对象。

内存共享前,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,考虑到复杂度,Redis只对包含整数值的字符串对象进行共享(O(1))。
数据库
服务器中的数据库
- Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db 数组 中,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库。
struct redisServer {
//...
//服务器数据库数量,程序根据这个属性决定初始化服务器时
//创建多少数据库
int dbnum;
//数组,保存着服务器所有数据库
redisDb *db;
//...
};redisDb结构中的dic字典保存了数据库中的所有的键值对。
typedef struct redisDb {
//...
//数据库键空间,每个键都是一个字符串对象,值是redis对象
dict *dict;
//过期字典,保存了键的过期时间(k=键,v=long long型整数),这个时间可以手动设置(expire等)
dict *expires;
} redisDb
过期键删除策略
- 定时删除:设置键的同时,创建一个定时器timer,时间到了执行删除操作,对内存友好,但对CPU不友好,另外对timer定时器的查找是O(N)。
- 惰性删除,每次获取键时检查是否过期,过期就删除。一些和时间有关的log数据,在某个节点之后对他们的访问会减少,大量积压惰性键后果严重,类似内存泄漏。
- 定期删除,限制删除时长以及操作频率。
- 通过由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,也正是因为这个原因,当一个过期键仍然存在于主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在。
RDB(redis database)
- RDB文件保存的就是二进制数据
- 开头的REDIS符号表示该文件为RDB文件
- EOF:1字节常量,标志正文内容结束,读到这里表示数据库所有键值对载入完毕。
- check_sum校验和,前四部份内容计算出的校验和与该值对比。有点类似TCP的一个个数据包 。
- 对于不同类型的键值对,RDB文件会使用不同的方式来保存他们。
RDB的两种持久化方式:
- save
同步、阻塞,数据量过大,redis服务器宕机时间会比较长。 - bgsave
原理: fork() + copyonwrite
rodk()出的子进程共享父类内存空间。为了保存彼此更改互不影响,又要节省内存空间,fork()之后,主进程所有内存页权限为read_only,当主进程收到写请求,CPU硬件检测内存也是read-only后触发缺页中断,异常页面复制一份后,主、子进程各自持有一份,然后可以主进程修改了。
AOF(append only file)
- AOF通过保存服务器所执行的命令来记录数据库的状态。
- AOF持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。
- append
当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾。
- 写入与同步
- Redis的服务器进程就是一个事件循环(event loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责执行像serverCron函数这样需要定时运行的函数。
关于Event Loop事件循环
https://www.ruanyifeng.com/blog/2014/10/event-loop.html -JS里的一种机制,由于JS是单线程,通过该机制将一些比较耗费时间的IO操作放入“任务队列”(task queue)异步执行,当前栈中执行的同步任务结束后CPU空闲时访问任务队列,保证CPU不会被IO操作的等待时间所阻塞占用。 PS:redis服务端的数据处理是单线程。
AOF重写
为了避免AOF文件过大,可以进行重现,原理就是读取数据库后直接用一条(或受限于键值对数量)或者多条RPUSH/SADD等命令重写。
aof_rewrite后台重写,读取数据库的时间较长,交给子进程,子进程重写时,主进程新的数据库操作命令加入到重写缓冲区。
事件
- redis是一个事件驱动程序。服务器需要处理时间事件、文件事件。
1.文件事件:Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。、
2. 时间事件::Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。
文件事件
基于reactor模式的单线程I/O多路复用文件事件处理器:

- 程序会在编译时自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现

时间事件:
Redis的时间事件分为以下两类:
- 定时事件:让一段程序在指定的时间之后执行一次。比如说,让程序X在当前时间的30毫秒之后执行一次。
- 周期性事件:让一段程序每隔指定时间就执行一次。比如说,让程序Y每隔30毫秒就执行一次。
- 服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。
- 示例:redis.c/serverCron每秒运行大约十次,工作包括关闭连接失效的客户端、清理过期键值对等等。

redis服务器中一次命令请求的过程
客户端键入命令 set key value,转换成协议报文后发送给服务器
- 服务器与客户端之间连接的套接字可读时,服务器读取协议内容,存到 client结构中保存客户端状态的输入缓冲区。然后分析请求,提取操作参数、命令参数。
- 调用命令执行器,根据arg[0]在命令表查找命令。cmd指向操作函数j结构。
client->cmd->proc(client) - 服务器从启动到能够处理客户端的命令请求需要执行以下步骤:1)初始化服务器状态;2)载入服务器配置;3)初始化服务器数据结构;4)还原数据库状态;5)执行事件循环。


复制
redis 2.8以前版本在断网后,从服务器重连之后再次复制是进行的sync重新全量复制:主服务器产生RDB(阻塞、硬盘IO、CPU)–>传给从服务器(网络IO)–>还原数据库(阻塞,IO)
2.8后主从复制:复制偏移量 + 复制积压缓冲区 + 运行id
每个数据会有偏移量以便对应哪些已同步,主服务器维护一个固定长度的复制积压缓冲区(FIFO)队列,近期的数据以及携带的偏移量都会同步放入这个缓冲区,以便之后从服务器重连上了复制,运行id用于验证主服务器是原先的服务器。
心跳检测
在命令传播阶段(同步的最新状态,且从服务器会执行主服务器发来的最新指令),从服务器默认会以每秒一次的频率,向主服务器发送命令:
REPLCONF ACK <replication_offset>其中replication_offset是从服务器当前的复制偏移量。
可以 :
- 检测主从服务器网络连接状态
- 辅助实现min-slave选项
- 检测命令丢失
与tcp中滑动窗口序号的机制去类比。
Sentinel(哨兵)

哨兵是redis的高可用性解决方案:由一个或多个Sentinel的instance组成的Sentinel系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时(在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。),自动将下线主服务器属下的某个从服务器升级为新的主服务器(根据通信情况、偏移量是否最新来选),然后由新的主服务器代替已下线的主服务器继续处理命令请求。

补充:上图是存的master服务器信息,从服务器信息存在sentinelstate的slave属性
















