Redis 攻略(上)

文中列出来的考点较多并且累计达3w+字 ,因此建议读者收藏,以备不时之需,通过本文你将了解到以下内容:

  • Redis的作者和发展简史
  • Redis常用数据结构及其实现
  • Redis的SDS和C中字符串的原理和对比
  • Redis有序集合ZSet的底层设计和实现
  • Redis有序集合ZSet和跳跃链表问题
  • Redis字典的实现及渐进式Rehash过程
  • Redis单线程运行模式的基本原理和流程
  • Redis反应堆模式的原理和设计实现
  • Redis持久化方案及其基本原理
  • 集群版Redis和Gossip协议
  • Redis内存回收机制和基本原理
  • Redis数据同步机制和基本原理
  • 笔者尽量详细地阐述每个问题,旨在深入理解避免囫囵吞枣的背诵,当然也会存在一些不足,如有问题可私信我。

    0x01. 什么是Redis及其重要性?

    Redis是一个使用ANSI C编写的开源、支持网络、基于内存、可选持久化的高性能键值对数据库。

    Redis的之父是来自意大利的西西里岛的Salvatore Sanfilippo,Github网名antirez,从2009年第一个版本起Redis已经走过了10个年头,目前Redis仍然是最流行的key-value型内存数据库的之一。

    优秀的开源项目离不开大公司的支持,在2013年5月之前,其开发由VMware赞助,而2013年5月至2015年6月期间,其开发由毕威拓赞助,从2015年6月开始,Redis的开发由Redis Labs赞助。笔者也使用过一些其他的NoSQL,有的支持的value类型非常单一,因此很多操作都必须在客户端实现,比如value是一个结构化的数据,需要修改其中某个字段就需要整体读出来修改再整体写入,显得很笨重,但是Redis的value支持多种类型,实现了很多操作在服务端就可以完成了,这个对客户端而言非常方便。


    当然Redis由于是内存型的数据库,数据量存储量有限而且分布式集群成本也会非常高,因此有很多公司开发了基于SSD的类Redis系统,比如360开发的SSDB、Pika等数据库,但是笔者认为从0到1的难度是大于从1到2的难度的,毋庸置疑Redis是NoSQL中浓墨重彩的一笔,值得我们去深入研究和使用。


    Redis提供了Java、C/C++、C#、 PHP 、JavaScript、 Perl 、Object-C、Python、Ruby、Erlang、Golang等多种主流语言的客户端,因此无论使用者是什么语言栈总会找到属于自己的那款客户端,受众非常广。

    0x02. 简述Redis常用的数据结构及其如何实现的?

    Redis支持的常用5种数据类型指的是value类型,分别为:字符串String、列表List、哈希Hash、集合Set、有序集合Zset,但是Redis后续又丰富了几种数据类型分别是Bitmaps、HyperLogLogs、GEO。

    由于Redis是基于标准C写的,只有最基础的数据类型,因此Redis为了满足对外使用的5种数据类型,开发了属于自己独有的一套基础数据结构,使用这些数据结构来实现5种数据类型。

    Redis底层的数据结构包括:简单动态数组SDS、链表、字典、跳跃链表、整数集合、压缩列表、对象。

    Redis为了平衡空间和时间效率,针对value的具体类型在底层会采用不同的数据结构来实现,其中哈希表和压缩列表是复用比较多的数据结构,如下图展示了对外数据类型和底层数据结构之间的映射关系:




    从图中可以看到ziplist压缩列表可以作为Zset、Set、List三种数据类型的底层实现,看来很强大,压缩列表是一种为了节约内存而开发的且经过特殊编码之后的连续内存块顺序型数据结构,底层结构还是比较复杂的。

    0x03. Redis的SDS和C中字符串相比有什么优势?

    在C语言中使用N+1长度的字符数组来表示字符串,尾部使用'\0'作为结尾标志,对于此种实现无法满足Redis对于安全性、效率、丰富的功能的要求,因此Redis单独封装了SDS简单动态字符串结构。

    在理解SDS的优势之前需要先看下SDS的实现细节,找了github最新的src/sds.h的定义看下:

    typedef char *sds;

    /*这个用不到 忽略即可*/
    struct __attribute__ ((__packed__)) sdshdr5 {
        unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
        char buf[];

    };

    /*不同长度的header 8 16 32 64共4种 都给出了四个成员
    len:当前使用的空间大小;alloc去掉header和结尾空字符的最大空间大小
    flags:8位的标记 下面关于SDS_TYPE_x的宏定义只有5种 3bit足够了 5bit没有用
    buf:这个跟C语言中的字符数组是一样的,从typedef char* sds可以知道就是这样的。
    buf的最大长度是2^n 其中n为sdshdr的类型,如当选择sdshdr16,buf_max=2^16。
    */

    struct __attribute__ ((__packed__)) sdshdr8 {
        uint8_t len; /* used */
        uint8_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr16 {
        uint16_t len; /* used */
        uint16_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr32 {
        uint32_t len; /* used */
        uint32_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr64 {
        uint64_t len; /* used */
        uint64_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };

    #define SDS_TYPE_5  0
    #define SDS_TYPE_8  1
    #define SDS_TYPE_16 2
    #define SDS_TYPE_32 3
    #define SDS_TYPE_64 4
    #define SDS_TYPE_MASK 7
    #define SDS_TYPE_BITS 3

    看了前面的定义,笔者画了个图:


    从图中可以知道sds本质分为三部分:header、buf、null结尾符,其中header可以认为是整个sds的指引部分,给定了使用的空间大小、最大分配大小等信息,再用一张网上的图来清晰看下sdshdr8的实例


    在sds.h/sds.c源码中可清楚地看到sds完整的实现细节,本文就不展开了要不然篇幅就过长了,快速进入主题说下sds的优势

    • O(1)获取长度: C字符串需要遍历而sds中有len可以直接获得;

    • 防止缓冲区溢出bufferoverflow: 当sds需要对字符串进行修改时,首先借助于len和alloc检查空间是否满足修改所需的要求,如果空间不够的话,SDS会自动扩展空间,避免了像C字符串操作中的覆盖情况;

    • 有效降低内存分配次数:C字符串在涉及增加或者清除操作时会改变底层数组的大小造成重新分配、sds使用了空间预分配和惰性空间释放机制,说白了就是每次在扩展时是成倍的多分配的,在缩容是也是先留着并不正式归还给OS,这两个机制也是比较好理解的;

    • 二进制安全:C语言字符串只能保存ascii码,对于图片、音频等信息无法保存,sds是二进制安全的,写入什么读取就是什么,不做任何过滤和限制;


    老规矩上一张黄健宏大神总结好的图:


    0x04. Redis的字典是如何实现的?简述渐进式rehash过程

    字典算是Redis中常用数据类型中的明星成员了,前面说过字典可以基于ziplist和hashtable来实现,我们只讨论基于hashtable实现的原理。

    字典是个层次非常明显的数据类型,如图:有了个大概的概念,我们看下最新的src/dict.h源码定义

    //哈希节点结构
    typedef struct dictEntry {
        void *key;
        union {
            void *val;
            uint64_t u64;
            int64_t s64;
            double d;
        } v;
        struct dictEntry *next;
    } dictEntry;

    //封装的是字典的操作函数指针
    typedef struct dictType {
        uint64_t (*hashFunction)(const void *key);
        void *(*keyDup)(void *privdata, const void *key);
        void *(*valDup)(void *privdata, const void *obj);
        int (*keyCompare)(void *privdata, const void *key1, const void *key2);
        void (*keyDestructor)(void *privdata, void *key);
        void (*valDestructor)(void *privdata, void *obj);
    } dictType;

    /* This is our hash table structure. Every dictionary has two of this as we
     * implement incremental rehashing, for the old to the new table. */

    //哈希表结构 该部分是理解字典的关键
    typedef struct dictht {
        dictEntry **table;
        unsigned long size;
        unsigned long sizemask;
        unsigned long used;
    } dictht;

    //字典结构
    typedef struct dict {
        dictType *type;
        void *privdata;
        dictht ht[2];
        long rehashidx; /* rehashing not in progress if rehashidx == -1 */
        unsigned long iterators; /* number of iterators currently running */
    } dict;

    C语言的好处在于定义必须是由最底层向外的,因此我们可以看到一个明显的层次变化,于是笔者又画一图来展现具体的层次概念:

    • 关于dictEntry

    dictEntry是哈希表节点,也就是我们存储数据地方,其保护的成员有:key,v,next指针。key保存着键值对中的键,v保存着键值对中的值,值可以是一个指针或者是uint64_t或者是int64_t。next是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决哈希冲突的问题。

    如图为两个冲突的哈希节点的连接关系:


    • 关于dictht

    从源码看哈希表包括的成员有table、size、used、sizemask。table是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针, 每个dictEntry结构保存着一个键值对;size 属性记录了哈希表table的大小,而used属性则记录了哈希表目前已有节点的数量。sizemask等于size-1和哈希值计算一个键在table数组的索引,也就是计算index时用到的。


    如上图展示了一个大小为4的table中的哈希节点情况,其中k1和k0在index=2发生了哈希冲突,进行开链表存在,本质上是先存储的k0,k1放置是发生冲突为了保证效率直接放在冲突链表的最前面,因为该链表没有尾指针

    • 关于dict

    从源码中看到dict结构体就是字典的定义,包含的成员有type,privdata、ht、rehashidx。其中dictType指针类型的type指向了操作字典的api,理解为函数指针即可,ht是包含2个dictht的数组,也就是字典包含了2个哈希表,rehashidx进行rehash时使用的变量,privdata配合dictType指向的函数作为参数使用,这样就对字典的几个成员有了初步的认识。


    字典的哈希算法

    //伪码:使用哈希函数,计算键key的哈希值
    hash = dict->type->hashFunction(key);
    //伪码:使用哈希表的sizemask和哈希值,计算出在ht[0]或许ht[1]的索引值
    index = hash & dict->ht[x].sizemask;
    //源码定义
    #define dictHashKey(d, key) (d)->type->hashFunction(key)

    redis使用MurmurHash算法计算哈希值,该算法最初由Austin Appleby在2008年发明,MurmurHash算法的无论数据输入情况如何都可以给出随机分布性较好的哈希值并且计算速度非常快,目前有MurmurHash2和MurmurHash3等版本。

    • 普通Rehash重新散列

    哈希表保存的键值对数量是动态变化的,为了让哈希表的负载因子维持在一个合理的范围之内,就需要对哈希表进行扩缩容。

    扩缩容是通过执行rehash重新散列来完成,对字典的哈希表执行普通rehash的基本步骤为分配空间->逐个迁移->交换哈希表,详细过程如下

    1. 为字典的ht[1]哈希表分配空间,分配的空间大小取决于要执行的操作以及ht[0]当前包含的键值对数量:
      扩展操作时ht[1]的大小为第一个大于等于ht[0].used*2的2^n;
      收缩操作时ht[1]的大小为第一个大于等于ht[0].used的2^n ;

      扩展时比如h[0].used=200,那么需要选择大于400的第一个2的幂,也就是2^9=512。

    2. 将保存在ht[0]中的所有键值对重新计算键的哈希值和索引值rehash到ht[1]上;

    3. 重复rehash直到ht[0]包含的所有键值对全部迁移到了ht[1]之后释放 ht[0], 将ht[1]设置为 ht[0],并在ht[1]新创建一个空白哈希表, 为下一次rehash做准备。

    • 渐进Rehash过程

    Redis的rehash动作并不是一次性完成的,而是分多次、渐进式地完成的,原因在于当哈希表里保存的键值对数量很大时, 一次性将这些键值对全部rehash到ht[1]可能会导致服务器在一段时间内停止服务,这个是无法接受的。

    针对这种情况Redis采用了渐进式rehash,过程的详细步骤:

    1. 为ht[1]分配空间,这个过程和普通Rehash没有区别;

    2. 将rehashidx设置为0,表示rehash工作正式开始,同时这个rehashidx是递增的,从0开始表示从数组第一个元素开始rehash。

    3. 在rehash进行期间,每次对字典执行增删改查操作时,顺带将ht[0]哈希表在rehashidx索引上的键值对rehash到 ht[1],完成后将rehashidx加1,指向下一个需要rehash的键值对。

    4. 随着字典操作的不断执行,最终ht[0]的所有键值对都会被rehash至ht[1],再将rehashidx属性的值设为-1来表示 rehash操作已完成。

    渐进式 rehash的思想在于将rehash键值对所需的计算工作分散到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的阻塞问题

    看到这里不禁去想这种捎带脚式的rehash会不会导致整个过程非常漫长?如果某个value一直没有操作那么需要扩容时由于一直不用所以影响不大,需要缩容时如果一直不处理可能造成内存浪费,具体的还没来得及研究,先埋个问题吧

    0x05. 讲讲4.0之前版本的Redis的单线程运行模式

    本质上Redis并不是单纯的单线程服务模型,一些辅助工作比如持久化刷盘、惰性删除等任务是由BIO线程来完成的,这里说的单线程主要是说与客户端交互完成命令请求和回复的工作线程。
    至于Antirez大佬当时是怎么想的设计为单线程不得而知,只能从几个角度来分析,来确定单线程模型的选择原因。

    5.1 单线程模式的考量

    CPU并非瓶颈多线程模型主要是为了充分利用多核CPU,让线程在IO阻塞时被挂起让出CPU使用权交给其他线程,充分提高CPU的使用率,但是这个场景在Redis并不明显,因为CPU并不是Redis的瓶颈,Redis的所有操作都是基于内存的,处理事件极快,因此使用多线程来切换线程提高CPU利用率的需求并不强烈;

    内存才是瓶颈单个Redis实例对单核的利用已经很好了,但是Redis的瓶颈在于内存,设想64核的机器假如内存只有16GB,那么多线程Redis有什么用武之地?

    复杂的Value类型:Redis有丰富的数据结构,并不是简单的Key-Value型的NoSQL,这也是Redis备受欢迎的原因,其中常用的Hash、Zset、List等结构在value很大时,CURD的操作会很复杂,如果采用多线程模式在进行相同key操作时就需要加锁来进行同步,这样就可能造成死锁问题。

    这时候你会问:将key做hash分配给相同的线程来处理就可以解决呀,确实是这样的,这样的话就需要在Redis中增加key的hash处理以及多线程负载均衡的处理,从而Redis的实现就成为多线程模式了,好像确实也没有什么问题,但是Antirez并没有这么做,大神这么做肯定是有原因的,果不其然,我们见到了集群化的Redis;

    集群化扩展:目前的机器都是多核的,但是内存一般128GB/64GB算是比较普遍了,但是Redis在使用内存60%以上稳定性就不如50%的性能了(至少笔者在使用集群化Redis时超过70%时,集群failover的频率会更高),因此在数据较大时,当Redis作为主存,就必须使用多台机器构建集群化的Redis数据库系统,这样来Redis的单线程模式又被集群化的处理所扩展了;


    软件工程角度:单线程无论从开发和维护都比多线程要容易非常多,并且也能提高服务的稳定性,无锁化处理让单线程的Redis在开发和维护上都具备相当大的优势;


    类Redis系统:Redis的设计秉承实用第一和工程化,虽然有很多理论上优秀的设计模式,但是并不一定适用自己,软件设计过程就是权衡的过程。业内也有许多类Redis的NoSQL,比如360基础架构组开发的Pika系统,基于SSD和Rocks存储引擎,上层封装一层协议转换,来实现Redis所有功能的模拟,感兴趣的可以研究和使用。

    5.2 Redis的文件事件和时间事件

    Redis作为单线程服务要处理的工作一点也不少,Redis是事件驱动的服务器,主要的事件类型就是:文件事件类型和时间事件类型,其中时间事件是理解单线程逻辑模型的关键。

    • 时间事件

    Redis的时间事件分为两类:
    1. 定时事件:任务在等待指定大小的等待时间之后就执行,执行完成就不再执行,只触发一次;

    2. 周期事件:任务每隔一定时间就执行,执行完成之后等待下一次执行,会周期性的触发;


    • 周期性时间事件

    Redis中大部分是周期事件,周期事件主要是服务器定期对自身运行情况进行检测和调整,从而保证稳定性,这项工作主要是ServerCron函数来完成的,周期事件的内容主要包括:
    1. 删除数据库的key

    2. 触发RDB和AOF持久化

    3. 主从同步

    4. 集群化保活

    5. 关闭清理死客户端链接

    6. 统计更新服务器的内存、key数量等信息

    可见 Redis的周期性事件虽然主要处理辅助任务,但是对整个服务的稳定运行,起到至关重要的作用。

    • 时间事件的无序链表

    Redis的每个时间事件分为三个部分:
    1. 事件ID 全局唯一 依次递增

    2. 触发时间戳 ms级精度

    3. 事件处理函数 事件回调函数

    时间事件Time_Event结构:


    Redis的时间事件是存储在链表中的,并且是按照ID存储的,新事件在头部旧事件在尾部,但是并不是按照即将被执行的顺序存储的

    也就是第一个元素50ms后执行,但是第三个可能30ms后执行,这样的话Redis每次从链表中获取最近要执行的事件时,都需要进行O(N)遍历,显然性能不是最好的,最好的情况肯定是类似于最小栈MinStack的思路,然而Antirez大佬却选择了无序链表的方式。

    选择无序链表也是适合Redis场景的,因为Redis中的时间事件数量并不多,即使进行O(N)遍历性能损失也微乎其微,也就不必每次插入新事件时进行链表重排

    Redis存储时间事件的无序链表如图:

    5.3 单线程模式中事件调度和执行

    Redis服务中因为包含了时间事件和文件事件,事情也就变得复杂了,服务器要决定何时处理文件事件、何时处理时间事件、并且还要明确知道处理时间的时间长度,因此事件的执行和调度就成为重点

    Redis服务器会轮流处理文件事件和时间事件,这两种事件的处理都是同步、有序、原子地执行的,服务器也不会终止正在执行的事件,也不会对事件进行抢占。
    • 事件执行调度规则

    文件事件是随机出现的,如果处理完成一次文件事件后,仍然没有其他文件事件到来,服务器将继续等待,在文件事件的不断执行中,时间会逐渐向最早的时间事件所设置的到达时间逼近并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了。

    由于时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间一般会比设定的时间稍晚一些。

    • 事件执行调度的代码实现

    Redis源码ae.c中对事件调度和执行的详细过程在aeProcessEvents中实现的,具体的代码如下:
    int aeProcessEvents(aeEventLoop *eventLoop, int flags){  int processed = 0, numevents;  if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS))    return 0;
    if (eventLoop->maxfd != -1 || ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) { int j; aeTimeEvent *shortest = NULL; struct timeval tv, *tvp;
    if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT)) shortest = aeSearchNearestTimer(eventLoop); if (shortest) {      long now_sec, now_ms; aeGetTime(&now_sec, &now_ms);      tvp = &tv; long long ms = (shortest->when_sec - now_sec)*1000 + shortest->when_ms - now_ms;
    if (ms > 0) { tvp->tv_sec = ms/1000; tvp->tv_usec = (ms % 1000)*1000; } else { tvp->tv_sec = 0; tvp->tv_usec = 0; } } else { if (flags & AE_DONT_WAIT) { tv.tv_sec = tv.tv_usec = 0; tvp = &tv; } else { tvp = NULL; /* wait forever */ }    } numevents = aeApiPoll(eventLoop, tvp); if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP) eventLoop->aftersleep(eventLoop);
    for (j = 0; j < numevents; j++) { aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd]; int mask = eventLoop->fired[j].mask; int fd = eventLoop->fired[j].fd;      int fired = 0;      int invert = fe->mask & AE_BARRIER; if (!invert && fe->mask & mask & AE_READABLE) { fe->rfileProc(eventLoop,fd,fe->clientData,mask); fired++;      } if (fe->mask & mask & AE_WRITABLE) { if (!fired || fe->wfileProc != fe->rfileProc) { fe->wfileProc(eventLoop,fd,fe->clientData,mask); fired++; }      } if (invert && fe->mask & mask & AE_READABLE) { if (!fired || fe->wfileProc != fe->rfileProc) { fe->rfileProc(eventLoop,fd,fe->clientData,mask); fired++; } } processed++; } } /* Check time events */ if (flags & AE_TIME_EVENTS)    processed += processTimeEvents(eventLoop); return processed;}
    • 事件执行和调度的伪码

    上面的源码可能读起来并不直观,在《Redis设计与实现》书中给出了伪代码实现:
    def aeProcessEvents()  #获取当前最近的待执行的时间事件  time_event = aeGetNearestTimer()  #计算最近执行事件与当前时间的差值  remain_gap_time = time_event.when - uinx_time_now()  #判断时间事件是否已经到期 则重置 马上执行  if remain_gap_time < 0:    remain_gap_time = 0  #阻塞等待文件事件 具体的阻塞等待时间由remain_gap_time决定  #如果remain_gap_time为0 那么不阻塞立刻返回  aeApiPoll(remain_gap_time)  #处理所有文件事件  ProcessAllFileEvent()  #处理所有时间事件  ProcessAllTimeEvent()
    可以看到Redis服务器是边阻塞边执行的,具体的阻塞事件由最近待执行时间事件的等待时间决定的,在阻塞该最小等待时间返回之后,开始处理事件任务,并且先执行文件事件、再执行时间事件,所有即使时间事件要即刻执行,也需要等待文件事件完成之后再执行时间事件,所以比预期的稍晚。

    • 事件调度和执行流程


    0x06. 谈谈对Redis的反应堆模式的认识

    Redis基于Reactor模式(反应堆模式)开发了自己的网络模型,形成了一个完备的基于IO复用的事件驱动服务器,但是不由得浮现几个问题:  
    1.  为什么要使用Reactor模式呢?

    2.  Redis如何实现自己的Reactor模式?

    6.1 Reactor模式

    单纯的epoll/kqueue可以单机支持数万并发,单纯从性能的角度而言毫无问题,但是技术实现和软件设计仍然存在一些差异。
    设想这样一种场景:
    • epoll/kqueue将收集到的可读写事件全部放入队列中等待业务线程的处理,此时线程池的工作线程拿到任务进行处理,实际场景中可能有很多种请求类型,工作线程每拿到一种任务就进行相应的处理,处理完成之后继续处理其他类型的任务

    • 工作线程需要关注各种不同类型的请求,对于不同的请求选择不同的处理方法,因此请求类型的增加会让工作线程复杂度增加,维护起来也变得越来越困难

    上面的场景其实和高并发网络模型很相似,如果我们在epoll/kqueue的基础上进行业务区分,并且对每一种业务设置相应的处理函数,每次来任务之后对任务进行识别和分发,每种处理函数只处理一种业务,这种模型更加符合OO的设计理念,这也是Reactor反应堆模式的设计思路。
    反应堆模式是一种对象行为的设计模式,主要同于同步IO,异步IO有Proactor模式,这里不详细讲述Proactor模式,二者的主要区别就是Reactor是同步IO,Proactor是异步IO,理论上Proactor效率更高,但是Proactor模式需要操作系统在内核层面对异步IO进行支持,Linux的Boost.asio就是Proactor模式的代表,Windows有IOCP。

    网上比较经典的一张Reactor模式的类图:


    图中给出了5个部件分别为:
    1. handle 可以理解为读写事件 可以注册到Reactor进行监控

    2. Sync event demultiplexer 可以理解为epoll/kqueue/select等作为IO事件的采集器

    3. Dispatcher 提供注册/删除事件并进行分发,作为事件分发器

    4. Event Handler 事件处理器 完成具体事件的回调 供Dispatcher调用

    5. Concrete Event Handler 具体请求处理函数


    更简洁的流程如下:
    循环前先将待监控的事件进行注册,当监控中的Socket读写事件到来时,事件采集器epoll等IO复用工具检测到并且将事件返回给事件分发器Dispatcher,分发器根据读、写、异常等情况进行分发给事件处理器,事件处理器进而根据事件具体类型来调度相应的实现函数来完成任务。

    6.2 Reactor模式在Redis中的实现

    Redis处理客户端业务(文件事件)的基本流程:


    • Redis的IO复用的选择

    #ifdef HAVE_EVPORT#include "ae_evport.c"#else    #ifdef HAVE_EPOLL    #include "ae_epoll.c"    #else        #ifdef HAVE_KQUEUE        #include "ae_kqueue.c"        #else        #include "ae_select.c"        #endif    #endif#endif

    Redis中支持多种IO复用,源码中使用相应的宏定义进行选择,编译时就可以获取当前系统支持的最优的IO复用函数来使用,从而实现了Redis的优秀的可移植特性。

    • Redis的任务事件队列

    由于Redis的是单线程处理业务的,因此IO复用程序将读写事件同步的逐一放入队列中,如果当前队列已经满了,那么只能出一个入一个,但是由于Redis正常情况下处理得很快,不太会出现队列满迟迟无法放任务的情况,但是当执行某些阻塞操作时将导致长时间的阻塞,无法处理新任务。

    • Redis事件分派器

    事件的可读写是从服务器角度看的,分派看到的事件类型包括:
    1. AE_READABLE 客户端写数据、关闭连接、新连接到达

    2. AE_WRITEABLE 客户端读数据

    特别地,当一个套接字连接同时可读可写时,服务器会优先处理读事件再处理写事件,也就是读优先。


    • Redis事件处理器

    Redis将文件事件进行归类,编写了多个事件处理器函数,其中包括:
    1. 连接应答处理器:实现新连接的建立

    2. 命令请求处理器:处理客户端的新命令

    3. 命令回复处理器:返回客户端的请求结果

    4. 复制处理器:实现主从服务器的数据复制


    • Redis C/S一次完整的交互

    Redis服务器的主线程处于循环中,此时Client向Redis服务器发起连接请求,假如是6379端口,监听端口在IO复用工具下检测到AE_READABLE事件,并将该事件放入TaskQueue中,等待被处理,事件分派器获取这个读事件,进一步确定是新连接请求,就将该事件交给连接应答处理器建立连接;

    建立连接后Client向服务器发送了一个get命令,仍然被IO复用检测处理放入队列,被事件分派器处理指派给命令请求处理器,调用相应程序进行执行;

    服务器将套接字的AE_WRITEABLE事件与命令回复处理器相关联,当客户端尝试读取结果时产生可写事件,此时服务器端触发命令回复响应,并将数据结果写入套接字,完成之后服务端接触该套接字与命令回复处理器之间的关联;


全部评论