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
- 升级编码方式INSET_ENC_INT32,每个整数占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位置。
Dict扩容(触发扩容的2种情况):
Dict中HashTable就是数组结合单向链表实现,当集合元素较多,会导致哈希冲突增加,链表过长,查询效率降低。Dict会在每次新增时检查负载因子(LoadFactor=used/size),满足以下情况出发扩容:
- 哈希表LoadFactor>=1,并且服务器没有执行BGSAVE或BGREWRITEAOF等后台进程
- 哈希表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重新计算索引,插入新表。
- 计算新的hash表realSize,值取决于扩容还是收缩:扩:新size为第一个>=dict/ht[0].used+1的2n。缩:新size为第一个>=dict.ht[0].used的2n(不<4)
- 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
- 设置dict.rehashidx=0,标示开始rehash
- 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
- 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存。
问题:如果dict包含百万entry,要在一次rehash中完成,极有可能导致主线程阻塞。
解决:rehash采用多次,渐进式完成,成为渐进式rehash。具体在上述第④部:
- 每次执行新增,查询,修改,删除操作时,都检查dict.rehashidx是否大于-1,是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]
- 将rehashidx赋值-1,代表rehash结束
- 在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字节的节点。
ZipList这种特殊情况下产生的连续多次空间扩展称为连锁更新(Cascade Update)。新增,删除都可能导致连锁更新发生。
总结:
- 压缩列表可看作连续存储的”双向链表“
- 列表节点不是通过指针链接,而采用记录上一节点和本节点长度来寻址,占用内存低。
- 如果列表数据过多,导致链表长,影响查询。
- 增删较大数据可能会发生连续更新问题。
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;
总结:
- 是一个节点为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
五种数据类型:
- OBJ_STRING int
- OBJ_LIST LinkedList和ZipList(3.2前),QuickList(3.2后)
- OBJ_SET insert,HT
- OBJ_ZSET ZipList,HT,SkipList
- OBJ_HASH ZipList,HT
五种数据结构
- String:最常见的数据类型,有3种编码格式。
- 基本的编码方式RAW,基于简单动态字符串SDS实现,存储上限512mb。
- SDS长<44字节,则采用EMBSTR编码,此时Object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率高。
- 存储整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(8字节),不需要SDS。
- 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;
}
注意:源码没看懂,再看
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;
}
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;
}
当元素数量不多时,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底层内存结构图》
- 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字节)
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模型:
- 在空间间的数据等待
- 两个空间之间的buffer种数据拷贝
/用户空间/
用户缓冲区
/用户空间/
|1.等待数据就绪 |2读取数据
/内核空间/
内核缓冲区
/内核空间/
在《UNIX网络编程》种,总结了5种IO模型:阻塞IO,非阻塞IO,IO多路复用,信号驱动IO,异步IO。
阻塞IO
顾名思义,阻塞IO两个阶段都必须阻塞等待。
非阻塞IO
顾名思义,非阻塞IO的recvFrom操作会立即返回结果而不是阻塞用户进程。但是会一直询问内核态数据有没有拷贝完成。花里胡哨,没啥暖用。
用户进程在第一阶段是非阻塞,第二阶段是阻塞状态,虽然是非阻塞,但性能没有提升,返回增加询问机制,导致CPU空转。得结合IO多路复用技术才有用。
IO多路复用
前两种IO用户应用在第一阶段都调用recvfrom来获取数据,差别在无数据时的处理方案。此时服务端处理客户端Socket请求时,在单线程下,只能依次处理一个socket,如果正在处理的socket切好未就绪(数据不可读或不可写),线程就会被阻塞,所有客户端socket都必须等待。性能很差。
解决:
- 使用多线程(开销大,cpu切换多)
- 所有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写入用户空间。
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实例中的红黑树保存要监听的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服务的基本流程图:
信号驱动IO(一阶段不阻塞,二阶段阻塞)
与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其他业务,无需阻塞等待。
缺点: 当有大量的IO操作,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出。而且内核空间与用户空间的频繁信号交互性能低。
异步IO(真正的双阶段非阻塞)
整个过程都是非阻塞,用户进程调用异步API后就返回,内核等待数据就绪并拷贝到用户空间后才会递交信号。通知用户进程。
缺点:高并发时内核全在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
Redis6.0为了提高IO读写效率,因此在解析客户端命令,写相响应结果时采用多线程。核心的命令执行,IO多路复用模块依然是由主线程执行。
通信协议
RESP协议
Redis是CS架构的应用,通信一般分为2步(不包括pipline和PubSub):
- 客户端想服务端发送一条命令
- 服务端解析并执行命令,返回响应结果给客户端
因此客户端发送命令的格式,服务端响应结果的格式必须有一个规范,这个规范就是通信协议。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;
问题:1.Redis如何知道一个key是否过期?2.是不是TTL到期立即删除?
- 利用两个dict分别记录k-v对及key-ttl对
- 因为需要扫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
- 执行频率受server.hz影响,默认10,即每秒执行10次,每个执行周期100ms
- 执行清理耗时不超多一次执行周期的25%
- 逐个遍历db,和db中的bucket,抽取20个key判断是否过期。
- 如果没有达到时间上限25ms,并且过期key比例大于10%,再进行一次抽象,否则结束。
FAST规则:主要:频率不固定,两次间隔不低于2ms,每次不超过1ms
- 执行频率受beforeSleep调用影响,但两次FAST模式间隔不低于2ms
- 执行清理耗时不超1ms
- 逐个遍历db,逐个遍历db中的bucket,抽取2.个key判断是否过期。
- 如果没有达到时间上限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;
}
}
}
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访问都会被计数,而是通过运算:
- 生成0~1的随机数R
- 计算1/(旧次数*lfu_log_factor+1),记录为P,lfu_log__factor默认为10
- 如果R < P,则计数器+1,且最大不超过255
- 访问次数会随时间衰减,距离上一次访问时间间隔lfu_decay_time分钟,计数器-1