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如下图所示:

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;

一般跳表和跳表搜索如下:

跳表搜索

跳表的查找过程:

  1. 比如查找68,先从顶层查找先比较-1,如果小于-1直接结束,68显然大于-1,然后和20比较,比它大指针往后走;

  2. 然后比较99,发现68比99小,此时指针向下,level层下一级

  3. 此时比较30,发现68比30大,此时指针向右移动

  4. 此时又出现了和顶层一样的情况,发现68比99小,此时指针向下,level层走向下一及

  5. 此时比较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设计的初衷(时间和空间上的这种考虑):

  1. 双向链表便于在表的两端进行push和pop操作,但它的内存开销较大。首先它每个节点除了要保存数据之外还要额外保存指针;其次双向链表的各个阶段是单独的内存块,地址不连续,节点多了容易产生碎片。
  2. 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类型,它的内存使用:

  1. 一个dictEntry的消耗(有两个指针,一个int64的消耗),RedisDB本身就是一个大的dict,每对kv都是其中的一个entry;
  2. value对象本身是String,采用的是redisObject对象(一个指针,一个int,已经几个使用位域字段共消耗4字节),redisOject的主要作用是为了dict内能够存储不同类型的value而使用的一个通用结构,这个结构就是RedisOjbect。
  3. 存储key为sds的消耗,header+本身字符串长度+1
  4. 存储过期时间消耗(也是存储为一个dictEntry,时间戳为int64)
  5. 前四项基本是存储任何一个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是否过期,如果过期则清除。

  1. 这种删除策略对cpu时友好的,只在被动操作时才会进行,不会在其他过期key上浪费cpu时间
  2. 这种策略对内存来说又不是友好的,一个key已经过期,不及时清理掉,仍然占用内存空间。如果有大量过期key存在,又很少被访问,会造成内存空间的大量浪费。

定期删除

这种策略会定期清理一批过期key,它主要来自于Redis本身主流程的常规任务。

它涉及时间事件的处理,对于持续运行的服务器而言,服务器定期对自身资源和状态需要进行必要的检查和整理,从而让服务器维持在一个健康稳定的状态,这类操作被称为常规操作(conr job)。

在Redis中,常规操作由redis.c/serverCron方法实现。它主要的流程如下:

  1. 更新服务器各类统计信息,比如时间,内存占用,数据库占用情况等。
  2. 清理数据库中的过期key
  3. 对不合理数据库大小进行调整
  4. 关闭和清理失效客户端连接
  5. 尝试进行持久化操作(AOF或RDB)
  6. 如果服务器时主节点,定期对从节点进行数据同步
  7. 如果是集群模式,对集群进行定期同步和连接测试(个人理解为心跳)

超限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