Redis 攻略(中)

0x07. Redis是如何做持久化的及其基本原理

    通俗讲持久化就是将内存中的数据写入非易失介质中,比如机械磁盘和SSD。在服务器发生宕机时,作为内存数据库Redis里的所有数据将会丢失,因此Redis提供了持久化两大利器:RDB和AOF

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


  • 查看RDB配置

[redis@abc]$ cat /abc/redis/conf/redis.conf   save 900 1  save 300 10  save 60 10000  dbfilename "dump.rdb" dir "/data/dbs/redis/rdbstro" 

前三行都是对触发RDB的一个条件, 如第一行表示每900秒钟有一条数据被修改则触发RDB,依次类推;只要一条满足就会进行RDB持久化;

第四行dbfilename指定了把内存里的数据库写入本地文件的名称,该文件是进行压缩后的二进制文件;

第五行dir指定了RDB二进制文件存放目录 ;


  • 修改RDB配置

在命令行里进行配置,服务器重启才会生效:
[redis@abc]$ bin/redis-cli127.0.0.1:6379> CONFIG GET save 1) "save"2) "900 1 300 10 60 10000"127.0.0.1:6379> CONFIG SET save "21600 1000" OK

7.1 RDB的SAVE和BGSAVE

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

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

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

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

如图展示了bgsave的简单流程


  • BGSAVE实现细节


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

  1. Redis使用fork函数创建子进程;

  2. 父进程继续接收并处理命令请求,子进程将内存数据写入临时文件;

  3. 子进程写入所有数据后会用临时文件替换旧RDB文件;


执行fork的时OS会使用写时拷贝策略,对子进程进行快照过程优化。


      Redis在进行快照过程中不会修改RDB文件,只有快照结束后才会将旧的文件替换成新的,也就是任何时候RDB文件都是完整的。我们可以通过定时备份RDB文件来实现Redis数据库备份,RDB文件是经过压缩的,占用的空间会小于内存中的数据大小。除了自动快照还可以手动发送SAVE或BGSAVE命令让Redis执行快照。通过RDB方式实现持久化,由于RDB保存频率的限制,如果数据很重要则考虑使用AOF方式进行持久化。


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

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

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

AOF配置:

[redis@abc]$ more ~/redis/conf/redis.confdir "/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文件之后,并不代表数据已经落盘了,而何时进行文件同步则是根据配置的appendfsync来进行配置:

appendfsync选项:always、everysec和no
  • always:服务器在每执行一个事件就把AOF缓冲区的内容强制性的写入硬盘上的AOF文件里,保证了数据持久化的完整性,效率是最慢的但最安全的;

  • everysec:服务端每隔一秒才会进行一次文件同步把内存缓冲区里的AOF缓存数据真正写入AOF文件里,兼顾了效率和完整性,极端情况服务器宕机只会丢失一秒内对Redis数据库的写操作;

  • no:表示默认系统的缓存区写入磁盘的机制,不做程序强制,数据安全性和完整性差一些。


AOF比RDB文件更大,并且在存储命令的过程中增长更快,为了压缩AOF的持久化文件,Redis提供了重写机制以此来实现控制AOF文件的增长。

AOF重写实现的理论基础是这样的:
  1. 执行set hello world 50次 

  2. 最后执行一次 set hello china

  3. 最终对于AOF文件而言前面50次都是无意义的,AOF重写就是将key只保存最后的状态。

  4. 重写期间的数据一致性问题


子进程在进行 AOF 重写期间, 主进程还需要继续处理命令, 而新的命令可能对现有的数据进行修改, 会出现数据库的数据和重写后的 AOF 文件中的数据不一致。

因此Redis 增加了一个 AOF 重写缓存, 这个缓存在 fork 出子进程之后开始启用, Redis 主进程在接到新的写命令之后, 除了会将这个写命令的协议内容追加到现有的 AOF 文件之外, 还会追加到这个缓存中。



当子进程完成 AOF 重写之后向父进程发送一个完成信号, 父进程在接到完成信号之后会调用信号处理函数,完成以下工作:
  1. 将 AOF 重写缓存中的内容全部写入到新 AOF 文件中

  2. 对新的 AOF 文件进行改名,覆盖原有的 AOF 文件

  3. AOF重写的阻塞性


整个 AOF 后台重写过程中只有最后写入缓存和改名操作会造成主进程阻塞, 在其他时候AOF 后台重写都不会对主进程造成阻塞, 将 AOF 重写对性能造成的影响降到了最低。

AOF 重写可以由用户通过调用 BGREWRITEAOF 手动触发。
服务器在 AOF 功能开启的情况下,会维持以下三个变量:
  1. 当前 AOF 文件大小 

  2. 最后一次 重写之后, AOF 文件大小的变量 

  3. AOF文件大小增长百分比

每次当 serverCron 函数执行时, 它都会检查以下条件是否全部满足, 如果是的话, 就会触发自动的 AOF 重写:
  1. 没有 BGSAVE 命令在进行 防止于RDB的冲突

  2. 没有 BGREWRITEAOF 在进行 防止和手动AOF冲突

  3. 当前 AOF 文件大小至少大于设定值 基本要求 太小没意义

  4. 当前 AOF 文件大小和最后一次 AOF 重写后的大小之间的比率大于等于指定的增长百分比

7.3 Redis的数据恢复

Redis的数据恢复优先级

  1. 如果只配置 AOF ,重启时加载 AOF 文件恢复数据;

  2. 如果同时配置了 RDB 和 AOF ,启动只加载 AOF 文件恢复数据;

  3. 如果只配置 RDB,启动将加载 dump 文件恢复数据。

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

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


新型的混合型持久化
RDB和AOF都有各自的缺点:
  1. RDB是每隔一段时间持久化一次, 故障时就会丢失宕机时刻与上一次持久化之间的数据,无法保证数据完整性

  2. AOF存储的是指令序列, 恢复重放时要花费很长时间并且文件更大


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

持久化实战

在实际使用中需要根据Redis作为主存还是缓存、数据完整性和缺失性的要求、CPU和内存情况等诸多因素来确定适合自己的持久化方案,一般来说稳妥的做法包括:
  1. 最安全的做法是RDB与AOF同时使用,即使AOF损坏无法修复,还可以用RDB来恢复数据,当然在持久化时对性能也会有影响。

  2. Redis当简单缓存,没有缓存也不会造成缓存雪崩只使用RDB即可。

  3. 不推荐单独使用AOF,因为AOF对于数据的恢复载入比RDB慢,所以使用AOF的时候,最好还是有RDB作为备份。

  4. 采用新版本Redis 4.0的持久化新方案。

0x08.谈谈Redis的ZIPLIST的底层设计和实现

先不看Redis的对ziplist的具体实现,我们先来想一下如果我们来设计这个数据结构需要做哪些方面的考虑呢?思考式地学习收获更大呦!


  • 考虑点1:连续内存的双面性

连续型内存减少了内存碎片,但是连续大内存又不容易满足。这个非常好理解,你和好基友三人去做地铁,你们三个挨着坐肯定不浪费空间,但是地铁里很多人都是单独出行的,大家都不愿意紧挨着,就这样有2个的位置有1个的位置,可是3个连续的确实不好找呀,来张图:



  • 考虑点2: 压缩列表承载元素的多样性

待设计结构和数组不一样,数组是已经强制约定了类型,所以我们可以根据元素类型和个数来确定索引的偏移量,但是压缩列表对元素的类型没有约束,也就是说不知道是什么数据类型和长度,这个有点像TCP粘包拆包的做法了,需要我们指定结尾符或者指定单个存储的元素的长度,要不然数据都粘在一起了。


  • 考虑点3:属性的常数级耗时获取

就是说我们解决了前面两点考虑,但是作为一个整体,压缩列表需要常数级消耗提供一些总体信息,比如总长度、已存储元素数量、尾节点位置(实现尾部的快速插入和删除)等,这样对于操作压缩列表意义很大。


  • 考虑点4:数据结构对增删的支持

理论上我们设计的数据结构要很好地支持增删操作,当然凡事必有权衡,没有什么数据结构是完美的,我们边设计边调整吧。


  • 考虑点5:如何节约内存

我们要节约内存就需要特殊情况特殊处理,所谓变长设计,也就是不像双向链表一样固定使用两个pre和next指针来实现,这样空间消耗更大,因此可能需要使用变长编码。

ziplist总体结构

大概想了这么多,我们来看看Redis是如何考虑的,笔者又画了一张总览简图:


从图中我们基本上可以看到几个主要部分:zlbytes、zltail、zllen、zlentry、zlend。


来解释一下各个属性的含义,借鉴网上一张非常好的图,其中红线验证了我们的考虑点2、绿线验证了我们的考虑点3:




来看下ziplist.c中对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;
}
zlentry的实现
  • encoding编码和content存储

我们再来看看zlentry的实现,encoding的具体内容取决于content的类型和长度,其中当content是字符串时encoding的首字节的高2bit表示字符串类型,当content是整数时,encoding的首字节高2bit固定为11,从Redis源码的注释中可以看的比较清楚,笔者对再做一层汉语版的注释

 /*
 ###########字符串存储详解###############
 #### 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=11时 表示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.
*/

content保存节点内容,其内容可以是字节数组和各种类型的整数,它的类型和长度决定了encoding的编码,对照上面的注释来看两个例子吧:


保存字节数组:编码的最高两位00表示节点保存的是一个字节数组,编码的后六位001011记录了字节数组的长度11,content 属性保存着节点的值 "hello world"。


保存整数:编码为11000000表示节点保存的是一个int16_t类型的整数值,content属性保存着节点的值10086。

  • prevlen属性

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

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


  1. 如果前一节点的长度小于254字节,那么prevlen属性的长度为1字节, 前一节点的长度就保存在这一个字节里面。

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


思考:注意一下这里的第一字节设置的是0xFE而不是0xFF,想下这是为什么呢?


没错!前面提到了zlend是个特殊值设置为0xFF表示压缩列表的结束,因此这里不可以设置为0xFF,关于这个问题在redis有个issue,有人提出来antirez的ziplist中的注释写的不对,最终antirez发现注释写错了,然后愉快地修改了,哈哈!

再思考一个问题,为什么prevlen的长度要么是1字节要么是5字节呢?为啥没有2字节、3字节、4字节这些中间态的长度呢?要解答这个问题就引出了今天的一个关键问题:连锁更新问题。


连锁更新问题

试想这样一种增加节点的场景:

如果在压缩列表的头部增加一个新节点,并且长度大于254字节,所以其后面节点的prevlen必须是5字节,然而在增加新节点之前其prevlen是1字节,必须进行扩展,极端情况下如果一直都需要扩展那么将产生连锁反应:



试想另外一种删除节点的场景

如果需要删除的节点时小节点,该节点前面的节点是大节点,这样当把小节点删除时,其后面的节点就要保持其前面大节点的长度,面临着扩展的问题:


理解了连锁更新问题,再来看看为什么要么1字节要么5字节的问题吧,如果是2-4字节那么可能产生连锁反应的概率就更大了,相反直接给到最大5字节会大大降低连锁更新的概率,所以笔者也认为这种内存的小小浪费也是值得的。


从ziplist的设计来看,压缩列表并不擅长修改操作,这样会导致内存拷贝问题,并且当压缩列表存储的数据量超过某个阈值之后查找指定元素带来的遍历损耗也会增加。

0x09.谈谈Redis的Zset和跳跃链表问题

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

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

ZSet中的字典和跳表布局:


注:图片源自网络

9.1 ZSet中跳跃链表的实现细节

  • 随机层数的实现原理

跳表是一个概率型的数据结构,元素的插入层数是随机指定的。Willam Pugh在论文中描述了它的计算过程如下:
  1. 指定节点最大层数 MaxLevel,指定概率 p, 默认层数 lvl 为1 

  2. 生成一个0~1的随机数r,若r<p,且lvl<MaxLevel ,则lvl ++

  3. 重复第 2 步,直至生成的r >p 为止,此时的 lvl 就是要插入的层数。


论文中生成随机层数的伪码:



在Redis中对跳表的实现基本上也是遵循这个思想的,只不过有微小差异,看下Redis关于跳表层数的随机源码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;}
其中两个宏的定义在redis.h中:
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */
可以看到while中的:
(random()&0xFFFF) < (ZSKIPLIST_P*0xFFFF)
第一眼看到这个公式,因为涉及位运算有些诧异,需要研究一下Antirez为什么使用位运算来这么写?
最开始的猜测是random()返回的是浮点数[0-1],于是乎在线找了个浮点数转二进制的工具,输入0.25看了下结果:


可以看到0.25的32bit转换16进制结果为0x3e800000,如果与0xFFFF做与运算结果是0,好像也符合预期,再试一个0.5:


可以看到0.5的32bit转换16进制结果为0x3f000000,如果与0xFFFF做与运算结果还是0,不符合预期。

我印象中C语言的math库好像并没有直接random函数,所以就去Redis源码中找找看,于是下载了3.2版本代码,也并没有找到random()的实现,不过找到了其他几个地方的应用:

random()在dict.c中的使用


random()在cluster.c中的使用


看到这里的取模运算,后知后觉地发现原以为random()是个[0-1]的浮点数,但是现在看来是uint32才对,这样Antirez的式子就好理解了。
ZSKIPLIST_P*0xFFFF
由于ZSKIPLIST_P=0.25,所以相当于0xFFFF右移2位变为0x3FFF,假设random()比较均匀,在进行0xFFFF高16位清零之后,低16位取值就落在0x0000-0xFFFF之间,这样while为真的概率只有1/4。更一般地说为真的概率为1/ZSKIPLIST_P。

对于随机层数的实现并不统一,重要的是随机数生成,LevelDB中对跳表层数的生成代码:

template <typename Key, typename Value>int SkipList<Key, Value>::randomLevel() {
static const unsigned int kBranching = 4; int height = 1; while (height < kMaxLevel && ((::Next(rnd_) % kBranching) == 0)) { height++; } assert(height > 0); assert(height <= kMaxLevel); return height;}
uint32_t Next( uint32_t& seed) { seed = seed & 0x7fffffffu;
if (seed == 0 || seed == 2147483647L) { seed = 1;  } static const uint32_t M = 2147483647L; static const uint64_t A = 16807; uint64_t product = seed * A; seed = static_cast<uint32_t>((product >> 31) + (product & M)); if (seed > M) { seed -= M; } return seed;}
可以看到leveldb使用随机数与kBranching取模,如果值为0就增加一层,这样虽然没有使用浮点数,但是也实现了概率平衡。

  • 跳表结点的平均层数

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

如果某件事的发生频率和它的某个属性成幂关系,那么这个频率就可以称之为符合幂次定律。幂次定律的表现是少数几个事件的发生频率占了整个发生频率的大部分, 而其余的大多数事件只占整个发生频率的一个小部分。


幂次定律应用到跳表的随机层数来说就是大部分的节点层数都是黄色部分,只有少数是绿色部分,并且概率很低。

定量的分析如下:
  1. 节点层数至少为1,大于1的节点层数满足一个概率分布。

  2. 节点层数恰好等于1的概率为p^0(1-p)。

  3. 节点层数恰好等于2的概率为p^1(1-p)。

  4. 节点层数恰好等于3的概率为p^2(1-p)。

  5. 节点层数恰好等于4的概率为p^3(1-p)。

  6. 依次递推节点层数恰好等于K的概率为p^(k-1)(1-p)


如果要求节点的平均层数,那么也就转换成了求概率分布的期望问题了,灵魂画手大白再次上线:


表中P为概率,V为对应取值,给出了所有取值和概率的可能,因此就可以求这个概率分布的期望了。

方括号里面的式子其实就是高一年级学的等比数列,常用技巧错位相减求和,从中可以看到结点层数的期望值与1-p成反比。对于Redis而言,当p=0.25时结点层数的期望是1.33。

在Redis源码中有详尽的关于插入和删除调整跳表的过程,本文就不展开了,代码并不算难懂,都是纯C写的没有那么多炫技特效,大胆读起来。

全部评论