redis的设计与实现

redis的设计和实现

第一部分、数据结构与对象

一、简单动态字符串:

在大多数情况下redis只会使用c字符串作为字面量,在大多情况下,redis使用SDS作为字符串表示。

比起C字符串,SDS具有五种优点:

SDS结构里面会有一个len变量,新增或者减len相应改变(而C语言并不会记录字符串的长度,如果直接用C语言的字符串则复杂度会变为O(N)) 常数复杂度获取字符串
SDS结构体里面会有一个free变量记录还剩余的空闲存储区,在进行字符串拼接时可以判断 杜绝缓冲区溢出
用free判断是否进行内存重分配,进行n次扩充如果时SDS最多进行N次内存重分配,如果时c语言字符串,则必定进行N次内存重分配 减少修改字符串长度所需的内存重分配次数
可以通过长度获取字符串并不会向c语言那样遇到空格就停止,SDS可以通过长度判断获取字符串 二进制安全
SDS结尾和C语言一样,这样可以很方便的调用C语言的一部分库函数 兼容部分C语言字符串函数
struct sdshdr{
    //记录buf数组中已经使用字节的数量
    //等于SDS所保存的字符串长度
    int len;
    //记录buf数组中未使用的字节数量
    int free;
    //字节数组用于保存字符串
    char buf[];
};

二、链表

链表被广泛的使用在Redis的各种功能中,比如列表键,发布与订阅,慢查询,监视器.

typedef struct list
{
    //表头节点
    listNode *head;
    //表尾节点
    listNode *tail;
    //表中所包含的节点数量
    unsigned long len;
    //各种链表函数
        .
        .
        .
        .
}
​
//链表采用的双端结构,前pre指向null,后tail指向null

redis的设计与实现_第1张图片

三、字典

字典用于实现redis的各种功能

typedef struct dictht{
    //哈希数组
    dictEntry **table;
    //哈希表大小
    unsigined long size;
    //哈希大小掩码,用于计算索引值
    //总是等于size-1
    unsigned long sizemask;
    //该哈希表已有的节点数量
    unsigned long used;
}dictht;
​
//dictEntry结构
typedef struct  dictEntry{
    //键
    void *key
   //值
   union{
        void *val;
        unit64_tu64;
        int64_ts64;
    }v;
  //指向下个哈希表节点,形成链表
    struct dictEntry *next;
}dictEntry;
//字典结构体
typedef struct dict{
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    //rehash索引
    //rehash不在进行时为1
    int trehashidx;
}dict;
字典里面有俩个哈希表一个平时用,一个rehash时用 怎么用hash表
链地址法 如何解决hash冲突
当服务器目前没有进行bgsave或者AOF时哈希负载因子大于一时和在进行bgsave和AOF时负载因子大于5时进行扩容,在负载因子小于0.1时进行缩小。 hash表的扩展与伸缩
渐进式的rehash 伸缩和扩展的方法
字典的删除或查找是在俩个hash表中都进行的。 字典的删除与查找

img

四、跳跃表

typedef struct zkiplistNode{
    //层
    struct zskiplistNode{
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned int span;
    }level[];
    //后退指针
    struct zskiplistNode *backward;
    //分值
    double score;
    //成员对象
    robj *obj
}zkiplistNode;
​
typedef struct zskiplist{
    //表节点和表尾节点
    structz skiplistNode *header,*tail;
    //表中节点的数量
    unsigned long length;
    //表中层数最大的节点的层数
    int level;
}zskiplist;

跳跃表是由zskiplist和zskiplistNode俩个结构组成的,其中zskiplist用于保存跳跃表中的信息,zskiplistNode则用于保存跳跃表节点。每个表节点的层高都是1-32之间的随机数。当分值相同节点按照成员对象的大小进行排序。

redis的设计与实现_第2张图片

而且还是从高分值向低分值迭代。

五、整数集合

typedef struct intset{
    //编码方式
    uint_32 encoding;
    //集合中包含的元素个数
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
}intset;

redis的设计与实现_第3张图片

整数集合的升级需要对底层数组进行内存重分配,引发升级的新元素长度总是比集合现有的所有元素的长度都大,新元素会被放在末尾 整数集合的升级
1.提升整数集合的灵活性2.尽可能的节约内存 升级的好处
有序与无重复保存集合元素 数组类型
只支持升级,不支持降级 无降级
可以根据改变encoding灵活的改变编码方式 改变编码方式

六、压缩列表

1.压缩列表是为了节约内存而开发的顺序型数据结构

2.压缩列表可以包含一个字节数组或者整数值

3.添加新节点到压缩列表,或者说从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现几率并不高。

redis的设计与实现_第4张图片

七、对象

typedef struct redisObject{
    //类型
    unsigned type:4;
    //编码
    unsigned encoding:4;
    //指向底层实现数据结构的指针
    void *ptr;
    ......
}robj;

1.字符串对象

redis的设计与实现_第5张图片

采用raw和embstr俩种方式存放字符串

embstr是专门用于保存短字符串值,embstr创建和释放只需要一次,因为embstr把字符串保存在一个连续的内存里面,相比于raw编码更能带来优势。

2.列表对象

redis的设计与实现_第6张图片

当字符串长度小于64个字节,保存的元素少于521个用的压缩列表,当大于时用的链表

具体多大变化可以通过list-max-ziplist-value选项和list-max-ziplist-entries在配置文件里面改变。

3.哈希对象

用的压缩列表或者哈希表。

redis的设计与实现_第7张图片

具体多大变化也可以通过redis.conf文件进行修改。

4.集合对象

redis的设计与实现_第8张图片

当集合中所有的对象都是整数且整数的数量不超过521个这时用的就是intset编码的格式。

5.有序集合的对象

redis的设计与实现_第9张图片

当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist 编码:

  1. 有序集合保存的元素数量小于 128 个;

  2. 有序集合保存的所有元素成员的长度都小于 64 字节;

不能满足以上两个条件的有序集合对象将使用 skiplist 编码。

压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。

6.对象的其他通用东西

1.类型检查

  • 在执行一个类型特定命令之前, 服务器会先检查输入数据库键的值对象是否为执行命令所需的类型, 如果是的话, 服务器就对键执行指定的命令;

  • 否则, 服务器将拒绝执行命令, 并向客户端返回一个类型错误。

举个例子, 对于 LLEN 命令来说:

  • 在执行 LLEN 命令之前, 服务器会先检查输入数据库键的值对象是否为列表类型, 也即是, 检查值对象 redisObject 结构 type 属性的值是否为 REDIS_LIST , 如果是的话, 服务器就对键执行 LLEN 命令;

  • 否则的话, 服务器就拒绝执行命令并向客户端返回一个类型错误;

图 8-18 展示了这一类型检查过程。

redis的设计与实现_第10张图片

其他类型特定命令的类型检查过程也和这里展示的 LLEN 命令的类型检查过程类似。

2.命令多态

现在, 考虑这样一个情况, 如果我们对一个键执行 LLEN 命令, 那么服务器除了要确保执行命令的是列表键之外, 还需要根据键的值对象所使用的编码来选择正确的 LLEN 命令实现:

  • 如果列表对象的编码为 ziplist , 那么说明列表对象的实现为压缩列表, 程序将使用 ziplistLen 函数来返回列表的长度;

  • 如果列表对象的编码为 linkedlist , 那么说明列表对象的实现为双端链表, 程序将使用 listLength 函数来返回双端链表的长度;

借用面向对象方面的术语来说, 我们可以认为 LLEN 命令是多态(polymorphism)的: 只要执行 LLEN 命令的是列表键, 那么无论值对象使用的是 ziplist 编码还是 linkedlist 编码, 命令都可以正常执行。

图 8-19 展示了 LLEN 命令从类型检查到根据编码选择实现函数的整个执行过程, 其他类型特定命令的执行过程也是类似的。

redis的设计与实现_第11张图片

实际上, 我们可以将 DEL 、 EXPIRE 、 TYPE 等命令也称为多态命令, 因为无论输入的键是什么类型, 这些命令都可以正确地执行。

DEL 、 EXPIRE 等命令和 LLEN 等命令的区别在于, 前者是基于类型的多态 —— 一个命令可以同时用于处理多种不同类型的键, 而后者是基于编码的多态 —— 一个命令可以同时用于处理多种不同编码。

3.内存回收

对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时, 引用计数的值会被初始化为 1

  • 当对象被一个新程序使用时, 它的引用计数值会被增一;

  • 当对象不再被一个程序使用时, 它的引用计数值会被减一;

  • 当对象的引用计数值变为 0 时, 对象所占用的内存会被释放。

4.对象共享

共享对象机制对于节约内存非常有帮助, 数据库中保存的相同值对象越多, 对象共享机制就能节约越多的内存。

比如说, 假设数据库中保存了整数值 100 的键不只有键 A 和键 B 两个, 而是有一百个, 那么服务器只需要用一个字符串对象的内存就可以保存原本需要使用一百个字符串对象的内存才能保存的数据。

目前来说, Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从 09999 的所有整数值, 当服务器需要用到值为 09999 的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象。

为什么 Redis 不共享包含字符串的对象?

当服务器考虑将一个共享对象设置为键的值对象时, 程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同, 只有在共享对象和目标对象完全相同的情况下, 程序才会将共享对象用作键的值对象, 而一个共享对象保存的值越复杂, 验证共享对象和目标对象是否相同所需的复杂度就会越高, 消耗的 CPU 时间也会越多:

  • 如果共享对象是保存整数值的字符串对象, 那么验证操作的复杂度为 O(1) ;

  • 如果共享对象是保存字符串值的字符串对象, 那么验证操作的复杂度为 O(N) ;

  • 如果共享对象是包含了多个值(或者对象的)对象, 比如列表对象或者哈希对象, 那么验证操作的复杂度将会是 O(N^2) 。

因此, 尽管共享更复杂的对象可以节约更多的内存, 但受到 CPU 时间的限制, Redis 只对包含整数值的字符串对象进行共享。

5.对象的空转时间

redis会记录自己最后一次被访问的时间,这个时间用于记录对象的空转时间。

重点回顾

  • Redis 数据库中的每个键值对的键和值都是一个对象。

  • Redis 共有字符串、列表、哈希、集合、有序集合五种类型的对象, 每种类型的对象至少都有两种或以上的编码方式, 不同的编码可以在不同的使用场景上优化对象的使用效率。

  • 服务器在执行某些命令之前, 会先检查给定键的类型能否执行指定的命令, 而检查一个键的类型就是检查键的值对象的类型。

  • Redis 的对象系统带有引用计数实现的内存回收机制, 当一个对象不再被使用时, 该对象所占用的内存就会被自动释放。

  • Redis 会共享值为 09999 的字符串对象。

  • 对象会记录自己的最后一次被访问的时间, 这个时间可以用于计算对象的空转时间

第二部分、单机数据库的实现

服务中的数据库是用数组保存的,默认是16,具体可以通过配置文件进行修改,客户端只需要进行指针的切换就可以切换数据库,增加新建,删除新建,更新新建,对键取值都是对键值对进行操作。

读写键空间的维护操作:

当使用 Redis 命令对数据库进行读写时, 服务器不仅会对键空间执行指定的读写操作, 还会执行一些额外的维护操作, 其中包括:

  • 在读取一个键之后(读操作和写操作都要对键进行读取), 服务器会根据键是否存在, 以此来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数, 这两个值可以在 INFO stats 命令的 keyspace_hits 属性和 keyspace_misses 属性中查看。

  • 在读取一个键之后, 服务器会更新键的 LRU (最后一次使用)时间, 这个值可以用于计算键的闲置时间, 使用命令 OBJECT idletime 命令可以查看键 key 的闲置时间。

  • 如果服务器在读取一个键时, 发现该键已经过期, 那么服务器会先删除这个过期键, 然后才执行余下的其他操作, 本章稍后对过期键的讨论会详细说明这一点。

  • 如果有客户端使用 WATCH 命令监视了某个键, 那么服务器在对被监视的键进行修改之后, 会将这个键标记为脏(dirty), 从而让事务程序注意到这个键已经被修改过, 《事务》一章会详细说明这一点。

  • 服务器每次修改一个键之后, 都会对脏(dirty)键计数器的值增一, 这个计数器会触发服务器的持久化以及复制操作执行, 《RDB 持久化》、《AOF 持久化》和《复制》这三章都会说到这一点。

  • 如果服务器开启了数据库通知功能, 那么在对键进行修改之后, 服务器将按配置发送相应的数据库通知, 本章稍后讨论数据库通知功能的实现时会详细说明这一点。

设置生存时间或者过期时间:

虽然有多钟不同单位和不同形式的设置命令,但实际上最后都是使用PEXPIREAT命令实现。

过期时间是通过键值对保存在过期字典里面的,过期键的判定,1.检查给定的键是否存在于过期字典:如果存在,那么取得键的过期时间。2.检查当前UNIX时间戳是否大于键的过期时间:如果是的话,那么键已过期;否则的话,键未过期。

redis的过期键的删除策略:

常见的删除策略有以下3种:

  1. 定时删除

    在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。

  2. 惰性删除

    放任过期键不管,每次从键空间中获取键时,检查该键是否过期,如果过期,就删除该键,如果没有过期,就返回该键。

  3. 定期删除

    每隔一段时间,程序对数据库进行一次检查,删除里面的过期键,至于要删除哪些数据库的哪些过期键,则由算法决定。

其中定时删除和定期删除为主动删除策略,惰性删除为被动删除策略。

1.1 定时删除策略

定时删除策略通过使用定时器,定时删除策略可以保证过期键尽可能快地被删除,并释放过期键占用的内存。

因此,定时删除策略的优缺点如下所示:

  1. 优点:对内存非常友好

  2. 缺点:对CPU时间非常不友好

举个例子,如果有大量的命令请求等待服务器处理,并且服务器当前不缺少内存,如果服务器将大量的CPU时间用来删除过期键,那么服务器的响应时间和吞吐量就会受到影响。

也就是说,如果服务器创建大量的定时器,服务器处理命令请求的性能就会降低,

因此Redis目前并没有使用定时删除策略。

1.2 惰性删除策略

惰性删除策略只会在获取键时才对键进行过期检查,不会在删除其它无关的过期键花费过多的CPU时间。

因此,惰性删除策略的优缺点如下所示:

  1. 优点:对CPU时间非常友好

  2. 缺点:对内存非常不友好

举个例子,如果数据库有很多的过期键,而这些过期键又恰好一直没有被访问到,那这些过期键就会一直占用着宝贵的内存资源,造成资源浪费。

1.3 定期删除策略

定期删除策略是定时删除策略和惰性删除策略的一种整合折中方案。

定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响,同时,通过定期删除过期键,也有效地减少了因为过期键而带来的内存浪费。

Redis服务器使用的是惰性删除策略和定期删除策略。

惰性删除的实现:

redis的设计与实现_第12张图片

定期删除的实现:

函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。

全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理。比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键。

随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作。

当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制:

主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键。

从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键

从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键。

通过由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,也正是因为这个原因,当一个过期键仍然存在于主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在。举个例子,有一对主从服务器,它们的数据库中都保存着同样的三个键message、xxx和yyy,其中message为过期键,如图所示。

redis的设计与实现_第13张图片

如果这时有客户端向从服务器发送命令GET message,那么从服务器将发现message键已经过期,但从服务器并不会删除message键,而是继续将message键的值返回给客户端,就好像message键并没有过期一样

redis的设计与实现_第14张图片

假设在此之后,有客户端向主服务器发送命令GET message,那么主服务器将发现键message已经过期:主服务器会删除message键,向客户端返回空回复,并向从服务器发送DEL message命令

redis的设计与实现_第15张图片

从服务器在接收到主服务器发来的DEL message命令之后,也会从数据库中删除message键,在这之后,主从服务器都不再保存过期键message了

数据库通知:

发送通知:

1)server.notify_keyspace_events属性就是服务器适配器notify-keyspace-events选项设置的值,如果给定的通知类型type不是服务器允许发送的通知类型,那么函数会直接返回,不做任何动作。

2)如果给定的通知,那么下一步函数会检测服务器是否允许发送键空间通知,如果允许的话,程序就会构建并发送通知。

3)最后,函数检测服务器是否允许发送键时间通知,如果允许的话,程序就会构建并发事件的通知。

1.RDB持久化

 Redis有个服务器状态结构:

struct redisService{
     //1、记录保存save条件的数组
     struct saveparam *saveparams;
     //2、修改计数器
     long long dirty;
     //3、上一次执行保存的时间
     time_t lastsave;
}

 ①、首先看记录保存save条件的数组 saveparam,里面每个元素都是一个 saveparams 结构:

struct saveparam{
     //秒数
     time_t seconds;
     //修改数
     int changes;
};

前面我们在 redis.conf 配置文件中进行了关于save 的配置:

save 900 1:表示900 秒内如果至少有 1 个 key 的值变化,则保存
save 300 10:表示300 秒内如果至少有 10 个 key 的值变化,则保存
save 60 10000:表示60 秒内如果至少有 10000 个 key 的值变化,则4r'r'r'r'r'r'r保存

 那么服务器状态中的saveparam 数组将会是如下的样子:

  

redis的设计与实现_第16张图片

  ②、dirty 计数器和lastsave 属性

  dirty 计数器记录距离上一次成功执行 save 命令或者 bgsave 命令之后,Redis服务器进行了多少次修改(包括写入、删除、更新等rrrrrrr操作)。

  lastsave 属性是一个时间戳,记录上一次成功执行 save 命令或者 bgsave 命令的时间。

  通过这两个命令,当服务器成功执行一次修改操作,那么dirty 计数器就会加 1,而lastsave 属性记录上一次执行save或bgsave的时间,Redis 服务器还有一个周期性操作函数 severCron ,默认每隔 100 毫秒就会执行一次,该函数会遍历并检查 saveparams 数组中的所有保存条件,只要有一个条件被满足,那么就会执行 bgsave 命令。

  执行完成之后,dirty 计数器更新为 0 ,lastsave 也更新为执行命令的完成时间。

①、优势

  1、RDB是一个非常紧凑(compact)的文件,它保存了redis 在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。

  2、生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。

  3、RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。

②、劣势

  1、RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行fork操作创建子进程,属于重量级操作,如果不采用压缩算法(内存中的数据被克隆了一份,大致2倍的膨胀性需要考虑),频繁执行成本过高(影响性能)

  2、RDB文件使用特定二进制格式保存,Redis版本演进过程中有多个格式的RDB版本,存在老版本Redis服务无法兼容新版RDB格式的问题(版本不兼容)

  3、在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失最后一次快照后的所有修改(数据有丢失)

2.AOF持久化

1)AOF持久化的实现:

会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区末尾:

struct redisServer{
    //..
    //AOF缓冲区
    sds aof_buf;
    //...;
}

2)文件的同步与写入

flushAppendOnlyFile 函数的行为由服务器配置的appendfsync选项的值来决定,各个值不同的产生行为

def eventLoop():
    while True:
        # 处理文件事件,接收命令请求以及发送命令回复
        # 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
        processFileEvents()
        
        # 处理时间事件
        processTimeEvents()
 
        # 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
        flushAppendOnlyFile()
appendfsync选项的值 flushAppendOnlyFile函数的行为
always 将aof_buf缓冲区中的所有内容写入并同步到AOF中
everysec 将aof_buf缓冲区中的所有内容写入到AOF文件中,如果上次同步AOF文件的时间距离现在超过1秒钟,那么再次对AOF文件进行同步,并且这个同步是由一个专门的线程在执行
no 将aof_buf缓冲区中的所有内容写入到AOF文件,但并不对AOF文件同步,何时同步由操作系统决定

为了提高文件的写入效率,在现代操作系统中,当用户调用write函数,将一些数据写入到文件的时候,操作系统通常会将写入数据暂时保存在一个内存缓冲区里面,等到缓冲区的空间被填满、或者超过了指定的时间限制后,才真正将缓冲求中的数据写入到磁盘中。这种做法虽然对效率带来了好处,但是对数据的安全性带来了问题,如果计算机发生了停机,那么保存在缓冲区的数据将丢失。

为此,系统提供了fsync和fdatasync俩个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到磁盘中。

3)AOF文件的载入和数据还原

redis的设计与实现_第17张图片

①创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服 务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令 的效果和带网络连接的客户端执行命令的效果完全一样

②从AOF文件中分析并读取出一条写命令

③使用伪客户端执行被读出的写命令

④一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止

4)AOF重写

服务器同时将命令发送到AOF文件和AOF重写缓冲区

  • AOF重写可以由用户通过调用BGREWRITEAOF手动触发。

  • 服务器在AOF功能开启的情况下,会维持以下三个变量:

    • 记录当前AOF文件大小的变量aof_current_size

    • 记录最后一次AOF重写之后,AOF文件大小的变量aof_rewrite_base_size

    • 增长百分比变量aof_rewrite_perc

  • 每次当serverCron(服务器周期性操作函数)函数执行时,它会检查以下条件是否全部满足,如果全部满足的话,就触发自动的AOF重写操作:

    • 没有BGSAVE命令(RDB持久化)/AOF持久化在执行;

    • 没有BGREWRITEAOF在进行;

    • 当前AOF文件大小要大于server.aof_rewrite_min_size(默认为1MB),或者在redis.conf配置了auto-aof-rewrite-min-size大小;

    • 当前AOF文件大小和最后一次重写后的大小之间的比率等于或者等于指定的增长百分比(在配置文件设置了auto-aof-rewrite-percentage参数,不设置默认为100%)

如果前面三个条件都满足,并且当前AOF文件大小比最后一次AOF重写时的大小要大于指定的百分比,那么触发自动AOF重写。

redis事件

redis的设计与实现_第18张图片

redis服务器是一个事件驱动程序,包含文件事件和时间事件。

1)文件事件

redis是基于Reactor模式开发了自己的网络事件处理器:这个事件处理器被称为文件事件处理器。

文件事件处理器使用I/O多路复用,程序同时监听多个套接字,并为套接字的不同任务关联不同的事件处理器。

当被监听的套接字准备好执行连接应答,读取,写入,关闭等操作时,与操作相应的文件事件就会被产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理相应的事件。

虽然文件事件处理器以单线程的方式运行,但通过使用I/0多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与Redis服务器中的其他同样以单线程方式运行的模块进行对接,这保持了Redis内部单线程设计的简单性。

redis的设计与实现_第19张图片

Redis 文件事件处理器过程

img

I/O多路复用程序通过队列向文件事件分派器传送套接字

redis的设计与实现_第20张图片

Redis I/O 多路复用调用的多路复用库

在 Redis 的事件处理器中,服务器中最常用有:

(1)连接应答处理器 (2)命令请求处理器 (3)命令恢复处理器

I/O 多路复用程序的实现 “多路”指的是多个网络连接 “复用”指的是复用同一个线程 采用多路 I/O 复用技术 可以让 单个线程 高效的 处理 多个连接请求(尽量减少网络 IO 的时间消耗) 且 Redis 在内存中操作数据的速度非常快,也就是说 内存内的操作 不会成为 影响Redis性能的瓶颈

Redis的 I/O多路复用程序的所有功能 是通过 包装 select、epoll、evport和kqueue 这些 I/O多路复用函数库 来实现的 每个I/O多路复用函数库 在 Redis源码中 都对应 一个单独的文件

因为Redis 为 每个I/O多路复用函数库 都实现了 相同的API,所以 I/O多路复用程序的底层实现 是可以互换的 Redis在I/O多路复用程序的实现源码中 用#include宏 定义了 相应的规则 程序会在编译时 自动选择 系统中 性能最好的I/O多路复用函数库 来作为 Redis的I/O多路复用程序的底层实现

2)时间事件

分为定时时间和周期性时间。

  1. 定时事件:让程序在指定时间只执行一次

  2. 周期事件:让程序每间隔一段时间执行一次

#define AE_NOMORE -1 
/**
 * 时间事件结构 
*/
/* Time event structure */
typedef struct aeTimeEvent {
    long long id; /* time event identifier. */ //事件id,全局唯一id,自增;
 
    //when_sec和when_ms 记录什么时候执行该事件;阅读 ae.c中processTimeEvents函数可以理解该两个属性的用途
    long when_sec; /* seconds */  // 记录事件到达的时间(秒)
    long when_ms; /* milliseconds */ //记录事件到达的时间(毫秒)(
 
    aeTimeProc *timeProc;
    aeEventFinalizerProc *finalizerProc; //最终执行的事件处理函数(ae.c中processTimeEvents函数中,可以看到该函数的调用逻辑)
    void *clientData;
    struct aeTimeEvent *prev;
    struct aeTimeEvent *next; //事件双向连表
} aeTimeEvent;

id是服务器为事件创建的全局唯一标志。从小到大自增; when_sec和when_ms 记录什么时候执行该事件;阅读 ae.c/processTimeEvents函数可以理解该值作用; timeProc就是「时间事件」的处理函数; 如果处理函数返回非AE_NOMORE的整数值,表示该事件是周期性事件,服务器会根据返回值更新 when属性(也就是下次执行的时间);

如果处理函数返回AE_NOMORE,表示是定时事件,执行完毕后会删除该事件;

时间事件的实现

服务器将所有的「时间事件」放到一个无序链表中。

当事件执行时,会遍历所有链表,判断已经到达的事件,并执行相应的事件处理器;

无序链表并不影响时间事件处理的性能,该链表并不按照when属性大小进行排序。正因为链表没有按照when属性排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有节点。

事件的调度与执行

  1. aeApiPool函数最大的堵塞事件是由最近的「时间事件」来决定的;这样可以避免服务器对时间事件的频繁轮询;

  2. 如果处理完一次「文件事件」后,还没有「时间事件」到达,服务器则再次执行「文件事件」直到「时间事件」的触发;

img

客户端

客户端的属性一类是通用属性,这些属性很少与与特定功能相关,无论客户端执行什么工作,它们都要用到这些属性。

另一类是和特定功能相关的属性,比如操作数据库时需要用到的db属性和dictid属性,执行事务时需要用到的mstate属性,以及执行WATCH命令时需要用到的watched_keys属性等等。

输入缓冲区

客户端状态的输入缓冲区用于保存客户端的命令请求:

typedef struct redisClient{
    //...
    
    sds querybuf
        
   //...     
}redisClient;

输入缓冲区的大小会根据输入内容动态的缩小和扩大。但它的最大大小不能超过1GB,否者服务器将关闭客户端。

在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务端将对命令请求的内容进行分析,并将得出的命令参数的个数分别保存到客户端状态的argv和argc属性。

命令实现函数

当服务器从协议内容中分析并得出argv属性和argc属性的值之后,服务器将根据项argv[0]的值,在命令表中查找命令所对应的命令实现函数。

分布式系统中的一致性算法

一个分布式系统不可能同时保证以下三个特征

一致性、可用性、分区容错性

一致性是指:比如有三篇文章,不能让另外俩篇文章对对其中的一篇文章的定义是不一致的

(强一致性算法)Paxos算法:

我是这么理解Paxos算法的

同学聚会出去玩

Client: 提出问题 咱们下面要去哪里玩呢 Propose: 鬼点子王 A B ... 开始发表意见/提案(ID自增长) Acceptor: 嗯嗯,不错,这个也不错呢,这个也不错, 点子的接受者,但是接受的方式很简单 谁的点子是最新提出的就听投谁的(事务ID / 提案ID) Learner:没有主见的家伙,很随意,什么都是随便阿随便,然后埋头玩手机的那群人,只等着他们讨论结束跟着他们混

提案通过很简单 过半就行 A开始提案A1 - 先去吃饭吧, 然后Acceptosr开始投票,嗯嗯.不错不错,可以可以,,,还没有过半通过 这时候 B又开始新提案B2 去看电影吧 然后Acceptor又开始新一轮投票 因为B提案ID比A的大 所以大家都比较接受B的题按

如果投票过半那就直接按B提案大家一起去看电影 然后跟旁边的几个玩手机的说 咱们去看电影吧 然后大家就一起去看电影了 ​ 如果投票没过半A那家伙不甘心肚子饿了就是想去吃饭又提出提案A3 大家又开始投票 ​ B也不甘示弱也提案B4 这样两个人没完没了的 大家都被弄晕了 ​ ​ 冲突要打架了,所以才有了后面说的等个时间 ​ A提案的时候 B也想提案不过得先等个10s 要是10S内大家都过半A的提议 那就听A的去吃饭 如果10S还没过半那就执行B的提案 ​ 嗯... 暂时就理解到这边

缺点:可想而知Paxos算法会造成活锁,而且非常难实现,还需要俩轮的RPC效率低。

Acceptor:指达到共识非常重要的数据节点

Learner:指一些备份数据库

Propose:可以不算数据库吧。算是处理请求转发请求的服务器

(强一致性算法)Raft[redis的哨兵模式就是采用的Raft算法]

强一致性算法(Raft)(redis的哨兵模式选举采用的就是这种算法)

你可能感兴趣的:(redis,数据结构,数据库)