redis原理之一存储

本系列文章主要用户整理redis,分成4节:

  • 存储 <=
  • 控制
  • 集群
  • 应用

本节为本系列第一篇

概述

整理看的资料,将redis原理分成以下几个部分:

Redis原理

左侧部分主要是单点内容,右侧部分主要是集群内容。单点内容可分成了存储控制,集群内容可以分成基于存储的数据同步以及搭建集群的3种控制方案

本文是第一篇,主要介绍单节点相关的存储。

与ngix类似,为表示尊重,我们先看看redis之父,意大利人Salvatore Sanfilippo。Github网名antirez

redis之父

参考1 参考2参考3参考4参考5

常见数据结构的实现

简介

常用数据结构实现对应表

Redis支持的常用5种数据类型指的是value类型,分别为:字符串String、列表List、哈希Hash、集合Set、有序集合Zset,Redis底层的数据结构包括:简单动态数组SDS、链表、字典、跳跃链表、整数集合、压缩列表、对象。

SDS简单动态字符串

c语言中没有动态字符串的结构,使用N+1长度的字符数组来表示字符串,尾部用\0作为结束标志(C++的STL中倒是有string的动态字符串)。

这里看看src/sds.h中的定义:

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

除了sdshdr5类型,都是由len(使用的空间大小)、alloc(分配的最大空间)、flags(哪种类型:sdshdr8是1、sdshdr16是2 )、buf(数组的指针)组成。

sds1

使用示例如下:

sds2
  • 长度检查:当sds需要对字符串进行修改时,首先借助于len和alloc检查空间是否满足修改所需的要求,如果空间不够的话,SDS会自动扩展空间,避免了像C字符串操作中的覆盖情况;

  • 空间伸缩:C字符串在涉及增加或者清除操作时会改变底层数组的大小造成重新分配、sds使用了空间预分配和惰性空间释放机制,说白了就是每次在扩展时是成倍的多分配的,在缩容是也是先留着并不正式归还给OS。通过这种方式,降低内存分配的次数。

字典的hashTable的实现

hash表(也叫散列表)通过把key值映射到表中一个位置来访问记录。这个映射函数叫做散列函数(hash函数),存放记录的数组叫做散列表。如下图所示:

普通的hash表

在redis中,hash表与上述稍有不同。先来看看它的结构(在src/dict.h中)

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);
    void *(*keyDup)(void *privdata, const void *key);
    void *(*valDup)(void *privdata, const void *obj);
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    void (*keyDestructor)(void *privdata, void *key);
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;
  • dictEntry

    dictEntry是哈希表节点,也就是我们存储数据地方,其保护的成员有:key,v,next指针。其结构如下:

    dictEntry

    与普通hash表不同的是,在节点中,将key也进行了保存。

  • dictht

    从源码看哈希表包括的成员有table、size、used、sizemask。

    table是一个指针数组,数组中的每个元素都是一个指向dictEntry结构的指针, 每个dictEntry结构保存着一个键值对;

    size 属性记录了哈希表table的大小

    used属性则记录了哈希表目前已有节点的数量。

    sizemask是size掩码的意思,通过&来过滤位,计算index时使用。

    dictht

    dictht就是我们上边的hashTable

  • dict

    从源码中看到dict结构体就是字典的定义,包含的成员有type,privdata、ht、rehashidx。

    其中dictType指针类型的type指向了操作字典的api,理解为函数指针即可

    privdata配合dictType指向的函数作为参数使用

    ht是包含2个dictht的数组,也就是字典包含了2个哈希表,

    rehashidx进行rehash时使用的变量,

    dict
  • hash算法

    redis使用MurmurHash算法计算哈希值,该算法最初由Austin Appleby在2008年发明,MurmurHash算法的无论数据输入情况如何都可以给出随机分布性较好的哈希值并且计算速度非常快。

    //伪码:使用哈希函数,计算键key的哈希值
    hash = dict->type->hashFunction(key);
    //伪码:使用哈希表的sizemask和哈希值,计算出在ht[0]或许ht[1]的索引值
    index = hash & dict->ht[x].sizemask;
    //源码定义
    #define dictHashKey(d, key) (d)->type->hashFunction(key)
    
  • rehash

    这里看到dict中有2个hash table,为什么有两个呢,这就需要介绍rehash了。

    哈希表保存的键值对数量是动态变化的,为了让哈希表的负载因子维持在一个合理的范围之内,就需要对哈希表进行扩缩容。

    扩缩容是通过执行rehash重新散列来完成,对字典的哈希表执行普通rehash的基本步骤为分配空间->逐个迁移->交换哈希表,详细过程如下:

    1. 为字典的ht[1]哈希表分配空间,分配的空间大小取决于要执行的操作以及ht[0]当前包含的键值对数量:
      扩展操作时ht[1]的大小为第一个大于等于ht[0].used*2的2n;
      收缩操作时ht[1]的大小为第一个大于等于ht[0].used的2
      n ;
      扩展时比如h[0].used=200,那么需要选择大于400的第一个2的幂,也就是2^9=512。
    2. 将保存在ht[0]中的所有键值对重新计算键的哈希值和索引值rehash到ht[1]上;
    3. 重复rehash直到ht[0]包含的所有键值对全部迁移到了ht[1]之后释放 ht[0], 将ht[1]设置为 ht[0],并在ht[1]新创建一个空白哈希表, 为下一次rehash做准备。
  • 渐进的rehash过程

    Redis的rehash动作并不是一次性完成的,而是分多次、渐进式地完成的,原因在于当哈希表里保存的键值对数量很大时, 一次性将这些键值对全部rehash到ht[1]可能会导致服务器在一段时间内停止服务,这个是无法接受的。

    针对这种情况Redis采用了渐进式rehash,过程的详细步骤:

    1. 为ht[1]分配空间,这个过程和普通Rehash没有区别;
    2. 将rehashidx设置为0,表示rehash工作正式开始,同时这个rehashidx是递增的,从0开始表示从数组第一个元素开始rehash。
    3. 在rehash进行期间,每次对字典执行增删改查操作时,顺带将ht[0]哈希表在rehashidx索引上的键值对rehash到 ht[1],完成后将rehashidx加1,指向下一个需要rehash的键值对。
    4. 随着字典操作的不断执行,最终ht[0]的所有键值对都会被rehash至ht[1],再将rehashidx属性的值设为-1来表示 rehash操作已完成。

    渐进式 rehash的思想在于将rehash键值对所需的计算工作分散到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的阻塞问题

zipList的实现

简介

zipList是reids的一个底层的结构,它能用于List、Hash、Zset等的实现。

ziplist是由一系列特殊编码的连续内存块组成的顺序存储结构,类似于数组,ziplist在内存中是连续存储的,但是不同于数组,为了节省内存 ziplist的每个元素所占的内存大小可以不同(数组中叫元素,ziplist叫节点entry,下文都用“节点”),每个节点可以用来存储一个整数或者一个字符串。

它可以存储string与integer值,integer会被编码成对应的数值而不是一串字符串。

特点:顺序存储不定长元素数字字符的压缩

结构

ziplist
  • zlbytes,表示整个压缩列表占用字节数,包括其自身4字节
  • zltail,表示到最后一位节点的偏移量,从而能O(1)复杂度获取末尾节点
  • zllen,表示压缩列表中节点个数。
  • entry,压缩列表中的每个节点,其中节点长度由保存内容定义
  • zlend,用于标记压缩列表结尾,使用特殊值255

以创建新的ziplist为例看看这几个参数

/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    ZIPLIST_LENGTH(zl) = 0;
    zl[bytes-1] = ZIP_END;
    return zl;
}

/* Resize the ziplist. */
unsigned char *ziplistResize(unsigned char *zl, unsigned int len) {
    zl = zrealloc(zl,len);
    ZIPLIST_BYTES(zl) = intrev32ifbe(len);
    zl[len-1] = ZIP_END;
    return zl;
}

通过zmalloc申请空间,申请的空间会比bytes在头部多出一个PREFIX_SIZE的长度,intrev32ifbe存在一个大小端的转化

entry的定义如下: 

typedef struct zlentry {
    unsigned int prevrawlensize; /* Bytes used to encode the previous entry len*/
    unsigned int prevrawlen;     /* Previous entry len. */
    unsigned int lensize;        /* Bytes used to encode this entry type/len.
                                    For example strings have a 1, 2 or 5 bytes
                                    header. Integers always use a single byte.*/
    unsigned int len;            /* Bytes used to represent the actual entry.
                                    For strings this is just the string length
                                    while for integers it is 1, 2, 3, 4, 8 or
                                    0 (for 4 bit immediate) depending on the
                                    number range. */
    unsigned int headersize;     /* prevrawlensize + lensize. */
    unsigned char encoding;      /* Set to ZIP_STR_* or ZIP_INT_* depending on
                                    the entry encoding. However for 4 bits
                                    immediate integers this can assume a range
                                    of values and must be range-checked. */
    unsigned char *p;            /* Pointer to the very start of the entry, that
                                    is, this points to prev-entry-len field. */
} zlentry;

主要包括前一元素的长度,本元素的长度,encoding的方式,以及内容。

encoding编码

###########字符串###############
 #### encoding部分分为三种类型:1字节、2字节、5字节 ####
 #### 最高2bit表示是哪种长度的字符串 分别是00 01 10 各自对应1字节 2字节 5字节 ####

 #### 当最高2bit=00时 表示encoding=1字节 剩余6bit 2^6=64 可表示范围0~63####
 #### 当最高2bit=01时 表示encoding=2字节 剩余14bit 2^14=16384 可表示范围0~16383####
 #### 当最高2bit=10时 表示encoding=5字节 比较特殊 用后4字节 剩余32bit 2^32=42亿多####
 * |00pppppp| - 1 byte
 *      String value with length less than or equal to 63 bytes (6 bits).
 *      "pppppp" represents the unsigned 6 bit length.
 * |01pppppp|qqqqqqqq| - 2 bytes
 *      String value with length less than or equal to 16383 bytes (14 bits).
 *      IMPORTANT: The 14 bit number is stored in big endian.
 * |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes
 *      String value with length greater than or equal to 16384 bytes.
 *      Only the 4 bytes following the first byte represents the length
 *      up to 32^2-1. The 6 lower bits of the first byte are not used and
 *      are set to zero.
 *      IMPORTANT: The 32 bit number is stored in big endian.

########################整数####################*
 *#### 高2bit固定为11 其后2bit 分别为00 01 10 11 表示存储的整数类型
 * |11000000| - 3 bytes
 *      Integer encoded as int16_t (2 bytes).
 * |11010000| - 5 bytes
 *      Integer encoded as int32_t (4 bytes).
 * |11100000| - 9 bytes
 *      Integer encoded as int64_t (8 bytes).
 * |11110000| - 4 bytes
 *      Integer encoded as 24 bit signed (3 bytes).
 * |11111110| - 2 bytes
 *      Integer encoded as 8 bit signed (1 byte).
 * |1111xxxx| - (with xxxx between 0000 and 1101) immediate 4 bit integer.
 *      Unsigned integer from 0 to 12. The encoded value is actually from
 *      1 to 13 because 0000 and 1111 can not be used, so 1 should be
 *      subtracted from the encoded 4 bit value to obtain the right value.
 * |11111111| - End of ziplist special entry.

这里有2个例子:

ziplist示例1 ziplist示例2

prevlen属性

最后来说一下prevlen这个属性,该属性也比较关键,前面一直在说压缩列表是为了节约内存设计的,然而prevlen属性就恰好起到了这个作用,回想一下链表要想获取前面的节点需要使用指针实现,压缩列表由于元素的多样性也无法像数组一样来实现,所以使用prevlen属性记录前一个节点的大小来进行指向。

prevlen属性以字节为单位,记录了压缩列表中前一个节点的长度,其长度可以是 1 字节或者 5 字节:

  1. 如果前一节点的长度小于254字节,那么prevlen属性的长度为1字节, 前一节点的长度就保存在这一个字节里面。
  2. 如果前一节点的长度大于等于254字节,那么prevlen属性的长度为5字节,第一字节会被设置为0xFE,之后的四个字节则用于保存前一节点的长度。

压缩

在添加新的entry(__ziplistInsert)时,会调用zipTryEncoding函数,在这个函数中,会尝试将数值字符串转换成long long来存储,进而减少内存的使用

int zipTryEncoding(unsigned char *entry, unsigned int entrylen, long long *v, unsigned char *encoding) {
    long long value;

    if (entrylen >= 32 || entrylen == 0) return 0;
    if (string2ll((char*)entry,entrylen,&value)) {
        /* Great, the string can be encoded. Check what's the smallest
         * of our encoding types that can hold this value. */
        if (value >= 0 && value <= 12) {
            *encoding = ZIP_INT_IMM_MIN+value;
        } else if (value >= INT8_MIN && value <= INT8_MAX) {
            *encoding = ZIP_INT_8B;
        } else if (value >= INT16_MIN && value <= INT16_MAX) {
            *encoding = ZIP_INT_16B;
        } else if (value >= INT24_MIN && value <= INT24_MAX) {
            *encoding = ZIP_INT_24B;
        } else if (value >= INT32_MIN && value <= INT32_MAX) {
            *encoding = ZIP_INT_32B;
        } else {
            *encoding = ZIP_INT_64B;
        }
        *v = value;
        return 1;
    }
    return 0;
}

过程主要是在string2ll中实现。

zset的实现

简介

ZSet结构同时包含一个字典和一个跳跃表,跳跃表按score从小到大保存所有集合元素。字典保存着从member到score的映射。两种结构通过指针共享相同元素的member和score,不浪费额外内存。

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

结构

zset

前面讲过了字典的实现,这里主要来学习ziplist的实现。

有序链表

下边一个有序链表:

有序链表

从该有序表中搜索元素 < 23, 43, 59 > ,需要比较的次数分别为 < 2, 4, 6 >,总共比较的次数为 2 + 4 + 6 = 12 次。有没有优化的算法吗? 链表是有序的,但不能使用二分查找。类似二叉搜索树,我们把一些节点提取出来,作为索引。得到如下结构:

有序链表2

这里我们把 < 14, 34, 50, 72 > 提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。 我们还可以再从一级索引提取一些元素出来,作为二级索引。

跳表

跳表具有如下性质:

  1. 由很多层结构组成
  2. 每一层都是一个有序的链表
  3. 最底层(Level 1)的链表包含所有元素
  4. 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
  5. 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。

以下跳表的结构。其中 -1 表示 INT_MIN, 链表的最小值,1 表示 INT_MAX,链表的最大值。

跳表2
  • 查询的算法如下:
/* 如果存在 x, 返回 x 所在的节点, 
 * 否则返回 x 的后继节点 */  

find(x)   
{  
    p = top;  
    while (1) {  
        while (p->next->key < x)  
            p = p->next;  
        if (p->down == NULL)   
            return p->next;  
        p = p->down;  
    }  
}  

跳表的神奇之处还在于它插入层数的随机上。

跳表的插入

先确定该元素要占据的层数 K(采用丢硬币的方式,这完全是随机的)

然后在 Level 1 ... Level K 各个层的链表都插入元素。

例子:插入 119, K = 2

跳表的插入

这里redis的实现与其他跳表稍有不同,redis实现会指定最大的层数MaxLevel

  1. 指定节点最大层数 MaxLevel,指定概率 p, 默认层数 lvl 为1
  2. 生成一个0~1的随机数r,若r<p,且lvl<MaxLevel ,则lvl ++
  3. 重复第 2 步,直至生成的r >p 为止,此时的 lvl 就是要插入的层数。

实现src/z_set.c:

/* Returns a random level for the new skiplist node we are going to create.
 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
 * (both inclusive), with a powerlaw-alike distribution where higher
 * levels are less likely to be returned. */
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

我们很容易看出,产生越高的节点层数出现概率越低,无论如何层数总是满足幂次定律越大的数出现的概率越小。

层数的期望与p的关系为:L = 1/(1-p)

内存回收机制

内存的回收包括2个策略:1. 过期键值对的删除,2. 内存淘汰制。

当redis在正常的内存范围内(50%占比内)运行时,只对过期的键值对删除即可,当接近满负荷时,就会启用内存淘汰制。

键值对过期

要实施对键值对的删除我们需要明白如下几点:

  • 带过期超时的键值对存储在哪里
  • 如何判断带超时的键值对是否可以被删除了?

键值对的存储

src/server.h下的源码:

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

本质上讲redis就是一个大的字典,来看看这种图:

键值对存储

redisDb的expires成员的类型也是dict,和键值对是一样的,本质上expires是dict的子集,expires保存的是所有带过期的键值对,称之为过期字典吧,它才是我们研究的重点。

对于键,我们可以设置绝对和相对过期时间、以及查看剩余时间:

  1. 使用EXPIRE和PEXPIRE来实现键值对的秒级和毫秒级生存时间设定,这是相对时长的过期设置
  2. 使用EXPIREAT和EXPIREAT来实现键值对在某个秒级和毫秒级时间戳时进行过期删除,属于绝对过期设置
  3. 通过TTL和PTTL来查看带有生存时间的键值对的剩余过期时间

过期字典expires和键值对空间dict存储的内容并不完全一样,过期字典expires的key是指向Redis对应对象的指针,其value是long long型的unix时间戳,前面的EXPIRE和PEXPIRE相对时长最终也会转换为时间戳,来看下过期字典expires的结构,:

过期键值对的存储

判断键是否过期可删除,需要先查过期字典是否存在该值,如果存在则进一步判断过期时间戳和当前时间戳的相对大小,做出删除判断.

内存淘汰制

为了保证Redis的安全稳定运行,设置了一个max-memory的阈值,那么当内存用量到达阈值,新写入的键值对无法写入,此时就需要内存淘汰机制,在Redis的配置中有几种淘汰策略可以选择,详细如下:

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错;
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中移除最近最少使用的 key;
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中随机移除某个 key;
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key;
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key;
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除;

后三种策略都是针对过期字典的处理,但是在过期字典为空时会noeviction一样返回写入失败,毫无策略地随机删除也不太可取,所以一般选择第二种allkeys-lru基于LRU策略进行淘汰。

个人认为antirez一向都是工程化思维,善于使用概率化设计来做近似实现,LRU算法也不例外,Redis中实现了近似LRU算法,并且经过几个版本的迭代效果已经比较接近理论LRU算法的效果了,这个也是个不错的内容,由于篇幅限制,本文计划后续单独讲LRU算法时再进行详细讨论。

过期健删除策略强调的是对过期健的操作,如果有健过期而内存足够,Redis不会使用内存淘汰机制来腾退空间,这时会优先使用过期健删除策略删除过期健。

内存淘汰机制强调的是对内存数据的淘汰操作,当内存不足时,即使有的健没有到达过期时间或者根本没有设置过期也要根据一定的策略来删除一部分,腾退空间保证新数据的写入。

持久化机制

Redis提供了持久化两大利器:RDB和AOF

  1. RDB 将数据库快照以二进制的方式保存到磁盘中。
  2. AOF 以协议文本方式,将所有对数据库进行过写入的命令和参数记录到 AOF 文件,从而记录数据库状态。

RDB

RDB文件适合数据的容灾备份与恢复,通过RDB文件恢复数据库耗时较短,可以快速恢复数据。

RDB持久化只会周期性的保存数据,在未触发下一次存储时服务宕机,就会丢失增量数据。当数据量较大的情况下,fork子进程这个操作很消耗cpu,可能会发生长达秒级别的阻塞情况。

SAVE是阻塞式持久化,执行命令时Redis主进程把内存数据写入到RDB文件中直到创建完毕,期间Redis不能处理任何命令。

BGSAVE属于非阻塞式持久化,创建一个子进程把内存中数据写入RDB文件里同时主进程处理命令请求。
bgsave

RDB方式的持久化是通过快照实现的,符合条件时Redis会自动将内存数据进行快照并存储在硬盘上,以BGSAVE为例,一次完整数据快照的过程:

  1. Redis使用fork函数创建子进程;
  2. 父进程继续接收并处理命令请求,子进程将内存数据写入临时文件;
  3. 子进程写入所有数据后会用临时文件替换旧RDB文件;

Redis在进行快照过程中不会修改RDB文件,只有快照结束后才会将旧的文件替换成新的,也就是任何时候RDB文件都是完整的。

我们可以通过定时备份RDB文件来实现Redis数据库备份,RDB文件是经过压缩的,占用的空间会小于内存中的数据大小。

AOF

在使用AOF持久化方式时,Redis会将每一个收到的写命令都通过Write函数追加到文件中类似于MySQL的binlog。换言之AOF是通过保存对redis服务端的写命令来记录数据库状态的。

AOF文件有自己的存储协议格式:

[redis@abc]$ more appendonly.aof 
*2     # 2个参数
$6     # 第一个参数长度为 6
SELECT     # 第一个参数
$1     # 第二参数长度为 1
8     # 第二参数
*3     # 3个参数
$3     # 第一个参数长度为 4
SET     # 第一个参数
$4     # 第二参数长度为 4
name     # 第二个参数
$4     # 第三个参数长度为 4
Jhon     # 第二参数长度为 4

AOF的配置:

[redis@abc]$ more ~/redis/conf/redis.conf
dir "/data/dbs/redis/abcd"           #AOF文件存放目录
appendonly yes                       #开启AOF持久化,默认关闭
appendfilename "appendonly.aof"      #AOF文件名称(默认)
appendfsync no                       #AOF持久化策略
auto-aof-rewrite-percentage 100      #触发AOF文件重写的条件(默认)
auto-aof-rewrite-min-size 64mb       #触发AOF文件重写的条件(默认)

AOF的工作流程:

aof文件写入

当开启AOF后,服务端每执行一次写操作就会把该条命令追加到一个单独的AOF缓冲区的末尾,然后把AOF缓冲区的内容写入AOF文件里,由于磁盘缓冲区的存在写入AOF文件之后,并不代表数据已经落盘了,而何时进行文件同步则是根据配置的appendfsync来进行配置:

appendfsync选项:always、everysec和no:

  • always:服务器在每执行一个事件就把AOF缓冲区的内容强制性的写入硬盘上的AOF文件里,保证了数据持久化的完整性,效率是最慢的但最安全的;
  • everysec:服务端每隔一秒才会进行一次文件同步把内存缓冲区里的AOF缓存数据真正写入AOF文件里,兼顾了效率和完整性,极端情况服务器宕机只会丢失一秒内对Redis数据库的写操作;
  • no:表示默认系统的缓存区写入磁盘的机制,不做程序强制,数据安全性和完整性差一些。

AOF的重写机制:

随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩的目的。把Redis进程内的数据转化为写命令同步到新AOF文件的过程。AOF重写过程分为手动触发和自动触发:

  1. 手动触发:直接调用bgrewriteaof。

  2. 自动触发:根据auto-aof-rewrite-min-size(默认64M)和auto-aof-rewrite-percentage参数确定自动触发时机。

    • 自动触发时机 = (aof_current_size > auto-aof-rewrite-min-size) && (aof_current_size / aof_base_size) > auto-aof-rewrite-percentage。
    • 当前AOF文件大小:aof_current_size。
    • 上一次重写后的AOF文件空间大小:aof_base_size。
    • 其中aof_current_size和aof_base_size可以在命令info persistence的结果中查看到。

    其过程如下:

    aof的rewrite

    AOF重写流程如下:

    1. 执行AOF重写请求。
    2. 如果当前进程正在执行AOF重写,请求不执行,直接返回。
    3. 如果当前进程正在执行bgsave操作,重写命令延迟到bgsave完成之后再执行。
    4. 主进程执行fork操作完成后,继续响应其他命令 。所有修改命令继续写入aof_buf缓冲区中并根据appendfsync策略同步到硬盘,保证原有AOF机制正确性。
    5. fork操作运用写时复制技术,子进程只能共享fork操作时的内存数据。子进程根据内存快照,按照命令合并规则写入到新的AOF文件。每次写入硬盘数据量由aof-rewrite-incremental-fsync控制,默认为32M。
    6. 父进程将重写缓冲区内的数据写入到新的AOF文件中。
    7. 使用新的AOF文件替换老文件,完成AOF重写。

​ 数据的恢复

redis数据的恢复

先开AOF,然后看RDB

拷贝 AOF 文件到 Redis 的数据目录,启动 redis-server AOF 的数据恢复过程:Redis 虚拟一个客户端,读取AOF文件恢复 Redis 命令和参数,然后执行命令从而恢复数据,这些过程主要在loadAppendOnlyFile() 中实现。

拷贝 RDB 文件到 Redis 的数据目录,启动 redis-server即可,因为RDB文件和重启前保存的是真实数据而不是命令状态和参数。

Redis 4.0 提供了更好的混合持久化选项: 创建出一个同时包含 RDB 数据和 AOF 数据的 AOF 文件, 其中 RDB 数据位于 AOF 文件的开头, 它们储存了服务器开始执行重写操作时的数据库状态,至于那些在重写操作执行之后执行的 Redis 命令, 则会继续以 AOF 格式追加到 AOF 文件的末尾, 也即是 RDB 数据之后。

混合恢复
# redis 

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×