Redis原理

文章详细介绍了Redis中多种数据结构的实现,包括SDS、IntSet、Dict、ZipList、QuickList、SkipList等,以及Redis的内存管理和网络模型,如IO多路复用中的阻塞IO、非阻塞IO、IO多路复用(select、poll、epoll)等。
摘要由CSDN通过智能技术生成

Redis原理

数据结构

动态字符串SDS

Redis中key是字符串,value是字符串或字符串集合。不过redis没有直接使用C语言的字符串。因为C中字符串存在问题:①获取字符串长度需要运算②非二进制安全③不可修改。

//c语言,声明字符串: char* s = "hello" 本质是字符数据:{'h','e','l','l','o','/0'}

Redis构建一种新的字符串结构,简单动态字符串SDS(SimpleDynamicString)。

eg: set name xuy   name和xuy都是SDS,SDS结构体如下
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;  //buf已保存的字符串字节数,不包含结束
    uint8_t alloc;  //buf申请总字节数,不包含结束
    unsigned char flags;  //不同SDS的头类型,用来控制SDS的头大小 #define SDS_TYPE_5(0) _8(1) _16(2) _32(3) _64(4)
    char buf[];
}
sds结构:len:4 | alloc:4 | flags:1 | n a m e \0

扩容:如果新字符串小于1M,则新空间为扩展后字符串长度的2倍+1。如果大于1M,则新空间为扩容后字符串长度+1M+1,称为内存预分配。
优点:①获取字符串长度时间复杂度O(1)②支持动态扩容③减少内存分配次数④二进制安全

IntSet

Redis中set集合基于整数数组一种实现,长度可变(底层扩容算法用参考c),有序(底层用二分查找实现)。

typedef struct intset {
    uint32_t encoding;  //编码方式,支持存放16位(#define INTSET_ENC_INT16(sizeof(int16_t))2字节整数java中short),32位,64位整数
    uint32_t length;  //元素个数
    int8_t contents[];  //整数数组
} inset;
IntSet结构: encoding:INTSET_ENC_INT16(2字节) | length:3(4字节) | 5 10 20(16位2字节*个数3)

为方便查找,Redis将IntSet中整数按照升序保存在contents数组中。
Inset插入元素方法中的的升级:如果插入数字5000超过int16_t的范围,inset会自动升级编码方式。源码:P147

  1. 升级编码方式INSET_ENC_INT32,每个整数占4字节,并按照新的编码方式及元素个数扩容。
  2. 倒序依次将数组中的元素拷贝到扩容后的正确位置。
  3. 将待添加的元素放入数组末尾。
  4. 最后将inset的encoding属性改为INSET_ENC_INT32,将length属性改为4。encoding:INTSET_ENC_INT32(4字节) | length:4(4字节) | 5 10 20 5000(32位4字节*个数3)

Inset是特殊的整数数组,优点:①元素唯一,有序。②具备升级机制,省空间。③底层采用二分查找查询。

Dict

Redis是键值型(Key-Value Pair)数据库。根据键实现快速增改。而键与值映射关系是通过Dict来实现的。
Dict由三部分组成:哈希表(DictHashTable),哈希节点(DictEntry),字典(Dict)

//dict结构
typedef struct dict {
    dictType *type;  //dict类型,内置不同hash函数
    void *privdata;  //私有数据,做特殊hash运算时用
    dictht ht[2];  //一个Dict包含两个哈希表。其中一个是当前进程,另一个是空。rehash时使用。
    long rehashidx;  //rehash的进度。-1表示未进行
    int16_t pauserehash;  //rehash是否暂停,1暂停,0继续
} dict;
//DictHashTable结构
typedef struct dictht {
    dictEntry **table;  //entry数组,数组中保存执行entry的指针
    unsigned long size;  //哈希表大小
    unsigned long sizemask;  //哈希表大小的掩码,总等于size-1
    unsigned long used;  //entry个数
} dictht;
//dictEntry结构
typedef struct dictEntry {
    void *key;  //键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v; //值
    struct dictEntry *next; //下一个Entry指针
} dictEntry;

当我们向Dict添加键值对时,Redis先根据key计算hash值h,然后利用h&sizemask来计算元素应该存储到数组中的那个索引位置。例如存k=v,k哈希值h=1,则1&3=1,因此k=v要存储到数组角标1位置。
《图:字典索引结构图》
《图:字典索引结构图2》

Dict扩容(触发扩容的2种情况):
Dict中HashTable就是数组结合单向链表实现,当集合元素较多,会导致哈希冲突增加,链表过长,查询效率降低。Dict会在每次新增时检查负载因子(LoadFactor=used/size),满足以下情况出发扩容:

  1. 哈希表LoadFactor>=1,并且服务器没有执行BGSAVE或BGREWRITEAOF等后台进程
  2. 哈希表LoadFactor>5
//dict扩容源码:
static int _dictExpandIfNeeded(dict *d){
    if(dictIsRehashing(d)) return DICT_OK;                                              //如果正在rehash,返回ok
    if(d->ht[0].size=0) return dictExpand(d,DICT_HT_INITIAL_SIZE);                      //如果哈希表为空,则初始化哈希表默认为4
    if(d->ht[0].used >= d->ht[0].size &&                                                //当负载因子(used/size)>1并且没有bgrewrite等子进程操作或者负载因子>5,则进行扩容
       (dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)){
        return dictExpand(d,d-ht[0].used+1);                                            //扩容大小为used+1,底层会对扩容大小做判断,实际找第一个大于used+1的2^n
    }
    return DICT_OK;
}

Dict收缩:
Dict除了扩容,每次删除元素时,也会对负载因为做检查,当loadFactor<0.1时,会做哈希表的收缩。
附:源码见t_hash.c>删除检查是否重置dict字典hashTypeDeleted() server.c>检查函数htNeedsResize(dict *dict) 重置dict函数int dictResize(dict *d)底层还是调用dictExpand函数

Dict的渐进式rehash (动态图:P149 19:00):
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新表。

  1. 计算新的hash表realSize,值取决于扩容还是收缩:扩:新size为第一个>=dict/ht[0].used+1的2n。缩:新size为第一个>=dict.ht[0].used的2n(不<4)
  2. 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
  3. 设置dict.rehashidx=0,标示开始rehash
  4. 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
  5. 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存。

问题:如果dict包含百万entry,要在一次rehash中完成,极有可能导致主线程阻塞。
解决:rehash采用多次,渐进式完成,成为渐进式rehash。具体在上述第④部:

  1. 每次执行新增,查询,修改,删除操作时,都检查dict.rehashidx是否大于-1,是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]
  2. 将rehashidx赋值-1,代表rehash结束
  3. 在rehash过程中,新增链表,则直接写入ht[1],查询,修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可确保ht[0]的数据只减不增,随着rehash最终为空。

总结:
Dict结构:

  • 类似java的hashTable,底层数组+链表解决hash冲突 *Dict包含两个hash表,ht[0]平常用,ht[1]用来rehash

Dict伸缩:

  • 当LoadFactor>5或>1且没有子进程时,Dict扩容,扩容大小>=used+1的2^n
  • 当LoadFactor<0.1,收缩,收缩为>=used的2^n
  • Dict采用渐进式hash,每次访问Dict时执行一次rehash
  • rehash的ht[0]只减不增,新增操作只在ht[1]执行,其他操作在两个哈希表。
ZipList

压缩列表:ZipList是一种特殊的双端链表,由一系列特殊编码的<连续内存块>组成。可在任意一端进行压入/弹出操作。时间复杂度O(1)。
zlbytes(压缩列表的总字节数) | zltail(尾节点偏移量) | zllen(entry总结点数) | entry(head) | entry | ... | endtry(tail) | zlend(结尾标示:0xff)

  • zlbytes:uint32_t 4字节 记录整个压缩列表占用的内存字节数
  • zltail: uint32_t 4字节 记录压缩列表尾节点到起始地址字节数,可以确定尾节点的地址。
  • zllen: uint16_t 2字节 记录压缩列表包含的节点数量。最大UINT16_MAX(65534),如超过会记录为65535,但真是数量需遍历列表后计算得出。
  • enry: 列表节点 ~ 压缩列表包含的节点数。长度由保存内容决定。
  • zlend uint8_t 1字节 特殊0xFF(十进制255),表示压缩列表的末端。

ZipListEntry:每一个Entry中包含的结构。ZipList中的Entry不用普通链表中前后节点指针,因为两个指针16字节费内存,而是采用下面结构
previous_entry_length | encoding | content

  • previous_entry_length:前一节点长度,占1(<254)或5字节(>254,第一个字节为0xFE,后四个字节才是真实长度数据)
  • encoding:编码属性,记录content数据类型(字符or整数)及长度,占1,2或5字节。
  • contents:记录保存节点的数据,可以是字符或整数。

注意:ZipList存储采用小端字节序,即低位字节在前,高位字节在后。例如:0x1234小段字节序后实际存储为:0x3412
Encoding编码:ZipList中的Encoding编码分为字符串和整数两种:

  • 字符串:如果encoding以"00","01"或"10"开头,则content是字符串。例如:保存ab。00000000(previous entry length) 00000010(encoding) 01100001(content) -> 0x00|0x02|0x61|0x62(4Bytes)
  • 整数:"11"开头,占1字节。具体参考P150 29:00

ZipList的连锁更新问题:ZipList的每个Entry都包含previous_entry_lenght来记录上一个节点的大小,长度是1个或者5个字节。

  • 如果前一节点长<254字节,则采用1字节来保存这个长度
  • 如果前一节点长>=254字节,则采用5字节来保存这个长度值,第一字节为0xFE,后四字节才是真实长度数据。

问题:假设由N个连续的长为250-253字节的entry,插入一个254字节的节点。
《图:ZipLis连续更新》
ZipList这种特殊情况下产生的连续多次空间扩展称为连锁更新(Cascade Update)。新增,删除都可能导致连锁更新发生。

总结:

  1. 压缩列表可看作连续存储的”双向链表“
  2. 列表节点不是通过指针链接,而采用记录上一节点和本节点长度来寻址,占用内存低。
  3. 如果列表数据过多,导致链表长,影响查询。
  4. 增删较大数据可能会发生连续更新问题。
QuickList

问题:ZipList虽然省内存,但必须连续,如果占用较多,则申请效率低,怎么办? -> 限制ZipList长度和entry大小。
问题:但存储大量数据,超出了ZipList最佳的上限怎么办? -> 创建多个ZipList来分片存储数据。
问题:数据拆分后很分散,不方便管理和查找,多个ZipList如何建立联系? -> Redis引入QuickList,双端链表,每个节点都是Y一个ZipList。
为避免QuickList中ZipList的entry过多,可修改list-max-ziplist-size来限制。

  • 如果值为+,则代表ZipList的允许的entry个数最大值。
  • 如果值为-,则代表ZipList的最大内存大小:-1-每个ZipList的内存占用不超过4kb。-2-8kb。-3-16kb。-4-32kb。-5-64kb。默认为-2.

除了控制ZipList大小,Quick还可以设置list-compress-depth对节点的ZipList做压缩。因为链表一般都是从首尾访问较多,所以首尾不压缩。0:不压缩。1:首尾1个节点不压缩,中间压缩。2:首尾2个节点不压缩。

typedef struct quickList {
    quickListNode *head;  //头节点
    quickListNode *tail;  //尾节点
    unsigned long count;  //所有Ziplist的entry数量
    unsigned long len;  //ziplist总数量
    int fill : QL_FILL_BITS;  //ziplist的entry上限,默认-2
    unsigned int compress : QL_COMP_BITS;  //首尾不压缩的节点数量
    unsigned int bookmark_count : QL_BM_BITS;  //内存重分配时的书签数量及数组,一般用不到。
    quickListBookmark bookmarks[];
} quickList;
typedef struct quickListNode {
    struct quickListNode *prev;  //前一个节点指针
    struct quickListNode *next;  //后一个节点指针
    unsigned char *zl;  //当前节点的ziplist指针
    unsigned int sz;  //当前节点的ziplist的字节大小
    unsigned int count : 16;  //当前节点的ziplist的entry个数
    unsigned int encoding : 2;  //编码方式:1,Ziplist。2,lzf压缩模式。
    unsigned int container : 2;  //数据容器类型(预留):1.其他。2.ziplist
    unsigned int recompress : 1;  //是否被解压缩:1.被解压缩,将来要重新压缩
    unsigned int attempted_compress : 1;  //测试用
    unsigned int extra : 10;  //预留
} quickListNode;

《图:QuickList结构》

总结:

  • 是一个节点为ZipList的双端链表,节点采用ZipList,解决传统链表内存占用过大。
  • 控制ZipList大小,解决连续内存空间申请效率问题。
  • 中间节点可以压缩,进一步节省空间。
SkipList

SkipList(跳表)首先时链表。与传统链表的差异:*元素按照升序排列存储。*节点包含多个指针,指针跨度不一样。

//t_zset.h
typedef struct zskiplist {
    struct zskiplistNode *header,*tail;  //头尾节点指针
    unsigned long length;  //节点数量
    int level;  //最大所引层级,默认1
} zskiplist;
//t.zset.h
typedef struct zskiplistNode {
    sds ele;  //节点存储值
    double score;  //节点分数,排序,查找用
    struct zskiplistNode *backward;  /前一个节点指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  //下一个节点指针
        unsigned long span;  //索引跨度   注:跨度表示某一层级之间跨第一层级的个数
    } level[];  //多级索引数组     注:不同节点的层级不一样,所以用数组
} zskiplistNode;

《图:跳表数据结构图》

总结:

  • 跳表是双向链表,每个节点包含score和ele值。
  • 节点按照score值排序,score值一样则按照ele字典排序。
  • 每个节点都可以包含多层指针,层数是1-32之间的随机数
  • 不同指针到下一个节点的跨度不同,层级越高,跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单
RedisObject

Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象。

typedef struct redisObject {
    unsigned type:4;  //对象类型,分别是string,list, hash,set,zset,#define OBJ_STRING 0 (1)
    unsigned encoding:4;  //底层编码方式,共有11种,占4bit
    unsigned lru:LRU_BITS;  //LRU_BITS为24  记录当前对象最后一次被访问的时间,占用24bit,用于判断空闲时间太久的key
    int refcount;  //对象引用计数器,为0则说明对象无人引用,可被回收。
    void *ptr  //指针指向存放实际数据的空间
} robj;

注意:建议不要多用String类型,因为会有都会有对象头造成空间浪费
Redis的编码方式:Redis会根据不同的数据类型,选择不同的编码方式,共11种。详见:P154
五种数据类型:

  1. OBJ_STRING int
  2. OBJ_LIST LinkedList和ZipList(3.2前),QuickList(3.2后)
  3. OBJ_SET insert,HT
  4. OBJ_ZSET ZipList,HT,SkipList
  5. OBJ_HASH ZipList,HT
五种数据结构
  1. String:最常见的数据类型,有3种编码格式。
  • 基本的编码方式RAW,基于简单动态字符串SDS实现,存储上限512mb。
  • SDS长<44字节,则采用EMBSTR编码,此时Object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率高。
  • 存储整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(8字节),不需要SDS。
    《图:String类型存储结构》
  1. List:list类型可以从首(LPUSH,LPOP),尾(RPUSH,RPOP)操作列表中的元素。
  • 为什么不用LinkedList:普通链表可从双端访问,需维护前后指针,占用内存,且内存碎片多。3.2版本前,元素数量<512且元素大小<64
  • 为什么不用ZipList:压缩列表可从双端访问,连续内存,内存占用低,存储上限低。3.2版本前,同上反之。
  • 为什么要用QuickList:LinekedList + ZipList,可双端访问,内存占用低,包含多个ZipList,存储上限高。3.2版本后。
void pushGnericCommand(Client *c, int where ,int xx){
    int j;
    robj *lobj = lookupKeyWrite(c -> db, c-> argv[1]);  //尝试找到KEY对应的List
    if(checkType(c,lobj,OBJ_LIST)) return;  //检查类型是否正确
    if(!lobj){  //为空?
        if(xx){
            addReply(cm shared.czero);
            return;
        }
        lobj = createQuicklistObject();  //为空则创建新的Quicklist
        quicklistSerOptions(lobj -> ptr, server, list_max_ziplist_size, server.list_compress_depth);
        dbAdd(c- > db, c -> argv[1], lobj);
    }
}
robj *createQuicklistObject(void) {
    quicklist *l = quicklistCreate();  //申请内存并初始化QuickList
    robj *o = createObject(OBJ_LIST, l);  //创建RedisObject,type为OBJ_LIST  //ptr指向QuickList
    o -> encoding = OBJ_ENCODING_QUICKLIST;  //设置编码为QuickList
    return o;
}

《图:list类型内存存储结构》
注意:源码没看懂,再看
3. Set:单列集合。特点:*不保证有序。*保证元素唯一(判断元素存在SISMEMBER s1 m1)。*求交集(SINTER s1 s2),并集,差集

  • 底层采用HashTable,也就是Dict,不过Dict是双列集合。为了查询效率和唯一,set采用HT编码(Dict)。Dict种key用来存储元素,value统一为null;
  • 当存储所有元素为整数,并且不超过set-max-intset-entries时,Set采用IntSet编码,以节省内存。
robj *setTypeCreate(sds value){
    if(isSdsRepresentableAsLongLong(value,NULL) == C_OK)  //判断value是否是数值类型 long long
        return createIntsetObject();  //如果是数值类型,则采用IntSet编码
    return createSetObject();  //否则采用默认编码,HT
}
robj *createIntsetObject(void) {
    intset *is = intsetNew();  //初始化INTSET并申请内存空间
    robj *o = createObject(OBJ_SET,is);  //创建RedisObject
    0 -> encoding = OBJ_ENCODING_INTSET;  //指定编码为INTSET
    return o;
}
robj *createSetObject(void) {
    dict *d = dictCreate(&setDictType, NULL);  //初始化Dict类型,并申请内存
    robj *o = createObject(OBJ_SET,d);  //创建RedisObject
    o->encoding = OBJ_ENCODING_HT;   //设置encoding为HT
    return o;
}

《图:set类型内存存储结构》
4. Z4Set:也就是SortedSet,其中每个元素都需要指定一个score和member。

  • 可根据score值排序 (ZADD z1 10 m1 20 m2 30 m3)
  • member必须唯一
  • 可根据member查询分数(ZSCORE z1 m1)

因此zset底层数据结构必须慢则键值存储,键必须唯一,可排序,所以采用SkipList + HT。

  • SkipList:可排序,并且同时存储score和ele值
  • HT(Dict):可键值存储,并且根据key找value
typedef struct zset {  //zset结构
    dict *dict;  //Dict指针
    zskiplist *zsl;  //SkipList指针
} zset;
robj *createZsetObject(void) {
    zset *zs = zmalloc(sizeof(*zs));
    robj *o;
    zs->dict = dictCreate(&zsetDictType, NULL);  //创建Dict
    zs->zsl = zslCreate();  //创建Skiplist
    o = createObject(OBJ_ZSET,zs);
    o->encoding = OBJ_ENCODING_SKIPLIST;
    return o;
}

《图:zset类型内存存储结构》

当元素数量不多时,HT和SkipList优势不明显,耗内存,因此zset还会采用ZipList结构来省内存,不过需满足:

  • 元素数量小于zset_max_ziplist_entries,默认128
  • 每个元素都小于zset_max_ziplist_value字节,默认64
zobj = lookupKeyWrite(c -> db,key);  //zadd添加元素时,先根据key找到zset,不存在则创建新的zset
if(checkType(c,zobj,OBJ_ZSET)) goto cleanup;
if(zobj == NULL){  //判断是否存在,不存在:
    if(server.zset_max_ziplist_entries == 0 || server.zset_max_ziplist_value < sdslen(c -> argv[scoreid + 1] -> ptr)){ //设置为0就是禁用Ziplist,或者value大小超过了maxvalue,采用HT+SkipList
        zobj = createZsetObject();
    } else {
        zobj = createZsetZiplistObject();  //否则,采用Ziplist
    }
    dbAdd(c -> db,key,zobj);
}
dbAdd(c -> db,key,zobj);

ziplist本身没有排序功能,而且没有键值对概念,因此需要有zset通过编码实现:

  • ZipList是连续存储,因此score和element是紧挨在一起的两个entry,element在前,score在后
  • socre越小越接近队首,score越大越接近队尾,按照score值升序。
    《图:优化后ZSet底层内存结构图》
  1. Hash:和Zset非常相似,都是键值存储(hset user:1 name jack age 21),都需要根据键获取值(HGET user:1 name),键唯一
    和ZSet的区别:zset键member值score;hash键值是任意值。zset根据score排序;hash无排序
    因此,Hash底层采用的编码与Zset一致,只需要把排序有关的ShipList去掉即可。
  • Hash结构默认采用ZipList编码,其中相邻的两个entry分别保存field和value
  • 当数据量较大,Hash编码会转为HT编码,也就是Dict,条件:1.Ziplist元素超过hash-max-ziplist-entries(默认512字节)2.Ziplist任意entry大小超过hash-max-ziplist-value(默认64字节)
    《图:Hash类型内存存储结构》
void hsetommand(client *c) {  //hset user1 name Jack age 21
    int i, created = 0;
    robj *o;
    if((o = hashTypeLookupWriteOrCreate(c,c->argv[1])) == NULL) return;  //判断key是都存在,不存在创建,默认用ZipList编码
    hashTypeTryConversion(o,c->argv,2,c->argc-1);  //判断是都需要把ZipList转为Dict
    for(i = 2; i < argc; i += 2){  //循环遍历每一对field和value,并执行hset命令
        created += !hashTypeSet(o,c->argv[i] -> ptr,c->argv[i+1]->ptr,HASH_SET_COPY);
    }
}

网络模型

用户空间和内核空间

服务器大多采用Linux系统,应用都需要通过Linux内核与硬件交互。为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:

  • 进程的寻址空间划分位2个部分:内核空间,用户空间
  • 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问。
  • 内核空间可以执行特权命令(Ring0),调用一切系统资源。

Linux为了提供IO效率,会在用户空间和内核空间都加入缓冲区:

  • 写数据,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
  • 读数据,要从设备读数据到内核缓冲区,然后拷贝到用户缓冲区

主要有2个影响IO效率的地方,从而提出5种IO模型:

  1. 在空间间的数据等待
  2. 两个空间之间的buffer种数据拷贝
    /用户空间/
    用户缓冲区
    /用户空间/
    |1.等待数据就绪 |2读取数据
    /内核空间/
    内核缓冲区
    /内核空间/

在《UNIX网络编程》种,总结了5种IO模型:阻塞IO,非阻塞IO,IO多路复用,信号驱动IO,异步IO。

阻塞IO

顾名思义,阻塞IO两个阶段都必须阻塞等待。
《图:阻塞IO等待数据》

非阻塞IO

顾名思义,非阻塞IO的recvFrom操作会立即返回结果而不是阻塞用户进程。但是会一直询问内核态数据有没有拷贝完成。花里胡哨,没啥暖用。
《图:非阻塞IO等待数据》
用户进程在第一阶段是非阻塞,第二阶段是阻塞状态,虽然是非阻塞,但性能没有提升,返回增加询问机制,导致CPU空转。得结合IO多路复用技术才有用。

IO多路复用

前两种IO用户应用在第一阶段都调用recvfrom来获取数据,差别在无数据时的处理方案。此时服务端处理客户端Socket请求时,在单线程下,只能依次处理一个socket,如果正在处理的socket切好未就绪(数据不可读或不可写),线程就会被阻塞,所有客户端socket都必须等待。性能很差。

解决:

  1. 使用多线程(开销大,cpu切换多)
  2. 所有socket不等待,而是监听所有socket,谁数据就绪就处理谁。

现在的问题:用户进程如何知道内核中的数据谁就绪了?

文件描述符(File Descriptor):简称FD,从0开始递增的<无符号整数>,用来关联Linux中的一个文件,在linux种一切皆文件,例如常规文件,视频,硬件设备等,也包括网络套接字(Socket)

IO多路复用:利用单线程同时监听多个FD,并在某个FD可读可写时得到通知,从而避免无效的等待,充分利用CPU资源。监听FD的方式,通知又分为:Select、poll、epoll。

差异:

  • select和poll只会通知用户进程有FD就绪,但不确定是哪个FD,需要用户进程逐个遍历FD来确认。
  • epoll则会在通知用户进程FD就绪同时,把已就绪的FD写入用户空间。
    《图:IO多路复用select》
1. IO多路复用-select
typedef long int __fd_mask;  //定义类型别名 __fd_amsk
typedef struct {  //fd_set 记录要监听的fd集合,及其对应状态
    __fd_mask fds_nits[__FD_SETSIZE / __NFDNITS];  //fds_bits是long类型数组。长为1024/32 = 32.共1024个bit位,每个bit代表一个fd,0代表未就绪,1代表就绪
} fd_set;
int select (  //select函数,用于监听多个fd的集合
    int nfds,  //要监视的fd_set的最大fd + 1
    fd_set *readfds,  //要监听的读事件的fd集合
    fd_set *writefds,  //要监听的写事件的fd集合
    fd_set *exceptfds,   //要监听异常事件的fd集合
    struct timeval *timeout  //超时时间,null-永不超时;0-不阻塞等待;大于0-固定时间等待。
);

select执行流程:
用户空间:
1.1 创建fd_set readfds //long int 类型数组 共1024个bit位,类型bitmap,0代表未就绪,1代表被监听标记
1.2 假如要监听fd=1,2,5
1.3 执行select(5+1.rdfs,null,null,3) //将rfds拷贝进内核空间
2.4 遍历fd_set,找到就绪的fd,读取其中数据 //遍历更新就绪的bit位置
内核空间:
2.1 遍历fd_set //遍历查找需要监听的bit位
2.2 没有就绪,则休眠
2.3 等待数据就绪被唤醒或超时 //将就绪的rfds的bit位更新原rfds,更新未就绪的bit位为0已就绪的bit位为1,重新拷贝回用户空间
总结:select模式存在的问题:

  • 需要将整个fd_set从用户空间拷贝进内核空间,select结束还要再次拷贝回用户空间。
  • select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  • fd_set监听的fd数量不能超过1024
2. IO多路复用-poll
//pollfd中的事件类型
#define POLLIN  //可读事件
#define POLLOUT  //可写事件
#define POLLERR  //错误事件
#define POLLNVAL  //fd未打开
struct pollfd {  //pollfd结构
    int fd;     //要监听的fd
    short int events;  //要监听的事件类型:读,写,异常
    short int revents;  //实际发生的事件类型
};
int poll(  //poll函数:同select函数,监听多个fd集合,判断就绪
    struct pollfd *fda;  //pollfd数组,可以自定义大小
    nfds_t nfda;  //数组元素个数
    int timeout;  //超时时间
);

IO流程:
① 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
② 调用poll函数,将pollfd数组拷贝进内核空间,转链表存储,无上限
③ 内核遍历fd,判断是否就绪
④ 数组就绪或超时后,拷贝pollfd数组回用户空间,返回就绪fd数量n
⑤ 用户进程判断n是否大于0
⑥ 大于0则遍历pollfd数组,找到就绪的fd

结论:与select对比:

  • select模式中的fd_set大小固定1024,而pollfd在内核中采用链表,理论无上限
  • 监听fd越多,每次遍历耗时越久,性能反而下降
3. IO多路复用-epoll

epoll是对select和poll的改进,提供了三个函数

struct eventpoll {
    struct re_root rbr;  //一颗红黑树,记录要监听的fd
    struct list_head relist;  //一个链表,记录就绪fd
};
int epoll_create(int size);  //1.会在内核创建eventpoll结构体,返回对应的句柄epfd
int epoll_ctl(  //2.将一个fd添加到epoll的红黑树中,并设置ep_poll_callback,callback触发时,就把对应的fd加入到rdlist这个就绪列表中
    int epfd,  //epoll实例的句柄
    int op,  //要执行的操作,包括ADD,MOD,DEL
    int fd,  //要监听的fd
    struct epoll_event *event  //要监听的事件类型:读,写,异常等
)
int epoll_wait(  //3.检查rdlist列表是否为空,不为空则返回就绪的fd数量
    int epfd,  //eventpoll实例的句柄
    struct epoll_event *events,  //空event数组,用于接收就绪的fd
    itn maxevents,  //events数组的最大长度
    int timeout  //超时时间,-1永不超时,0不阻塞,大于0为阻塞时间
)

《图:epoll模式结构图》

总结:

  • 基于epoll实例中的红黑树保存要监听的fd,接近无上限,而且增删改查效率都非常高,性能不会随监听的fd数量增加而下降。
  • 每个fd只需要执行一次epoll_ctl添加到红黑树,以后每次epoll_wait无需传递任何参数,无需重复拷贝fd到内核空间。
  • 内核会将就绪的fd直接拷贝回用户空间的指定位置,用户进程无需遍历所有fd就能知道就绪的fd是谁。
4. IO多路复用-事件通知机制

当fd有数据可读时,我们调用epoll_wait就可以得到通知,但是事件通知的模式有两种:

  • LevelTriggered:LT,当DF有数据可读,会重复通知多次,直至数据读取完成,epoll默认模式。
  • EdgeTriggered:ET,当FD有数据可读,只会通知一次,不管数据是否处理完成。

LT举例:假设有客户端socket对应FD已注册到epoll实例->socket发送2KB数据=> (服务端调用epoll_wait,得到通知FD就绪->服务端从FD读取1KB数据->回到服务端调用epoll_wait继续读取形成循环)。
问题:

  • ET、LT:调用epoll_wait会将就绪列表清空,导致第二次获取为空。
  • LT:1.多次通知导致效率下降。2.有惊群效应:有很多的进程在监听FD就绪队列,都在调用epoll_wait尝试去获取就绪的FD,1个就绪,所有的进程都去获取。

解决:LT:系统自动调用epoll_ctl把未读取的重新添加回队列 ET:1.需要手动添加(又回到LT)2.读取所有数据循环从FD读取数据(服务端从FD读取2KB数据)注意用非阻塞IO读取,阻塞为空时会死等。

结论:ET避免了LT的惊群效应,ET最好结合非阻塞IO读取FD数据,相比LT会复杂。

5. IO多路复用-web服务流程

基于epoll模式的web服务的基本流程图:
《图:基于epoll模式的web服务的基本流程图》

信号驱动IO(一阶段不阻塞,二阶段阻塞)

《图:信号驱动IO》
与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其他业务,无需阻塞等待。

缺点: 当有大量的IO操作,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出。而且内核空间与用户空间的频繁信号交互性能低。

异步IO(真正的双阶段非阻塞)

整个过程都是非阻塞,用户进程调用异步API后就返回,内核等待数据就绪并拷贝到用户空间后才会递交信号。通知用户进程。
《图:异步IO》

缺点:高并发时内核全在IO操作,导致挤压。

同步和异步

IO操作是同步还是异步,主要看数据在内核空间与用户空间的拷贝过程(读写数据的IO),也就是二阶段的同步还是异步。
《图:两阶段同步与异步》

Redis网络模型

Redis4.0引入多线程异步处理耗时较长操作(删除unlink,同步)Redis6.0在核心网络模型中引入多线程,进一步提高多核CPU的利用率。
Redis为什么是单线程?

  • Redis是内存性数据库。性能瓶颈是网络延迟而非执行速度,因此多线程并不能带来巨大的性能提升。
  • 多线程会导致频繁上下文切换,必然引入线程锁
  • 多线程有安全问题,复杂度增加,性能反而降低

单线程下的Redis网络模型:Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并将这些实现封装,提供统一高性能API库AE(如:ae_epoll.c).
P171Redis实现epoll源码。

serverSocket->aeEventLoop->beforeSleep->aeApiPoll
《图:Redis网络模型》
Redis6.0为了提高IO读写效率,因此在解析客户端命令,写相响应结果时采用多线程。核心的命令执行,IO多路复用模块依然是由主线程执行。

通信协议

RESP协议

Redis是CS架构的应用,通信一般分为2步(不包括pipline和PubSub):

  1. 客户端想服务端发送一条命令
  2. 服务端解析并执行命令,返回响应结果给客户端

因此客户端发送命令的格式,服务端响应结果的格式必须有一个规范,这个规范就是通信协议。Redis使用的RESP协议,到6.0升级到RESP3(增加客户端缓存)

RESP中,通过首字节的字符来区分不同数据类型,常用的数据类型包括5种:

  • 单行字符串:首字节是‘+’,后面跟上单行字符串,以CRLF(“\r\n”)结尾,例如返回“OK”:“+OK\r\n”
  • 错误:首字节是‘-’,与单行字符串格式一样,只是字符串是异常信息,例如:“-Error message\r\n”
  • 数值:首字节是’:',后面跟上数字格式的字符串,以CRLF结尾。例如:“10\r\n”
  • 多行字符串:首字节是’$',表示二进制安全的字符串,最大支持512MB
  • 数组:首字节是’*',后面跟上数组元素个数,再跟上元素,元素数据类型不限
模拟Redis客户端

见util包下RESP.java

内存策略

内存回收

Redis性能强主要是因为基于内存存储。单节点内存不宜多大,会影响持久化或主从同步性能。可修改maxmemory: 1gb。

过期策略

可通过expire命令给Redis设置TTL。
DB结构:Redis是k-v内存数据库,均保存在Dict结构中,但是有两盒Dict,一个用来保存k-v,一个用来记录key-TTL。

typedef struct redisDb {
    dict *dict;                     //(dict1)存放所有k-v,被称为keyspace
    dict *expires;                  //(dict2)存放每一个key及其对应TTL存货时间,只包含设置了TTL的key。
    dict *blocking_keys;            //key with clients waiting for data (BLPOP)
    dict *ready_keys;               //blocked key that received a push
    dict *watched_keys;             //watched key for multi/exec cas
    int id;
    long long avg_ttl;              //记录平均TTL时长
    unsigned long expires_cursor;   //expire检查时在dict中抽样的索引位置
    list *defrag_later;             //等待碎片整理的key列表
} redisDb;

《图:RedisDB结构》
问题:1.Redis如何知道一个key是否过期?2.是不是TTL到期立即删除?

  1. 利用两个dict分别记录k-v对及key-ttl对
  2. 因为需要扫key,耗时较大,所以采用惰性删除和周期删除

惰性删除:在访问一个key时检查存活时间,过期则删除。缺点:一直没人访问则永远不会被删除
周期删除:通过定时任务,周期抽样部分过期key执行删除:1.设置定时任务serverCron(),按照server.hz频率来执行清理,模式为SLOW。2.每个事件循环前调用beforeSleep()函数,执行清理,模式为FAST.

int serverCron(struct aeEventLoop *eventLoop, Long long id, void *clientData) {
    //更新lrulock到当前时间,为后期的LRU和LFU做准备
    unsigned int lrulock = getLRUClock();
    atomicSet(server.lrulock,lrulock);
    //执行database的数据清理,例如过期key处理
    databasesCron(); //尝试清理key,模式为SLOW
    return 1000/server.hz;
}
void aeMain(aeEventLoop *eventLoop) {
    eventLoop -> stop = 0;
    while(!eventLoop -> stop) {
        //beforeSleep() --> Fast清理模式
        // n == aeApiPoll()
        //如果n > 0,FD就绪,处理IO事件
        //如果到了执行时间,则调用serverCron() --> SLOW
    }
}

SLOW规则:主要:频率默认10,每次不超过25ms

  1. 执行频率受server.hz影响,默认10,即每秒执行10次,每个执行周期100ms
  2. 执行清理耗时不超多一次执行周期的25%
  3. 逐个遍历db,和db中的bucket,抽取20个key判断是否过期。
  4. 如果没有达到时间上限25ms,并且过期key比例大于10%,再进行一次抽象,否则结束。

FAST规则:主要:频率不固定,两次间隔不低于2ms,每次不超过1ms

  1. 执行频率受beforeSleep调用影响,但两次FAST模式间隔不低于2ms
  2. 执行清理耗时不超1ms
  3. 逐个遍历db,逐个遍历db中的bucket,抽取2.个key判断是否过期。
  4. 如果没有达到时间上限1ms,并且过期key比例大于10%,再进行一次抽样,斗则结束。
淘汰策略

内存淘汰:当Redis内存达到设置阈值,主动挑选部分key进行删除释放内存。通过执行processCommand()中尝试内存淘汰:

/***redis在执行任务命令之前都会检查内存***/
int processCommand(client *c) {
    //如果服务器设置了server.maxmemory属性,并且并未执行lua脚本
    if(server.maxmemory && !server.lua_timeout) {
        //尝试进行内存淘汰performEvictions
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        
        if(out_of_memory && reject_cmd_on_oom) {
            rejectCommand(c, shared.oomerr);
            return C_OK;
        }
    }
}

《图:performEvictions算法流程图》
Reids支持8种不同的策略来删除key:

  • noeviction:不淘汰任务key,内存满时不允许写入,默认
  • volatile-ttl:对设置TTL的key,比较剩余TTL,小的先淘汰
  • allkeys-random:全体key随机淘汰。db中dict随机
  • volatile-random:对设置了TTL的key随机淘汰
  • allkeys-lru:全体key基于LRU算法淘汰
  • volatile-lru:对设置了TTL的key基于LRU算法淘汰
  • allkeys-lfu:全体key基于LFU算法淘汰
  • volatile—lfu:对设置了TTL的key基于LFU算法淘汰

LRU:最少最近使用,用当前时间减去最后一次访问时间,值越大先淘汰。
LFU:最小频率使用,统计每个key访问频率。值越小先淘汰。
Redis的数据都会被封装为RedisObject结构:

typedef struct redisObject {
    unsigned type : 4;      //对象类型
    unsigned encoding:4;    //编码方式
    unsigned lru:LRU_BITS;  //LRU:以秒为单位记录最近一次访问,长24bit。LFU:高16位以分钟位单位记录最近一次访问时间,低8位记录逻辑(算法)访问次数。
    int refcount;           //引用指针,计数位0则可回收
    void *ptr;              //数据指针,指向真实数据
} robj;

LFU的访问次数为逻辑访问次数,并不是每次key访问都会被计数,而是通过运算:

  1. 生成0~1的随机数R
  2. 计算1/(旧次数*lfu_log_factor+1),记录为P,lfu_log__factor默认为10
  3. 如果R < P,则计数器+1,且最大不超过255
  4. 访问次数会随时间衰减,距离上一次访问时间间隔lfu_decay_time分钟,计数器-1
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值