前言
学习是个输入的过程,在进行输入之后再进行一些输出,比如写写文章,笔记,或者做一些技术串讲,虽然需要花费不少时间,但是好处很多,首先是能通过输出给自己的输入带来一些动力,然后也能在写文章和总结时加强自己学习的印象,在一些细节上能够更加仔细深刻地理解其中一些逻辑关系,最后也能够把自己的理解分享给别人,如果别人能够帮你指出一些错误,将会有更多意想不到的收获。
最近无聊在学习redis的源码,又把之前读过的书《redis设计与实现》看了一遍,主要并不是想抄袭或者背下redis的源码,而是想在redis源码中找到一些灵感,或者收获一些体系结构或者实现细节上的方案,任何技术都是会过时的,但是解决问题的思路永远不会。如果看完一篇文章或者一本书,你背下了所有redis核心部分的源码,在作者看来意义不大,但是如果你能根据学到的东西自己重新实现一个redis,甚至根据自己的想法做一些优化,这才是真正的进步,
为了加深自己记忆,方便自己以后回忆学习,我写了这个文章,把书上的一些我认为比较重要的内容记录下来
一 Redis摘要
redis对自己的定义是
Redis is the world’s fastest in-memory database. It provides cloud and on-prem solutions for caching, vector search, and NoSQL databases that seamlessly fit into any tech stack—making it simple for digital customers to build, scale, and deploy the fast apps our world runs on.
--Redis官方介绍 -2025/4/8
其实就是一个开源的内存数据库,但是它提到三个重要的词语,这也是它主打的东西
fastest:关于这点,类似绝对的宣传听说在欧美是违法的,作者在编写文章时已经向欧盟提交了举报。
simple:使用redis是方便快捷的
caching, vector search, and NoSQL databases:它的设计初衷是用于缓存,矢量搜索,数据存储,尽管很多人把他当成消息队列,分布式锁使用,redis后面也提供了专门的事件发布和监听能力,但是估计也是不情愿的。
二 Redis的数据结构和类型的实现
本章节会介绍redis内部实现的一些数据结构和各种类型的值的实现。
2.1 Redis内置的数据结构
redis内部实现了一些用于存储数据和数据结构,因为redis的各种各样类型的键值对都是基于这些数据结构的,所以很有必要先分别介绍一下它们,但是因为这些数据结构都是计算机通用的内容,所以这里只会简单介绍一下并且说一下它们特殊的地方(如果有)而不会花太多篇幅
2.1.1 SDS(动态字符串)
redis封装了自己的字符串表示结构sdshdr(simple dynamic string),而没有使用C语言传统的字符串表示(以空字符串结尾的字符数组),绝大多数的字符串表示都是使用的sds,只有极少数不需要修改字符串的情况下才会使用C语言传统的字符串表示,比如打印日志。
struct sdshdr {
//字符串长度(不包含最后面的空字符)
unsigned int len;
//指示buf数组中未使用字节数
unsigned int free;
//存储字符串内容,以空字符结尾
char buf[];
};
sds相比于C语言字符串的优势主要体现在
1 更快获取到字符串长度
2 更容易管理字符串,减少内存重分配次数,字符串内容更新变长时,避免缓冲区溢出(通过free记录剩下的空间,如果不够可创建一个新的sdshdr),截断字符串时不需要释放空间以防止内存泄漏等
2.1.2 链表
redis的链表实现其实和传统的链表并没有太多差别,这里不做太多赘述
链表节点结构体
typedef struct listNode {
//前驱节点
struct listNode *prev;
//后续节点
struct listNode *next;
//值
void *value;
} listNode;
链表结构体
typedef struct list {
//头节点
listNode *head;
//尾节点
listNode *tail;
//长度
unsigned long len;
//节点值的复制函数,释放函数,对比函数的函数指针...
} list;
2.1.3 字典
redis的字典使用哈希表作为底层实现,一个哈希表包含了多个哈希表节点,每个哈希表节点就表示一个键值对。
哈希表节点
typedef struct dictEntry {
//键
void *key;
//值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向另一个哈希表节点,用于避免哈希冲突
struct dictEntry *next;
} dictEntry;
需要注意的是redis是通过拉链法来避免哈希冲突的,所以需要next用来放置相同哈希值的key
哈希表
typedef struct dictht {
//存储哈希表节点,每个元素就是一个哈希表节点的指针(不保存哈希表节点本身)
dictEntry **table;
//哈希表大小,table数组长度,不代表哈希表节点数量
unsigned long size;
//哈希表掩码,用于计算索引值,总是对于size - 1
unsigned long sizemask;
//哈希表的节点数
unsigned long used;
} dictht;
注意table本身并不直接保存哈希表节点,里面每个元素都是哈希表结点指针。
字典
typedef struct dict {
//保存这个字典的哈希函数和键值的复制,对比,销毁方法
dictType *type;
//字典的私有数据,type中的函数会用到这些数据
void *privdata;
//存储数据的哈希表,字典只会使用ht[0],ht[1]只会在进行reheah时使用
dictht ht[2];
//如果正在进行rehash,指示当前rehash的索引,不在rehash时为-1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
dictType是用来放置哈希函数,键值对复制,对比,销毁函数的结构体,这些方法会用到的私有数据会放在privdata中,rehashidx在redis进行rehash使用,rehash的内容会在后面的章节具体描述,这里可以只关注ht[0]即可,
以下是一个正常状态下(没有正在进行rehash)的字典的结构示例图
2.1.4 跳跃表
redis的跳跃表由跳跃表节点zskiplistNode组成,结构体如下
跳跃表节点
typedef struct zskiplistNode {
//节点存储的值
robj *obj;
//分值,是一个doule类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
double score;
//后退指针,每次只能后退至前一个节点
struct zskiplistNode *backward;
//层,数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度
struct zskiplistLevel {
//前进指针
struct zskiplistNode *forward;
//跨度, 用于记录两个节点之间的距离,指向NULL的所有前进指针的跨度都为0
unsigned int span;
} level[];
} zskiplistNode;
跳跃表
typedef struct zskiplist {
//头节点和尾节点
struct zskiplistNode *header, *tail;
//节点数量
unsigned long length;
//性来记录节点的数量 ,程序可以在O(L)复杂度内返回跳跃表的长度意。
//leve1属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,表头节点的层高并不计算在内
int level;
} zskiplist;
初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前 进指针就可以完成了,跨度实际上是用来计算排位(rank )的:在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。 举个例子,图5- 4用虚线标记了在跳跃表中查找分值为3. 0、成员对象为。3的节点时, 沿途经历的层:查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跳跃表中 的排位为3。
再举个例子,图5-5用虚线标记了在跳跃表中查找分值为2. 0、成员对象为o2的节点 时,沿途经历的层:在查找节点的过程中,程序经过了两个跨度为1的节点,因此可以计算出,目标节点在跳跃表中的排位为2。
在同 一个跳跃表中,各个节点保存的成员对象必须是唯 一的,但是多个节点保存的分值却可以是相同的,分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面
2.1.5 整数集合
整数集合(intset) 是Redis用于保存整数值的集合抽象数据结构 , 它可以保存类型为 int16,int32或者int64的整数值,并且保证集合中不会出现重复元素,它是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
typedef struct intset {
//编码方式
uint32_t encoding;
//集合长度
uint32_t length;
//保持元素的数组
int8_t contents[];
} intset;
contents 数组是整数集合的底层实现:整数集合的每个元素都是contents 数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项,
虽然intset结构将contents属性声明为int8_t 类型的数组,但实际上contents数 组并不保存任何int8_t 类型的值,contents数组的真正类型取決于encoding属性的值: 又如果encoding属性的值为INTSET_ENC_INT16,那么content s 就是一个int16t类型的数组,数组里的每个项都是一个int 16_t 类型的整数值(最小值 为-32768,最大值为32767)。 又如果encoding属性的值为INTSET_ENC_INT32,那么contents 就是一个int32 _ t 类型的数组 , 数组里的每个项都是一个int32 _ t类型的整数值,又如果encoding 属性的值为INTSET_ENC_INT64,那么contents 就是一个int 64_ t 类型的数组,数组里的每个项都是一个int64_t 类型的整数值。
升级和降级
每当我们要将 一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。整数集合不支持降级操作, 一旦对数组进行了升级,编码就会一直保持升级后的状态,这里我们不再具体深入升级和降级的细节。
2.1.6 压缩列表
压缩列表(ziplist) 是列表键和哈希键的底层实现之一 。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis 就 会使用压缩列表来做列表键的底层实现。压缩列表是Redis 为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构 。 一个压缩列表可以包含任意多个节点, 每个节点可以保存 一个字节数组或者 一个整数值
各组成部分说明
2.1.7 对象
Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。
Redis中的每个对象都由一个redisobject结构表示,该结构中和保存数据有关的三个属性分别是type属性、encoding属性和ptr属性。
typedef struct redisObject {
//记录了对象的类型,可以是REDIS_STRING,REDIS_LIST,REDSI_HASH,REDSI_SET,REDSI_ZSET
unsigned type:4;
//指向对象的底层实现数据结构,这些数据结构由对象的encoding属性决定
void *ptr;
//记录对象所使用的编码,也就是说说这个对象使用了什么数据结构作为对象的底层实现
unsigned encoding:4;
...
} robj;
type的枚举
encoding的枚举
每种类型的对象都可以根据情况使用多种不同的编码,表8-4列出了每种类型的对象可以使用的编码。
2.2 字符串对象
字符串对象的编码可以是int 、raw或者embstr,
如果一个字符串对象保存的是整数值,并且这个 整数值可以用long类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面 (将void*转换成long), 并将字符串对象的编码设置为int
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisobject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构
2.3 列表对象
列表对象的编码可以是ziplist或者linkedlist。
ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。举个例子,如果我们执行以下RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值:
redis> RPUSH numbers 1 "three" 5
(integer) 3
如果numbers键的值对象使用的是zip1ist编码,
linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。
2.4 哈希表对象
哈希对象的编码可以是ziplist或者hashtable。
ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:
1 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后。
2 添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
举个例子,如果我们执行以下HSET命令,那么服务器将创建一个列表对象作为profile键的值:
其对应存储结构如下
hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:
1 字典的每个键都是一个字符串对象,对象中保存了键值对的键;
2 字典的每个值都是一个字符串对象,对象中保存了键值对的值。
其对应存储结构如下
2.5 集合对象
集合对象的编码可以是intset或者hashtable。
intset编码的集合对象使用整数集合作底层实现,集合对象包含的所有元素都被保存在整数集合里面。举个例子,以下代码将创建一个如图8-12所示的intset编码集合对象:
redis> SADD numbers 135 (integer)3
hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。
2.6 有序集合对象
有序集合的编码可以是ziplist或者skiplist。
ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。举个例子,如果我们执行以下ZADD命令,那么服务器将创建一个有序集合对象作price键的值:
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:
三 Redis过期策略的实现
我们在使用redis时有时候会指定一些key的过期时间ttl,对于用户而言,这个key到了指定的过期时间就会自动消失,这个章节会介绍一下redis对于key的过期策略的实现。
3.1 key的过期时间存储
redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:
1 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)。
2 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间一一个毫秒精度的UNIX时间戳。
图展示了一个带有过期字典的数据库例子,在这个例子中,键空间保存了数据库中的所有键值对,而过期字典则保存了数据库键的过期时间
很容易想到,我们在指定,修改,删除某个key的ttl时,redis只需要修改过期字典这个key相应的值就可以了。而获取某个key剩余的生存时间时,也只需要在过期字典中得到这个key的过期时间,然后减去当前时间返回就可以了。如果需要判定一个key是否过期了,只需要判断过期字典中这个key(如果有)是否大于当前时间,
3.2 过期key的删除策略
3.2.1 常用的过期策略
当redis的某个key过期后,redis肯定是需要清理它来释放内存的,说的清理策略,现在常用的方案有三种
定时删除
定时删除策略对内存是最友好的,通过使用定时器,定时删除策略可以保证过期键会尽可能快地被删除,并释放过期键所占用的内存,但是他的缺点也很明显,如果设置过期时间的key太多时候,就需要注册大量的定时器,而现在大多数定期器的实现本质上都是定时扫描,每次都需要扫描所有定时任务查看是否到达执行时间,时间复杂度是o(n),无疑会给系统造成巨大的负担。
惰性删除
就是每次用的时候判断这个key是否过期,过期就把它删了,这种方式实现简单,性能损耗小,但是对于一些过期后没有再访问的key,会造成内存泄漏。
定期删除
就是每隔一段时间扫描一些key,判断是否过期,过期则删除。定期删除策略的难点是确定删除操作执行的时长和频率,在内存浪费和性能之间找到一个平衡。
如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将CPU时间过多地消耗在删除过期键上面。
如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况。
3.2.2 redis的过期策略
在前面,我们讨论了定时删除、惰性删除和定期删除三种过期键删除策略,Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡。
惰性策略
过期键的惰性删除策略由db.c/expirelfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeded函数对输人键进行检查。
如果输入键已经过期,那么expireIfNeeded函数将输人键从数据库中删除。
如果输入键未过期,那么expireIfNeeded函数不做动作。
定期删除策略
过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpirecycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。
函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpirecycle函数调用时,接着上一次的进度进行处理。比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次activeExpirecycle函数执行时,将从11号数据库开始查找并删除过期键
随着activeExpirecycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作。
3.2.3 AOF、RDB和复制功能对过期键的处理
RDB
在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。
在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。如果服务器以主服务器模式运行,那么在载人RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载人到数据库中,而过期键则会被忽略,所以过期键对载入RDB文件的主服务器不会造成影响,如果服务器以从服务器模式运行,那么在载人RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响。
AOF
当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,来显式地记录该键已被删除。
和生成RDB文件时类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。
四 Redis数据持久化的实现
任何机器都会有宕机的情况,redis服务器也不例外,一旦宕机,任何内存中的数据都会丢失,这是一个巨大的数据安全隐患,redis作为一个成熟的内存数据库,在存储数据的同时也会持久化这些数据,便于服务重启或者宕机后的数据恢复,这就涉及到了性能,硬盘占用和数据恢复完整度之间的平衡,本章节会介绍一下redis实现的数据持久化的实现。
4.1 RDB持久化
因为Redis是内存数据库,它将自己的数据库状态储存在内存里面,所以如果不想办法将储存在内存中的数据库状态保存到磁盘里面,那么一旦服务器进程退出,服务器中的数据库状态也会消失不见。为了解决这个问题,Redis提供了RDB持久化功能,这个功能可以将Redis在内存中的数据库状态保存到磁盘里面,避免数据意外丢失。RDB持久化既可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中,RDB持久化功能所生成的RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。
因为RDB文件是保存在硬盘里面的,所以即使Redis服务器进程退出,甚至运行Redis服务器的计算机停机,但只要RDB文件仍然存在,Redis服务器就可以用它来还原数据库状态
4.1.1 RDB文件的创建和载入
有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE。SAVE命令会阻塞Redis服务器进程(客户端发送的所有命令请求都会被拒绝),直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求,BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求。
和使用SAVE命令或者BGSAVE命令创建RDB文件不同,RDB文件的载入工作是在服务器启动时自动执行的,所以Redis并没有专门用于载人RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就会自动载人RDB文件。服务器在载入RDB文件期间,会一直处于阻塞状态(客户端发送的所有命令请求都会被拒绝),直到载人工作完成为止。另外,因为AOF文件的更新频率通常比RDB文件的更新频率高,所以
1 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。
2 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。
SAVE,BGSAVE,BGREWRITEAOF执行关系
1 在BGSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令同时执行是了避免父进程(服务器进程)和子进程同时执行两个rdbSave调用,防止产生竟争条件
2 在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件
3 如果BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行。
4 如果BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令会被服务器拒绝
BGREWRITEAOF和BGSAVE两个命令的实际工作都由子进程执行,所以这两个命令在操作方面并没有什么冲突的地方,不能同时执行它们只是一个性能方面的考虑。
4.1.2 自动间隔性保持
功能
因为BGSAVE命令可以在不阻塞服务器进程的情况下执行,所以Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。用户可以通过save选项设置多个保存条件,但只要其中任意一个条件被满足,服务器就会执行BGSAVE命令。
举个例子,如果我们向服务器提供以下配置:
save 900 1
save 300 10
save 60 10000
那么只要满足以下三个条件中的任意一个,BGSAVE命令就会被执行:
1 服务器在900秒之内,对数据库进行了至少1次修改。
2 服务器在300秒之内,对数据库进行了至少10次修改
3 服务器在60秒之内,对数据库进行了至少10000次修改。
如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件,默认条件就是上面的例子,
保存save条件-saveparams
redis使用saveparams属性来保存自动save的条件,saveparams属性是一个数组,数组中的每个元素都是一个saveparam结构,每个saveparam结构都保存了一个save选项设置的保存条件
struct saveparam {
time_t seconds;
int changes;
};
比如上面的例子,saveparams的存储结构如下
save信息保存-dirty和lastsave
服务器状态还维持着一个dirty计数器,以及一个lastsave属性:
1 dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)。
2 lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间
struct redisServer (
//...
// 修改计数器
long long dirty;
// 上一次执行保存的时间
time_t lastsave;
//...
};
检查保存条件是否満足
Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令.程序会遍历并检查saveparams数组中的所有保存条件,只要有任意一个条件被满足,那么服务器就会执行BGSAVE命令。
4.1.3 RDB文件结构
图10-10展示了一个完整RDB文件所包含的各个部分
RDB文件的最开头是REDIS部分,这个部分的长度为5字节,保存着“REDIS"五个字符。通过这五个字符,程序可以在载人文件时,快速检查所载人的文件是否RDB文件
文件的版本号,比如“0006"就代表RDB文件的版本为第六版。本章只介绍第六版RDB文件的结构。
databases部分包含着零个或任意多个数据库,以及各个数据库中的键值对数据,如果服务器的数据库状态为空(所有数据库都是空的),那么这个部分也空,长度为0字节
EOF常量的长度为1字节,这个常量标志着RDB文件正文内容的结束,当读入程序遇到这个值的时候,它知道所有数据库的所有键值对都已经载人完毕了。
check_sum是一个8字节长的无符号整数,保存着一个校验和,这个校验和是程序通过对REDIS、db_version、databases、EOF四个部分的内容进行计算得出的。服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比,以此来检查RDB文件是否有出错或者损坏的情况出现。
作为例子,图10-11展示了一个databases部分为空的RDB文件:文件开头的"REDIS"表示这是一个RDB文件,之后的"0006"表示这是第六版的RDB文件,因为databases为空,所以版本号之后直接跟着EOF常量,最后的6265312314761917404是文件的校验和。
databases部分
一个RDB文件的databases部分可以保存任意多个非空数据库。例如,如果服务器的0号数据库和3号数据库非空,那么服务器将创建一个如图10-12所示的RDB文件,图中的database0代表0号数据库中的所有键值对数据,而database3则代表3号数据库中的所有键值对数据
每个非空数据库在RDB文件中都可以保存为SELECTDB、db_number、key_value_pairs三个部分,如图10-13所示。
SELECTDB常量的长度为1字节,当读人程序遇到这个值的时候,它知道接下来要读人的将是一个数据库号码
db_number保存着一个数据库号码,根据号码的大小不同,这个部分的长度可以是1字节、2字节或者5字节。当程序读入db-_number部分之后,服务器会调用SELECT命令,根据读人的数据库号码进行数据库切换,使得之后读入的键值对可以载人到正确的数据库中
key_value_pairs部分保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。根据键值对的数量、类型、内容以及是否有过期时间等条件的不同,key_value_pairs部分的长度也会有所不同。每个key_value_Pairs部分都保存了一个或以上数量的键值对,如果键值对带有过期时间的话,那么键值对的过期时间也会被保存在内
EXPIRETIME_MS常量的长度为1字节,它告知读人程序,接下来要读人的将是一个以毫秒为单位的过期时间。
ms是一个8字节长的带符号整数,记录着一个以毫秒为单位的UNIX时间戳,这个时间戳就是键值对的过期时间。
TYPE记录了value的类型,长度1字节,值可以是以下常量的其中一个
• REDIS_RDB_TYPE_STRING
• REDIS_RDB_TYPE_LIST
• REDIS_RDB_TYPE_SET
• REDIS_RDB_TYPE_ZSET
• REDIS_RDB_TYPE_HASH
• REDIS_RDB_TYPE_LIST_ZIPLIST
• REDIS_RDB_TYPE_SET_INTSET
• REDIS_RDB_TYPE_ZSET_ZIPLIST
• REDIS_RDB_TYPE_HASH_ZIPLIST
以上列出的每个TYPE常量都代表了一种对象类型或者底层编码,当服务器读入RDB文件中的键值对数据时,程序会根据TYPE的值来决定如何读人和解释value的数据。key和value分别保存了键值对的键对象和值对象,其中key总是一个字符串对象,它的编码方式和REDIS_RDB_TYPE_STRING类型的value一样。根据内容长度的不同,key的长度也会有所不同。
4.2 AOF持久化
4.2.1 AOF流程和文件内容
除了RDB持久化功能之外,Redis还提供了AOF(AppendOnlyFile)持久化功能。与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态的,如图11-1所示。
被写人AOF文件的所有命令都是以Redis的命令请求协议格式保存的,因为Redis的命令请求协议是纯文本格式,所以我们可以直接打开一个AOF文件,观察里面的内容。下面是一个AOF文件例子
redis> SET msg "hello"
OK
redis> SADD fruits "app le" "banana" "cherry"(integer) 3
redis > RPUSH numbers 128 256 512(integer) 3
*2 \r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n *5\r\n$4\r\nSADD\r\n$6\r\nfruits\r\n$5\r\napple\r\n$6\r\nbanana\r\n$6\r\ncherry\r\n *5\r\n$5\r\RPUSH\r\n$7\r\nnumbers\r\n$3\r\n128\r\n$3\r\256\r\n$3\r\n512\r\n
在这个AOF文件里面,除了用于指定数据库的SELECT命令是服务器自动添加的之外,其他都是我们之前通过客户端发送的命令。服务器在启动时,可以通过载人和执行AOF文件中保存的命令来还原服务器关闭之前的数据库状态。
4.2.2 AOF持久化的实现
AOF持久化功能的实现可以分为命令追加(append)、文件写人、文件同步(sync)三个步骤。
命令追加
当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾。
struct redisServer (
//...
//AOF缓存区
sds aof_buf;
//...
};
AOF文件的写入和同步
Redis的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责执行像serverCron函数这样需要定时运行的函数。因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区里面,所以在服务器每次结束一个事件循环之前,它都会调用flushappendOnlyFile函数,考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF文件里面
flushAppendOnlyFile函数的行为由服务器配置的appendfsync选项的值来决定,各个不同值产生的行为如表11-1所示。
这里的同步指的就是强制刷盘(因为大部分系统对于文件读写有缓存)
如果用户没有主动为appendfsync选项设置值,那么appendfsync选项的默认值为everysec,关于appendfsync选项的更多信息,请参考Redis项目附带的示例配置文件redis.conf。
AOF文件的载入与数据还原
因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读人并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。Redis读取AOF文件并还原数据库状态的详细步骤如下
1 创建一个不带网络连接的伪客户端(fakeclient):因为Redis的命令只能在客户端上下文中执行,而载人AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样
2 从AOF文件中分析并读取出一条写命令。
3 使用伪客户端执行被读出的写命令。
4 一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止
AOF 重写和其实现
因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的AOF文件很可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间就越多
为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写(rewrite)功能。通过该功能,Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,所以新AOF文件的体积通常会比IAOF文件的体积要小得多。
虽然Redis将生成新AOF文件替换旧日AOF文件的功能命名为“AOF文件重写”,但实际上,AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写人操作,这个功能是通过读取服务器当前的数据库状态来实现的
AOF重写程序放在子进程里执行,这样做可以同时达到两个目的
1子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求。
2子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。
不过,使用子进程也有一个问题需要解决,因为子进程在进行AOF重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致,为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,如图11-4所示。
当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:
1将AOF重写缓冲区中的所有内容写人到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致。
2 对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换。
这个信号处理函数执行完毕之后,父进程就可以继续像往常一样接受命令请求了,在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,在其他时候,AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低。
五 Redis服务器实现
5.1 事件
Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:
文件事件(fileevent):Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。
时间事件(timeevent):Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。
下面将对文件事件和时间事件进行介绍,说明这两种事件在Redis服务器中的应用,它们的实现方法,以及处理这些事件的API等等。
5.1.1 文件事件
Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler):
文件事件处理器使用I/O多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
当被监听的套接字准备好执行连接应答(accept)、读取(read)、写人(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。虽然文件事件处理器以单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与Redis服务器中其他同样以单线程方式运行的模块进行对接,这保持了Redis内部单线程设计的简单性。
文件事件处理器的四个组成部分,它们分别是套接字、1/O多路复用程序、文件事件分派器(dispatcher),以及事件处理器。
套接字
文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答命令请求处理器(accept)、写人、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器命冬回复处理器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。
I/O多路复用程序
I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。尽管多个文件事件可能会并发地出现,但1/O多路复用程序总是会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),1/O多路复用程序才会继续向文件事件分派器传送下一个套接字。
Redis的I/O多路复用程序的所有功能都是通过包装常见的select、epo11、evport和kqueue这些1/O多路复用函数库来实现的,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件,比如ae_select.c、ae_epoll.c、ae_kqueue.C,诸如此类。因为Redis为每个1/0多路复用函数库都实现了相同的API,所以1/O多路复用程序的底层实现是可以互换的编译时会根据系统,硬件等情况自动选择系统中性能最高的1/O多路复用函数库来作为Redis的1/O多路复用程序的底层实现:
文件事件分派器
文件事件分派器接收1/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。
事件处理器
服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,它们定义了某个事件发生时,服务器应该执行的动作。Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求,比如说:
1 为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器。
2 为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器。
3 为了向客户端返回命令的执行结果,服务器要为客户端套接字关联命令回复处理器。
4 当主服务器和从服务器进行复制操作时,主从服务器都需要关联特别为复制功能编写的复制处理器。
在这些事件处理器里面,服务器最常用的要数与客户端进行通信的连接应答处理器、命令请求处理器和命令回复处理器。
事件的类型
I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字操作之间的对应关系如下:
1 当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作),或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE_READABLE事件。
2 当套接字变得可写时(客户端对套接字执行read操作),套接字产生AB_WRITABLE事件。
I/O多路复用程序允许服务器同时监听套接字的AE_READABLE事件和AE_WRITABLE事件,
如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理AB_READABLE事件,等到AB_READABIE事件处理完之后,才处理AB_WRITABLE事件。这也就是说,如果一个套接字又可读又可写的话,那么服务器将先读套接字,后写套接字
5.1.2 时间事件
介绍
Redis的时间事件分为以下两类:
1 定时事件:让一段程序在指定的时间之后执行一次。比如说,让程序X在当前时间的30毫秒之后执行一次。
2 周期性事件:让一段程序每隔指定时间就执行一次。比如说,让序Y每隔30毫秒就执行一次。
一个时间事件主要由以下三个属性组成:
1 id:服务器为时间事件创建的全局唯一1D(标识号)。ID号按从小到大的顺序递增,新事件的ID号比旧事件的ID号要大。
2 when:毫秒精度的UNIX时间戳,记录了时间事件的到达(arrive)时间。
3 timeProc:时间事件处理器,一个函数。当时间事件到达时,服务器就会调用相应的处理器来处理事件。
一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值,如果事件处理器返回ae.h/AB_NOMORE,那么这个事件为定时事件:该事件在达到一次之后就会被删除,之后不再到达。如果事件处理器返回一个非AE_NOMORE的整数值,那么这个事件为周期性时间:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。比如说,如果一个时间事件的处理器返回整数值30,那么服务器应该对这个时间事件进行更新,让这个事件在30毫秒之后再次到达。
实现
服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历鳖个链表,查找所有已到达的时间事件,并调用相应的事件处理器。图12-8展示了一个保存时间事件的链表的例子,链表中包含了三个不同的时间事件:因为新的时间事件总是插入到链表的表头,所以三个时间事件分别按ID逆序排序,表头事件的ID为3,中间事件的ID为2,表尾事件的ID为1。
注意,我们说保存时间事件的链表无序链表,指的不是链表不按ID排序,而是说,该链表不按when属性的大小排序。正因为链表没有按when属性进行排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。
时间事件应用实例-serverCron函数
持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:
1 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
2 清理数据库中的过期键值对。
3 关闭和清理连接失效的客户端。
4 尝试进行AOF或RDB持久化操作。
5 如果服务器是主服务器,那么对从服务器进行定期同步。又如果处于集群模式,对集群进行定期同步和连接测试。
Redis服务器以周期性事件的方式来运行serverCron函数,在服务器运行期间,每隔一段时间,serverCron就会执行一次,直到服务器关闭为止。在Redis2.6版本,服务器默认规定serverCron每秒运行10次,平均每间隔100毫秒运行一次。从Redis2.8开始,用户可以通过修改hz选项来调整serverCron的每秒执行次数,具体信息请参考示例配置文件redis.conf关于hz选项的说明。
5.1.3 事件的调度与执行
因为服务器中同时存在文件事件和时间事件两种事件类型,所以服务器必须对这两种事件进行调度,决定何时应该处理文件事件,何时又应该处理时间事件,以及花多少时间来处理它们等等。事件的调度和执行由ae.c/aeProcessEvents函数负责,以下是该函数的伪代码表示:
def aeProcessEvents () :
# 获取到达时间离当前时间最接近的时间事件
time_event = aeSearchNearestTimer ()
# 计算最接近的时间事件距离到达还有多少毫秒
remaind ms_ = time_event.when - unix_ts_now()
#如果事件已到达,那么remaind_ms的值可能为负数 ,将它设定为0
if remaind_ms < 0:
remaind_ms = 0
# 根据 remaind_ms 的值,创建timeval 结构
timeval = create_timeval_with_ms (remaind_ms)
# 阻塞并等待文件事件产生,最大阻塞时间由传入的timeval结构决定
# 如果remaind_ms的值为0,那么aeApiPoll调用之后马上返回 ,不阻塞
aeApiPoll (timeval)
# 处理所有已产生的文件事件
processFileEvents()
# 处理所有已到达的时间事件
processTimeEvents ()