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-cli
CONFIG GET save
1) "save"
2) "900 1 300 10 60 10000"
"21600 1000" CONFIG SET save
OK
7.1 RDB的SAVE和BGSAVE
如图展示了bgsave的简单流程:
BGSAVE实现细节
Redis使用fork函数创建子进程;
父进程继续接收并处理命令请求,子进程将内存数据写入临时文件;
子进程写入所有数据后会用临时文件替换旧RDB文件;
执行fork的时OS会使用写时拷贝策略,对子进程进行快照过程优化。
7.2 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配置:
[ ]$ more ~/redis/conf/redis.conf
dir "/data/dbs/redis/abcd"
appendonly yes
appendfilename "appendonly.aof"
appendfsync no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
always:服务器在每执行一个事件就把AOF缓冲区的内容强制性的写入硬盘上的AOF文件里,保证了数据持久化的完整性,效率是最慢的但最安全的;
everysec:服务端每隔一秒才会进行一次文件同步把内存缓冲区里的AOF缓存数据真正写入AOF文件里,兼顾了效率和完整性,极端情况服务器宕机只会丢失一秒内对Redis数据库的写操作;
no:表示默认系统的缓存区写入磁盘的机制,不做程序强制,数据安全性和完整性差一些。
执行set hello world 50次
最后执行一次 set hello china
最终对于AOF文件而言前面50次都是无意义的,AOF重写就是将key只保存最后的状态。
重写期间的数据一致性问题
将 AOF 重写缓存中的内容全部写入到新 AOF 文件中
对新的 AOF 文件进行改名,覆盖原有的 AOF 文件
AOF重写的阻塞性
当前 AOF 文件大小
最后一次 重写之后, AOF 文件大小的变量
AOF文件大小增长百分比
没有 BGSAVE 命令在进行 防止于RDB的冲突
没有 BGREWRITEAOF 在进行 防止和手动AOF冲突
当前 AOF 文件大小至少大于设定值 基本要求 太小没意义
当前 AOF 文件大小和最后一次 AOF 重写后的大小之间的比率大于等于指定的增长百分比
7.3 Redis的数据恢复
Redis的数据恢复优先级
如果只配置 AOF ,重启时加载 AOF 文件恢复数据;
如果同时配置了 RDB 和 AOF ,启动只加载 AOF 文件恢复数据;
如果只配置 RDB,启动将加载 dump 文件恢复数据。
RDB是每隔一段时间持久化一次, 故障时就会丢失宕机时刻与上一次持久化之间的数据,无法保证数据完整性
AOF存储的是指令序列, 恢复重放时要花费很长时间并且文件更大
持久化实战
最安全的做法是RDB与AOF同时使用,即使AOF损坏无法修复,还可以用RDB来恢复数据,当然在持久化时对性能也会有影响。
Redis当简单缓存,没有缓存也不会造成缓存雪崩只使用RDB即可。
不推荐单独使用AOF,因为AOF对于数据的恢复载入比RDB慢,所以使用AOF的时候,最好还是有RDB作为备份。
采用新版本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 字节:
如果前一节点的长度小于254字节,那么prevlen属性的长度为1字节, 前一节点的长度就保存在这一个字节里面。
如果前一节点的长度大于等于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和跳跃链表问题
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
ZSet中的字典和跳表布局:
注:图片源自网络
9.1 ZSet中跳跃链表的实现细节
随机层数的实现原理
指定节点最大层数 MaxLevel,指定概率 p, 默认层数 lvl 为1
生成一个0~1的随机数r,若r<p,且lvl<MaxLevel ,则lvl ++
重复第 2 步,直至生成的r >p 为止,此时的 lvl 就是要插入的层数。
/* 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 */
(random()&0xFFFF) < (ZSKIPLIST_P*0xFFFF)
random()在dict.c中的使用
random()在cluster.c中的使用
ZSKIPLIST_P*0xFFFF
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;
}
跳表结点的平均层数
如果某件事的发生频率和它的某个属性成幂关系,那么这个频率就可以称之为符合幂次定律。幂次定律的表现是少数几个事件的发生频率占了整个发生频率的大部分, 而其余的大多数事件只占整个发生频率的一个小部分。
节点层数至少为1,大于1的节点层数满足一个概率分布。
节点层数恰好等于1的概率为p^0(1-p)。
节点层数恰好等于2的概率为p^1(1-p)。
节点层数恰好等于3的概率为p^2(1-p)。
节点层数恰好等于4的概率为p^3(1-p)。
依次递推节点层数恰好等于K的概率为p^(k-1)(1-p)
全部评论