Redis原理

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/13924.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

字节岗位薪酬体系曝光,看完感叹:不服真不行

曾经的互联网是PC的时代&#xff0c;随着智能手机的普及&#xff0c;移动互联网开始飞速崛起。而字节跳动抓住了这波机遇&#xff0c;2015年&#xff0c;字节跳动全面加码短视频&#xff0c;从那以后&#xff0c;抖音成为了字节跳动用户、收入和估值的最大增长引擎。 自从字节…

媒体宣传的优势与重要性

传媒如春雨&#xff0c;润物细无声&#xff0c;大家好&#xff0c;我是51媒体网胡老师。 媒体宣传日益成为企业和品牌宣传推广的重要手段&#xff0c;媒体的宣传报道更有权威性&#xff0c;能够帮助品牌进行背书&#xff0c;更有权威性&#xff0c;另外媒体的报道在搜索引擎中…

智能文案改写工具-智能改写工具免费

智能写作机器人 智能写作机器人&#xff0c;这是一种让人类写作变得更加简单的创新技术。它的出现&#xff0c;为内容生产领域带来了巨大的进步&#xff0c;不仅提高了人们的写作效率&#xff0c;还让优质的内容更容易被产生和共享。现在&#xff0c;让我们来了解一下智能写作…

Windows环境下NVM安装后Node/NPM命令无法使用

问题&#xff1a;Windows环境下安装nvm后&#xff0c;使用nvm安装node&#xff0c;无法使用node相关命令。 解决方案&#xff1a;注意安装的时候有两个路径&#xff0c;第一个是nvm所在的路径&#xff0c;第二个是nodejs所在的路径&#xff0c;大家需要在对应的目录下找到路径…

Python爬虫实战——获取电影影评

Python爬虫实战——获取电影影评 前言第三方库的安装示例代码效果演示结尾 前言 使用Python爬取指定电影的影评&#xff0c; 注意&#xff1a;本文仅用于学习交流&#xff0c;禁止用于盈利或侵权行为。 操作系统&#xff1a;windows10 家庭版 开发环境&#xff1a;Pycharm Co…

Linux嵌入式uboot使用tftp网络启动加载zImage、设备树

文章目录 一、前言二、Linux U-boot 相关命令&#xff08;1&#xff09;help 命令&#xff08;2&#xff09;printenv 命令&#xff08;3&#xff09;setenv 函数&#xff08;4&#xff09;saveenv 函数 三、tftp启动linux内核步骤&#xff08;1&#xff09;进入u-boot模式&…

vue:生成二维码 qrcode、vue-qr(二维码中间可带logo)

一、方法一 qrcode qrcode - npm 1.1、安装 yarn add qrcode 1.2、页面引入 import QRCode from qrcode; 1.3、方法里边使用 getQRCodeUrl(){ QRCode.toDataURL(hello world,{color: {dark:"#010599FF",light:"#FFBF60FF"}}).then((url) > {// 获…

基于Html+Css的图片展示25

准备项目 项目开发工具 Visual Studio Code 1.44.2 版本: 1.44.2 提交: ff915844119ce9485abfe8aa9076ec76b5300ddd 日期: 2020-04-16T16:36:23.138Z Electron: 7.1.11 Chrome: 78.0.3904.130 Node.js: 12.8.1 V8: 7.8.279.23-electron.0 OS: Windows_NT x64 10.0.19044 项目…

Linux+云服务器

目录 前言 一、Linux介绍 二、Linux 环境搭建 2.1 云服务器 2.2 XShell 终端 三、Linux 常用命令 3.1操作目录的命令 3.1.1 ls 【list的缩写】 双击某个目录 3.1.2 pwd 【print working directory的缩写】打印当前所处地址 3.1.3 cd 【change directory的缩写】切…

yolov5训练自己的目标检测模型

yolov5训练自己的目标检测模型 1.克隆项目并配置环境 1.1克隆项目 进入GitHub下载yolov5源码 点此进入 选择分支v5.0&#xff0c;并下载源码 anaconda激活相应环境 activate pytorch进入项目存放的地址 E: cd yolov5-master1.2 yolov5项目结构 ├── data&#xff1a;主…

Java版本工程管理系统软件源码 自主研发,工程行业适用

Java版工程项目管理系统 Spring CloudSpring BootMybatisVueElementUI前后端分离 功能清单如下&#xff1a; 首页 工作台&#xff1a;待办工作、消息通知、预警信息&#xff0c;点击可进入相应的列表 项目进度图表&#xff1a;选择&#xff08;总体或单个&#xff09;项目显示…

Zimbra 远程代码执行漏洞(CVE-2019-9670)漏洞分析

Zimbra 远程代码执行漏洞(CVE-2019-9670)漏洞分析 漏洞简介 Zimbra是著名的开源系统&#xff0c;提供了一套开源协同办公套件包括WebMail&#xff0c;日历&#xff0c;通信录&#xff0c;Web文档管理和创作。一体化地提供了邮件收发、文件共享、协同办公、即时聊天等一系列解决…

集合专题·拔高·壹

文章目录 1 Collection单列集合、Map双列集合1.1 Collection单列集合1.1.1 Collection单列集合及其实现类1.1.1.1 list集合与Array数组1.1.1.1.1 ArrayList1.1.1.1.2 LinekdList1.1.1.1.2 Vector1.1.1.1.2.1 ArrayList、Vector &#xff08;线程安全&#xff09;的区别是什么1.…

【elasticsearch部署】

安装elasticsearch 1.部署单点es1.1.创建网络1.2.加载镜像1.3.运行 2.部署kibana2.1.部署2.2.DevTools 3.安装IK分词器3.1.在线安装ik插件&#xff08;较慢&#xff09;3.2.离线安装ik插件&#xff08;推荐&#xff09;1&#xff09;查看数据卷目录2&#xff09;解压缩分词器安…

基于web的电动车租赁管理系统C#+asp.net+sqlserver

具体功能如下&#xff1a;个人信息管理&#xff1a;实现登陆后对个人信息进行修改和查看的功能。 修改登录密码&#xff1a;实现登陆后对个人密码进行修改的功能。 申请租车订单&#xff1a;客户用户登陆后可以申请租车订单。同时可以查看租赁订单信息。 售后评价管理&#xff…

【PR 基础】设置上下黑白边的两种方法

方法1 点击 文件-》新建-》旧版标题 点击确定 点击矩形工具 利用矩形工具框选出上下黑白边 款选完成后点击关闭 将刚创建的字幕拖入轨道 可以修改其持续时长与视频时长保持一致 如果想要修改字幕可以双击来修改 比如可以将颜色改为黑色 方法2 点击号&#xff0c;再选择安全边…

C语言入门篇——函数篇

目录 1、什么是函数 2、函数的分类 2.1库函数 2.2自定义函数 3、函数的参数 3.1实际参数&#xff08;实参&#xff09; 3.2形式参数&#xff08;形参&#xff09; 4、函数的调用 4.1传值调用 4.2传址调用 5、函数的嵌套调用和链式访问 5.1嵌套调用 5.2链式访问 6、…

【C++】模板

目录 前言 1.函数模板 1.1使用 1.2实现逻辑 1.3实例化 1.4匹配规则 2.类模板 2.1使用 实例化 前言 &#x1f397;️照以前的想法&#xff0c;若我们想实现一个交换函数&#xff0c;需要这样写。 void swap(int& x, int& y) {int tmp x;x y;y tmp; }int …

自动驾驶方案及相关对标

华为&#xff1a; 2021年4月18日&#xff0c;在华为智能汽车解决方案BU新品发布会上&#xff0c;华为智能汽车解决方案BU总裁王军表示&#xff0c;华为要持续加大对汽车行业的投入&#xff0c;今年在研发上的投资将达到10亿美元&#xff0c;未来每年保持30%左右增长&#xff0…

[Netty] Mpsc Queue (十七)

JCTools 是适用于 JVM 并发开发的工具&#xff0c;主要提供了一些 JDK 确实的并发数据结构&#xff0c;例如非阻塞 Map、非阻塞 Queue 等。其中非阻塞队列可以分为四种类型&#xff0c;可以根据不同的场景选择使用。 Spsc 单生产者单消费者Mpsc 多生产者单消费者Spmc 单生产者…