- Redis设计原则
- Redis数据结构及其内部原理
- Redis的Map的rehash详解
- Redis内存估算
- Redis客户端与服务器交互模式
- Redis过期Key处理策略
- Redis部署架构
- 缓存场景中常见的问题
- Redis持久化方式及优缺点
- 参考链接:
Redis设计原则
这个看了篇文章,感觉特别重要的一点是:我们所要学习的一种技术或组件,如果能够理解或抓住其核心思想,它解决问题的思路及方式,那么后续如果碰到新问题的时候才会加以灵活应用和解决。
Redis设计原则
存储效率
Redis专门用于存储数据的,它对于计算机资源的主要消耗在于内存,节省内存是它的一个重要方面,所以Redis的内部数据结构设计比较精细,设计的时候考虑了数据压缩,减少内存随便等问题。
快速响应
与快速响应对应的高吞吐量。Redis是用于提供在线访问的,对于单个请求的响应时间要求很高。因此,快速响应时间是比高吞吐量更重要的目标。有时候这两个目标是相互矛盾的。
单线程
Redis性能瓶颈不在cpu资源,一般在用内存访问和网络IO的耗时上,采用单线程设计带来的好处是:极大地简化数据结构和算法的实现。Redis通过异步IO和pipeline等机制来实现高速的并发访问。
Redis数据结构及其内部原理
Redis设计数据结构分为两层:一层是对外使用提供的数据结构,另外一层底层数据结构。
对外数据结构
包括:String、List、Map(Hash)、Set、SortedSet
String类型:
字符串、整数和浮点数。根据场景相互自动转型,并且根据需要选取底层实现承载方式。
String 类型的value内部:int、sds结构作为存储,int存放整形数据,sds存放字节、字符串和浮点类型数据。
sds内部结构:
用buf数组存储字符串内容,但数组长度会大于存储内容的长度。会有专门存放“\0”作为结尾,还会多预留几个空间(free区域),当append字符串长度小于free区域,则sds不会重新申请内存,直接使用free区域。
扩容:对当前字符串操作完成后预期小于1M时,扩容后数组buf长度=预期长度*2+1;若大于1M,则buf总会预留1M的free空间
value对象通常分为两部分:redisObject和redisObject的ptr指向sds部分。创建value对象时,通常需要为redisObject和sds申请两次内存。但对于短小的字符串,可以把两者连续存放,所以会一次将两者内存都申请了。
list类型
list类型value对象内部:底层使用linkedlist和ziplist、quicklist作为存储(3.2以后使用quicklist)。
当列表保存的字符串元素对象长度都小于64字节
保存的元素数量小于512个
当list元素满足以上两个条件时,redis会采用ziplist实现承载方式以减少内存占用,否则采用linkedlist结构。
实际上quicklist是ziplist和linkedlist结合的产物。我们可以这样理解,每个双向链表的节点都是一个ziplist。之所以这么设计,应该是空间和时间之间的取舍或一个折中方案。后面会详细介绍quicklist结构。
linkedlist内部实现时双向链表。在list定义了头尾指针和列表长度,pop/push操作、llen操作时间复杂度为O(1)。由于时链表lindex操作时间复杂度是O(N)。
ziplist内部结构:
所有数据放置在连续内存中,其中zlbytes表示ziplist总长度,zltail指向最末元素,zllen表示元素个数,entry表示元素自身内容为ziplist界定符。
rpush、rpop、llen复杂度为O(1),lpush和pop操作由于涉及全列表元素移动,时间复杂度为O(N)。
以下为ziplist源码数据结构定义:
typedef struct ziplist{
/*ziplist分配的内存大小*/
uint32_t bytes;
/*达到尾部的偏移量*/
uint32_t tail_offset;
/*存储元素实体个数*/
uint16_t length;
/*存储内容实体元素*/
unsigned char* content[];
/*尾部标识*/
unsigned char end;
}ziplist;
/*元素实体所有信息, 仅仅是描述使用, 内存中并非如此存储*/
typedef struct zlentry {
/*前一个元素长度需要空间和前一个元素长度*/
unsigned int prevrawlensize, prevrawlen;
/*元素长度需要空间和元素长度*/
unsigned int lensize, len;
/*头部长度即prevrawlensize + lensize*/
unsigned int headersize;
/*元素内容编码*/
unsigned char encoding;
/*元素实际内容*/
unsigned char *p;
}zlentry;
ziplist内存布局
bytes | offset | length | content:{zlentry,zlentry … …} | end |
map类型
map又叫hash。map内存的key和value不能嵌套map。只能以string类型:整形、浮点和字符串。
map本身内部存储的数据结构:hashtable和ziplist两种承载方式实现。对于数据量较小的map采用ziplist实现。
dict如下图所示:
hashtable的内部结构:
1.主要分为三层,自底向上分别是:dictEntry、dicht、dict
2.dictEntry管理一个key-value对,同时保留同一个桶中相邻元素的指针,依次维护hash桶中的元素内部链
3.dictht是一个数组,维护hash表中的所有桶链,它的table字段维护整个hash桶,是一个数组,每个元素指向桶的第一个元素
4.dict,当dictht需要扩容\缩容时,用于管理dictht的迁移
5.set值的流程:先通过MurmurHash算法求出key的hash值,,再对桶个数取模找出key对应的桶,然后进入桶中,遍历桶中全部dictEntry,判断是否已经有相同的key,如果没有,则将新key对应的key-value键值对采用头插法放桶的头部,并更新dictht的used数量,否则更新对应的dictEntry。(used表示hash表中已存在的多少个元素)。由于每次插入都要遍历桶中的dictEntry,所以桶中dictEntry比较多的时候,性能会线性下降。
6.扩容,通过负载因子判定是否需要增加桶的个数。负载因子=used元素总数/hash桶个数。有两个阈值小于1一定不扩容;大于5一定扩容。扩容时hash新桶数量=2*现有hash桶数量
7.缩容:负载因子的阈值时:0.1
8.扩容或缩容时通过新建hash表dictht的方式实现。即扩容时会存在两个hash表,一个源表。一个目标表。通过将源表桶逐步迁移到目标表,以数据迁移到方式实现扩容,迁移完成后目标表覆盖源表。值得注意的是:迁移过程中,key的访问首先访问源表,如果发现key对应的源表中的桶已完成迁移,则重新访问目标表,否则在源表中操作。
9.redis是单线程操作出来请求,迁移和访问请求都在相同的线程中进行,所以不存在并发性问题。
ziplist内部结构:
1.和list的ziplist实现类似,不同的是map对应的ziplist的entry个数总数2的整数倍
2.ziplist实现下,由hash表遍历变成了链表的顺序遍历,复杂度为O(N)
set类型
set内部结构:intset、hashtable来存储。当set中只包含整形元素时,则采用intset
hashtable存储时,hashtable中的value永远为null。
intset内部结构:
1.核心元素是一个字节数组,从小到大存放set的元素。
2.由于元素有序排列,所以set获取操作采用二分查找实现,时间复杂度O(log(N))。进行插入时,首先通过二分查找本次位置,然后进行扩容,再将预计插入位置之后元素移动,最后插入元素,插入时间复杂度为O(N)。删除类似。
sorted-set类型
sorted-set内部结构类似map。一个key-value对,但是有序的,value是一个浮点数。称为score,内部按照score从小到大进行排序的。
serted-set内存结构:ziplist或skiplist+hashtable方式来承载实现的。
Redis源码zset实现如下:
typedef struct zset{
zskiplist *zsl;
dict *dict
}zset;
底层数据结构
包括:redisObject、dict、sds、ziplist、skiplist、intset、quicklist
底层的数据结构,在外部用户使用的过程中已经涉及到大部分数据结构的实现和应用。
下面主要讲下redisObject、skiplist、intset、quicklist。
redisObject 类型
redisObject上文中已经提到,redis中key一般为字符串,value为redisObject结构体,其中它的成员void * ptr,可以绑定各种类型的数据,给了我们无限的可能。
typedef struct redisObject {
// 刚刚好32 bits
// 对象的类型,字符串/列表/集合/哈希表
unsigned type:4;
// 未使用的两个位
unsigned notused:2; /* Not used */
// 编码的方式,Redis 为了节省空间,提供多种方式来保存一个数据
// 譬如:“123456789” 会被存储为整数123456789
unsigned encoding:4;
// 当内存紧张,淘汰数据的时候用到
unsigned lru:22; /* lru time (relative to server.lruclock) */
// 引用计数
int refcount;
// 数据指针
void *ptr;
} robj;
需要注意的是:
Redis进行内存预估的时候根据可以:key+redisObject占用大小+(sds、list、dict、intset)才更为准确。
skiplist 类型
跳表(skiplist)是一个链表,相比一般链表,有更高的查找效率,其效率可以比拟二叉查找树。
跳表有以下性质:
1.有很多层结构组成
2.每一层是一个有序链表
3.最底层包含所有元素
4.如果一个元素出现在level i层,则它在level i之下的链表也会出现
5.每个节点包含两个指针,一个指向同一链表的下一个元素,一个指向下面一层的元素。
Redis中跳表数据结构定义:
// 跳表节点结构体
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
// 节点数据
robj *obj;
// 分数,游戏分数?按游戏分数排序
double score;
// 后驱指针
struct zskiplistNode *backward;
// 前驱指针数组TODO
struct zskiplistLevel {
struct zskiplistNode *forward;
// 调到下一个数据项需要走多少步,这在计算rank 的非常有帮助
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
// 跳表头尾指针
struct zskiplistNode *header, *tail;
// 跳表的长度
unsigned long length;
// 跳表的高度
int level;
} zskiplist;
一般跳表和跳表搜索如下:
跳表的查找过程:
-
比如查找68,先从顶层查找先比较-1,如果小于-1直接结束,68显然大于-1,然后和20比较,比它大指针往后走;
-
然后比较99,发现68比99小,此时指针向下,level层下一级
-
此时比较30,发现68比30大,此时指针向右移动
-
此时又出现了和顶层一样的情况,发现68比99小,此时指针向下,level层走向下一及
-
此时比较68找到了该元素。
跳表的插入过程:
跳表的插入算法描述:找出每一层插入数据的位置并保存,在Redis跳表中是根据score/member的大小来决定插入位置;将新数据插入到指定位置,并调整指针,在Redis中还要调整span。[span:为从两个相邻的节点间隔了多少节点。]
Redis跳表插入源码:
zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
// update 是插入节点所在位置的前一个节点。我们在学习链表插入的时候,需要找到插入
// 位置的前一个节点。因为在跳表中一个节点是有多个前驱指针的,所以这里需要保存的
// 是多个节点,而不是一个节点
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
redisAssert(!isnan(score));
x = zsl->header;
// 遍历skiplist 中所有的层,找到数据将要插入的位置,并保存在update 中
for (i = zsl->level-1; i >= 0; i--) {
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
// 链表的搜索
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
// update[i] 记录了新数据项的前驱
update[i] = x;
}
// random 一个level,是随机的
/* we assume the key is not already inside, since we allow duplicated
* scores, and the re-insertion of score and redis object should never
* happen since the caller of zslInsert() should test in the hash table
* if the element is already inside or not. */
level = zslRandomLevel();
// random level 比原有的zsl->level 大,需要增加skiplist 的level
if (level > zsl->level) {
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
zsl->level = level;
}
// 插入
x = zslCreateNode(level,score,obj);
for (i = 0; i < level; i++) {
// 新节点项插到update[i] 的后面
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
/* update span covered by update[i] as x is inserted here */
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
// 更高的level 尚未调整span
/* increment span for untouched levels */
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
// 调整新节点的后驱指针
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x;
// 调整skiplist 的长度
zsl->length++;
return x;
}
跳表的删除过程:
跳表的删除和插入类型,找出每一层删除数据的前驱并保存;接着调整指针和span。
Redis跳表删除源码:
// x 是需要删除的节点
// update 是每一个层x 的前驱数组
/* Internal function used by zslDelete, zslDeleteByScore and zslDeleteByRank */
void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
int i;
// 调整span 和forward 指针
for (i = 0; i < zsl->level; i++) {
if (update[i]->level[i].forward == x) {
update[i]->level[i].span += x->level[i].span - 1;
update[i]->level[i].forward = x->level[i].forward;
} else {
// update[i]->level[i].forward == NULL,只调整span
update[i]->level[i].span -= 1;
}
}
// 调整后驱指针
if (x->level[0].forward) {
x->level[0].forward->backward = x->backward;
} else {
zsl->tail = x->backward;
}
// 删除某一个节点后,层数level 可能降低,调整level
while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
zsl->level--;
// 调整跳表的长度
zsl->length--;
}
intset类型
Redis源码中intset定义如下:
typedef struct intset {
// 每个整数的类型
uint32_t encoding;
// intset 长度
uint32_t length;
// 整数数组
int8_t contents[];
} intset;
intset本质上是一个有序、不重复、整形数组,支持不同类型的整数。contents没有制定长度,这样为了方便分配和释放内存。encoding能可以取三个值:16、32和64位整数。查找元素采用二分查找。
intset插入算法比较意思,需要插入的整数超出原有集合的范围(即内存内存不同),则升级整数类型,然后从后向前插入有序。否则进行二分查找位置index,然后移动位置index后的元素插入当前数据。
quicklist类型
quicklist对外暴露的就是list数据类型,它底层实现所依赖的就是quicklist,经常当作队列使用,比如它经常支持的一些操作:
lpush:在左侧入队,插入数据 rpop:在右侧弹出,删除数据 rpush:在右侧入队,插入数据 rpop:在右侧弹出,删除数据
在quicklist的实现上,这些操作的时间复杂度都是O(1)。当然list也支持任意中间位置存取如:lindex、linsert,但它们需要对list进行遍历,所以时间复杂度为O(N)。
quicklist内部是一个双向链表,而且是一个ziplist的双向链表。意思就是quicklist上的每个节点都是一个ziplist。
quicklist设计的初衷(时间和空间上的这种考虑):
- 双向链表便于在表的两端进行push和pop操作,但它的内存开销较大。首先它每个节点除了要保存数据之外还要额外保存指针;其次双向链表的各个阶段是单独的内存块,地址不连续,节点多了容易产生碎片。
- ziplist是一块连续的内存,所以存储效率高。但是不利于修改,每次变动都触发一次realloc。特别当ziplist长度很长的时候。
于是结合双向链表和ziplist的优点,quicklist应运而生。不过这点也确实带来一个新的问题,那就是到底一个quicklist节点存储多长的ziplist合适呢?
同样是存储12个数据项,既可以一个quicklist只有一个节点,这个节点之间包含12个数据项,也可以有3个节点,一个节点包含4个数据项,更可以包含6个节点,每个节点的ziplist只包含2个数据项。
这是需要一个平衡的难题,我们从存储效率上分析下,ziplist越短,内存碎片越多,从而降低存储效率,这就蜕化为一个普通的双向链表了。每个quicklist越长,则为ziplist分配大块连续内存的难度就越大。有可能有很多小块内存空间,但却找不到一块足够大的连续空间分配给ziplist的情况,这样同样会降低存储效率。
由此可见需要设置ziplist保证一个合理的长度,这个到底多长取决于具体应用场景,Redis将这个问题抛给了应用开发者,提供了一项配置,使用者可以根据自己情况调整。
list-max-ziplist-size -2 #A配置项
list-compress-depth 0 #B配置项
A配置项它可以取正值,也可以取负值。
当取正值的时候,表示根据数据项个数来限定quicklist上节点ziplist的长度。比如配置5 表示每个quicklist节点的ziplist最多包含5个数据项
当取负值是,表示按照字节占有来限定每个quicklist节点ziplist的长度,这时候只能取-1到-5的值。每个值含义如下:
-5 ziplist大小不能超过64Kb
-4 ziplist大小不能超过32Kb
-3 ziplist大小不能超过16Kb
-2 ziplist大小不能超过8Kb
-1 ziplist大小不能超过4Kb
B配置项表示quicklist两端不被压缩到节点个数。这里所指的是quicklist双向链表的节点个数,不是ziplist的数据项个数。
参数list-compress-depth
的取值含义如下:
0 是个特殊值,表示都不压缩。默认值
1 表示是quicklist两端各有1个节点不压缩,中间节点压缩。
2 表示是quicklist两端各有2个节点不压缩,中间节点压缩。
3 表示是quicklist两端各有3个节点不压缩,中间节点压缩。
依次类推…
Redis在源码中quicklist的定义:
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF;
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* total count of all entries in all ziplists */
unsigned int len; /* number of quicklistNodes */
int fill : 16; /* fill factor for individual nodes */
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;
Redis的Map的rehash详解
rehash有2种工作模式:
lazy rehashing:在每次对dict进行操作的时候执行一个桶的的rehash
active rehashing:每100ms里面使用1ms时间进行rehash。
rehashidx是下一个需要rehash的项在ht[0]中的索引,不需要rehash时值为-1.
rehash的过程时多次进行的,每次进行dictAdd、dictRepalce(在可以存在的情况下会调用dictAdd和dictFind这块相当于两次查询)、dictDelete操作时会触发一个桶的rehash。
redis中dict.c源码文件中关于rehash的核心代码:
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
/*判读是否在进行rehash,不在进行状态的话直接返回,通过rehashidx判定*/
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned long)d->rehashidx);
/*对于ht[0].table桶为空的直接跳过,rehashidx++*/
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
/*获得本次需要进行rehash桶的位置,rehashidx记录桶进度*/
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
while(de) {
uint64_t h;
nextde = de->next;
/* 算出源表ht[0].table中对应key到目标表ht[1].table的桶位置 */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
/*利用头插法进行插入,ht[0].used--,ht[1].used++*/
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* 判断整个ht[1]是否迁移完成,ht[1]覆盖到ht[0],释放无用空间,rehashidx值为-1,rehash过程结束*/
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
return 1;
}
Redis内存估算
看了以上Redis内部数据结构的所有介绍,在做内存估算时,注意内部数据结构上的消耗,就可以估算更为准确一些。
这块进行内存估算前,先回顾下Redis的内部数据结构:sds、dict、intset、zipmap、adlist、quicklist、skip list
具体进行内容估算,以key为name,value为jerry,类型时string类型,它的内存使用:
- 一个dictEntry的消耗(有两个指针,一个int64的消耗),RedisDB本身就是一个大的dict,每对kv都是其中的一个entry;
- value对象本身是String,采用的是redisObject对象(一个指针,一个int,已经几个使用位域字段共消耗4字节),redisOject的主要作用是为了dict内能够存储不同类型的value而使用的一个通用结构,这个结构就是RedisOjbect。
- 存储key为sds的消耗,header+本身字符串长度+1
- 存储过期时间消耗(也是存储为一个dictEntry,时间戳为int64)
- 前四项基本是存储任何一个key都需要的,最后一个根据value的数据结构不同而不同;
Redis客户端与服务器交互模式
1.串行请求/响应模式:
a.每一次请求的发送都依赖于上一次请求的相应结构完全接受,同一个连接的每秒吞吐量低。
b.Redis对单个请求的处理时间较短(比区域网延迟小一个数量级),主要大部分时间在于网络等待。
2.双工请求(pipeline)
a.适用于批量独立写入操作。将请求数据批量发送到服务器,再批量地从服务器连接字节流中一次读取每个数据的响应,减少网络io时延。
3.原子化批量操作(事务)
a.客户端通过和Redis服务两阶段交互做到批量院子命令执行事务的效果:入队操作(即服务器会先将客户端命令放入队列中)和执行阶段(依次执行队列中的所有请求)。
b.一个连接在执行事务时,不会执行其他客户端请求
c.Redis事务不是一致性的,没有回滚机制。如果中途失败,则返回错误信息,但已经执行成功的不会回滚。
d.事务中有可能带有读操作作为条件,由于批量请求只会先入队,然后再批量写请求一起执行,所以读操作一般不会跟批量写请求一起执行,这时候就会导致批量写之前和之后读到的数据不一致,这种通过乐观锁的可串行化来解决,Redis通过watch机制实现乐观锁。
4.发布/订阅模式
a.发布短和订阅者通过channel关联
b.channel的订阅关系维护在redis实例级别,独立于redisDB的key-value体系。所有的channel都有一个map维护,键是channel的名字,value是所有订阅者client的指针链表。
在Redis cluster实现中,客户端可以在任何节点订阅消息,也可以在任何节点发布消息。集群会保证消息被正确的转发。(在当前的实现中,发布的消息会被简单的广播到所有节点,有时候也会利用Bloom过滤器或其他算法进行优化,不过一般不推荐使用)。
5.脚本化批量执行/脚本模式
比如使用lua脚步化工具执行的一些命令。
Redis的Watch机制
1.将本次事务涉及的所有key注册为观察模式
2.执行只读操作
3.批量写操作命令发送到服务器入队
4.发送exec指令,执行队列命令
5.如果前面注册观察模式key一个或多个,在exec前被修改,则exec将直接失败,拒绝执行;否则执行完请求队列的指令
6.Redis没有原生的悲观锁或快照实现,但可通过乐观锁绕过。一旦两次读到的操作不一样,watch机制触发,拒绝后续exec执行。
Redis过期Key处理策略
过期key删除策略总的来说有三种:
被动删除(惰性删除):当读/写一个已经过期key时,会触发惰性删除策略,直接删除掉这个过期key
主动删除(定期删除):由于被动无法保证冷数据或过期数据及时被清理掉,所以Redis本身灰定期主动淘汰一批已过期key
当前已超过内存maxmemory限定,触发主动清理策略
被动删除
只有key被操作(get),Redis才会被动检查key是否过期,如果过期则清除。
- 这种删除策略对cpu时友好的,只在被动操作时才会进行,不会在其他过期key上浪费cpu时间
- 这种策略对内存来说又不是友好的,一个key已经过期,不及时清理掉,仍然占用内存空间。如果有大量过期key存在,又很少被访问,会造成内存空间的大量浪费。
定期删除
这种策略会定期清理一批过期key,它主要来自于Redis本身主流程的常规任务。
它涉及时间事件的处理,对于持续运行的服务器而言,服务器定期对自身资源和状态需要进行必要的检查和整理,从而让服务器维持在一个健康稳定的状态,这类操作被称为常规操作(conr job)。
在Redis中,常规操作由redis.c/serverCron方法实现。它主要的流程如下:
- 更新服务器各类统计信息,比如时间,内存占用,数据库占用情况等。
- 清理数据库中的过期key
- 对不合理数据库大小进行调整
- 关闭和清理失效客户端连接
- 尝试进行持久化操作(AOF或RDB)
- 如果服务器时主节点,定期对从节点进行数据同步
- 如果是集群模式,对集群进行定期同步和连接测试(个人理解为心跳)
超限maxmemory
当前已用内存超哥mamemory限定时,触发主动清理策略:
vlolatile-lru 只对设置过期时间key进行LRU
allkeys-lru 所有key进行LRU
volatile-random 过期key随机删除
allkeys-random 随机删除
volatile-ttl 删除即将过期的key
noeviction 永不过期,所有写请求失败报错。
Redis部署架构
Redis部署分为:单机部署,主从部署,哨兵+主-从、集群模式(redis-cluster)方式、自研方式(代理)
单机部署:这个很好理解,是一个单节点服务,主要用在业务不重要,数据可丢失,数据可靠性不高的场景
主从部署:最大地区别于单机是有主从数据同步,可以对外提供服务和读写分离策略。
哨兵模式:是HA(高可用)的一种原生解决方案,其主要部署架构包含两部分:Redis Sentinel集群和Redis数据集群。
其中Sentinel集群是若干Sentinel 节点组成的分布式集群,可以实现故障转移,故障发现,配置中心和客户端桶中,Sentinel的部署节点数量要满足2n+1奇数个。
Redis-Cluster集群模式:主要解决单机内存、并发或流量瓶颈来做数据分片,起到很好的负载均衡的目的。
Redis-Cluster集群最小配置6节点以上(3主3从),其中主节点提供读写,从节点可以作为备机。不提供请求,实现故障转移(fail-over)。
Redis Cluster采用的是虚拟slot分区,根据键的hash函数映射到0~16383的整数槽内,每个redis节点负责维护一部分slot及槽内所映射到key-value数据。 需要注意的是slot采用的是一个位数组来提供查找效率,占用16384/8 byte的大小
一致性:每个节点都保存了集群的配置信息,存储clusterState中,通过引入自增epoch变量来使得各个节点保持一致。
sharding数据分片:
1.将所有数据划分为16384个分片(slot),每个节点对应一部分slot,每个key都会根据hash映射到slot中的一个,slotId=crc16(key)%16384
2.当一个key访问key不存在的对应节点的slot中,redis会返回给客户端一个moved命令,告知其正确的路由信息,从而重新发起请求。Client会对每次请求来缓存本地路由信息,以便下次请求能够直接路由到正确节点。
3.分片迁移:分片迁移的触发和过程控制由外部系统完成,redis只提供对外的命令支持,说白了这个分片迁移需要自己手动来做。迁移包含两种:一种节点状态的设置,即迁移前需要标记源、目标节点;另一种是key迁移的原子化命令。
fail-over故障转移:
1.故障发现:节点间两两通过tcp保持连接,进行心跳检测ping、pong交互,若对方的pong响应超时,则置为pfail状态,并传播给其他节点。
2.故障确认:当集群半数以上节点对某一节点的pfail状态进行了确认,则将其改为fail状态,确认其故障。
3.slave选举:当有一个master挂掉了,则对其slave进行选举,竞选出新master。主要根据各个slave最后一次同步master信息的时间,越新表示slave数据越新,竞选优先级就越高。竞选成功后传播给其他节点。
集群不可用情况:
1.集群中任意master挂掉,且当前master没有slave
2.集群中超过半数以上master挂掉
缓存场景中常见的问题
以下是常见的几种缓存使用中碰到的场景,可能根据不同业务也会有所不同,有些业务场景下,缓存的使用不一定会有问题。
缓存雪崩
缓存雪崩简单来说,由于缓存失效,导致同一时间大面积失效,从而导致查询请求都落到DB上,最终导致数据库cpu和负载过高,甚至宕机。
解决思路:
1.加锁技术/使用队列:限制并发请求缓存的数量(如使用semphore做简单的并发限制,限流),或使用一定数量的队列,来避免缓存失效的大量DB请求。这种做法会降低吞吐量。
2.分析相关业务缓存,缓存数据失效时间在基础上+随机,保证缓存数据不在同一时间大量失效。
3.做主备,万一某一个master挂掉,可以走从节点
缓存穿透
缓存穿透指:查询用户数据,缓存为空,然后查询数据库也没有,这样用户查询的时候每次都会查数据库,导致穿透。
解决思路:
1.如果数据库为空,设置一个特殊的默认值,这样二次查询就可以从缓存中查询,不用查询数据库,当然需要注意的是,数据库有如果有数据需要做缓存更新处理。
2.给key设置特定规则,然后查询前过滤掉不,其实就是按特定规则去标记,然后查询
缓存并发
缓存并非:如果并发访问比较高的时候,一个缓存失效,此时可能多个请求都落到了数据库查询,如果并发很大,会导致数据库压力过大,还有缓存频繁更新的问题。
解决思路:
1.对缓存查询加锁,如果key不存在,就加锁,然后查数据库,然后解锁,其次发现有锁等待,然后等待解锁,然后查缓存或数据库。
缓存预热
缓存预热就是在系统上线前,对热数据提前加载到缓存中,防止系统上线时流量全部打到数据库,导致数据库负载过高或挂掉。
解决思路:
1. 数据量不大时,系统启动时,直接加载
2. 系统启动时,需要自身实现缓存预热程序。
Redis持久化方式及优缺点
Redis持久化方式有两种:RDB和AOF(Append of file)两种持久化方式,两种持久化方式各有优缺点。
RDB
默认开启,会按照配置的指定时间dump内存快照到磁盘中,生成一个dump.rdb的文件,redis启动时再恢复到内存
Redis会fork一个子线程来将父进程的数据库夫复制到子进程的内存中,然后由子进程完成文件磁盘的写入,子进程首先会写入到一个临时文件,持久化过了之后,在用这个临时文件覆盖上次的快照文件,然后推出,释放内存。
需要注意的是:
每次持久化都会将主进程的数据复制一遍,导致内存开销加倍,若此时内存不足,则会阻塞服务运行,直到复制结束释放内存;
每次持久化都会将内存数据完整写入磁盘一遍,如果数据量较大,并且写入操作频繁,必然会引起大量的磁盘I/O操作,严重影响性能,也可能在持久化过程中导致数据丢失。
AOF
以日志记录Redis每个写操作(注意:读操作不会记录),只需追加文件,但不可以改写文件,Redis启动是需要按日志从头到尾执行一遍完成数据恢复。
AOF的触发方式有三种,服务器配置项appendfsync
:
always
:每次操作都会写入,效率低
everysec
:默认值,每一秒写入一次(这个相当于异步写入,有可能丢失1秒内的数据)
no
: 从不同步,高效但数据不会被持久化。
RDB和AOF的优缺点
RDB文件是通过压缩的,Redis默认开启,配置项为rdbcompression
,
压缩:
优点:减少磁盘存储空间 缺点:消耗cpu资源
不压缩:
优点:不消耗cpu资源 缺点:占用磁盘空间多
总的来说RDB :备份只包含一个文件,容易恢复、相比于AOF机制,如果数据集很大,RDB启动效率会高很多。 缺点就是:比较占用内存,系统一旦宕机,此前没有持久化的数据都将丢失。由于rdb通过fork子进程来进行持久化的,因此当数据集比较大时,可能会导致整个服务停顿阻塞。
AOF持久化是类似于日志追加的方式,记录Redis的写操作,相比rdb能带来更好的数据安全性。由于改方式采用append模式,即使写入过程出现奔溃,也只会影响一部分为写入的数据丢失。 缺点:AOF相比RDB一般比较数据集比较大,AOF在恢复数据上不如RDB快。根据同步策略,AOF在运行效率上会慢于RDB。总之每秒同步策略的效率还是比较高的,同步禁用策略和RDB一样高。
二者选择的标准,就是看系统愿意牺牲一些性能(rdb),还是换取更高的缓存一致性(aof)。
RDB持久化配置:
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,则dump内存快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,则dump内存快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,则dump内存快照。
AOF持久化配置三种:
appendfsync always #每次有数据修改发生时都会写入AOF文件。
appendfsync everysec #每秒钟同步一次,该策略为AOF的缺省策略。
appendfsync no #从不同步。高效但是数据不会被持久化。
参考链接:
http://zhangtielei.com/posts/blog-redis-dict.html
https://blog.csdn.net/wuyangyang555/article/details/82152005