Redis中级篇

117 阅读20分钟

🍎Redis底层结构

Redis 摒弃了C字符串,转而采用自己实现的的名为简单动态字符串的抽象类型 简称SDS

传统C字符串是以\0结尾的 若不以这个结尾表示字符数组 C语言中没有String类型,而是通过char[]来表示

🍉SDS的定义

struct sdshdr{
    //记录buf数组中已使用字节的数量
    //即SDS所保存字符串的长度
    int len;
    //记录buf数组中未使用字节的数量
    int free;
    //字节数组用于保存字符串
    char buf[];
}

image.png

SDS遵循了以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里,好处就是SDS可以重用C字符串函数库里的函数

空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作由 SDS函数自动完成

image.png

🍉SDS与C的区别

🍓获取字符串长度复杂度

C字符串并不记录字符长度 这就导致在获取字符长度时需要遍历来统计字符长度 复杂度为O(n)

而SDS本身就有len属性,获取字符长度的复杂度为O(1)

🍓缓冲区溢出

C字符串不记录自身长度,当我们在做字符串拼接时没有给足够的内存来存储拼接的字符串,就会造成缓冲区溢出

SDS会检查是否空间是否满足,如果不满足会自动进行扩容

🍓内存分配次数

C字符串的底层实现总是用N+1个字符长度的数组来存储N个字符,每次增加或者删减字符都需要做一次内存重新分配

增加不重新分配会造成内存溢出 删减不重新分配会造成内存泄漏

在SDS中 数组长度 不一定是字符数量加一,数组里面可以包含未使用的字节,而未使用的通过free属性来记录

通过未使用空间,SDS实现了空间预分配惰性空间释放两种优化策略

🥭空间预分配

空间预分配用于优化SDS的字符串增长操作

当对SDS修改后长度小于1MB,程序会分配和len属性同样大小的未使用空间,也就是len和free是相同的

如修改后 SDS的len将变成13字节 那么该buf的实际长度将是 13+13+1=27 字节

当SDS修改后长度大于1MB,那么程序会分配1MB的未使用空间

如:修改后SDS的len是30MB, buf的实际长度是 30MB+1MB+1byte

🥭惰性空间释放

惰性空间释放用于优化SDS的字符串删减操作

当需要缩短字符串长度时,程序并不会立即使用内存重分配来回收多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用

🍓安全性

C字符串中不允许中间有空格,否则会被误认为是字符串结尾,使得C字符串只能保存文本数据,而不能保存 图片 音频视频之类的二进制数据

🍓兼容C字符串函数

SDS遵循C字符串的以空字符结尾的惯例,就可以直接使用C的一些函数

image.png

🍉数据结构

🍓字典

概念:字典,又称为符号表 关联数组 或映射,是一种用保存键值对的抽象数据结构

作用:

  • 字典中的每个键都是独一无二的,我们可以根据字典中的键来找到对应的值,通过键来更改值或根据键删除整个键值对
  • Redis的数据库就是根据字典来实现的,对数据库的CRUD操作也是字典上操作的
  • 当一个哈希键包含的键值对比较多,又或者键值对中元素都是比较长的字符串时,字典就会做为哈希键的底层实现

🥭实现

Redis的字典使用哈希表作为底层实现.一个哈希表里面可以有多个哈希表节点,每个哈希表节点就保存了字典中的一个键值对

哈希表

哈希表的定义

typeedf struct dictht{
    //哈希表数组
    dictEntry  **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    //总是等于size-1
    unsigned long sizemask;
    //该哈希表已有节点的数量
    unsigned long used;
}

其中table属性是一个数组,数组中的每个元素都指向一个dictEntry,大概样子如图所示

image-20221201101920696.png 每个dictEntry结构保存着一个键值对

size记录了哈希表的大小,即table数组的大小

sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放在table数组的哪个索引上 哈希表节点

哈希表节点用dictEntry结构表示,每个dictEntry结构都保存着一个键值对

typeedf struct dictEntry{
    //键
    void *key;
    //值
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    //指向下个哈希表节点  形成链表
    struct dictEntry *next;
} dictEntry;

key属性保存着键 v属性保存着值 next指向另一个哈希表节点的指针,用来解决键冲突

下图表示将两个索引值相同的k1和k0通过next连接起来

字典

typeedf struct dict{
    //类型特定函数
    dictType *type;
    //私有数据
    void *privadata;
    //哈希表  包含两个哈希表 在rehash中使用
    dictht ht[2];
    //rehash 索引
    //当rehash不在进行时,值为-1
    in trehashidx;
}

type属性指向dictType结构 每个dictType保存了一簇用于操作特定类型键值对函数

privdata属性则保存了需要传给那些类型特定函数的可选参数


typeedf struct dictType{
    //计算哈希值的函数
   unsigned int(*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;

普通字典结构如下:

image-20221201112515801.png

哈希算法

当要将一个新的键值对添加到字典里面的时候,要先根据键计算出哈希值和索引值 再根据索引值将键值对放在相应索引上

比如向字典表中添加一个元素 set name marvin 先会对key做散列运算,将得到的值和哈希表的大小4做取余,假设得到的是3

那么key就会落到3这个位置上

1667038384731-82dae1f5-ee44-49a3-a926-a7b4830d1a66.png

键冲突

当有两个以上的key被分配到同一索引上时,就需要通过dictEntry的next节点连接组成一个链表来解决 rehash

当我们哈希表中存的数据越来越多,哈希冲突的概率就会越来越大。这样所有的键值对冲突后会形成一个链表,查询的效率就由原先的O(1)变成了O(n),所以我们要有一个评估的标准,用来判断是否需要扩缩容。我们通过负载因子来判断是否需要扩缩容

负载因子=used/size

扩容条件:

  • 程序没有执行BGSAVE命令或者BGREWRITEAOF(AOF重写)命令,并且哈希表的负载因子大于等于1
  • 如果程序正在执行BGSAVE或者BGREWRITEAOF(AOF重写)命令并且哈希表的负载因子大于等于5。在执行RDB或者AOF重写操作时,redis会创建当前服务器的子进程执行相应操作,为了避免在子进程存在期间对哈希表进行扩展操作,将扩展因子提高。可以避RDB或者AOF重写时不必要的内存写入操作,最大限度的节约内存。

缩容条件: 负载因子小于0.1 扩容过程:

image.png 1.首先需要为ht[1]分配空间,ht[1]大小为第一个大于等于ht[0].used*2的2n次幂,used是4,4x2=8 8恰好是2的n次方

image.png 2.将ht[0]的四个键值对都rehash到ht[1]

image.png 3.释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,至此 哈希表扩展操作执行完毕

image.png

渐进式rehash

然而redis并不像我画的那样,只有一两个key。一个生产使用的redis可以达到几百上千万个。而redis的核心计算是单线程的,一次性重新散列这么多的key会造成长时间的服务不可用,因此需要采用渐进式的rehash。 在渐进式的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,CRUD过程中的操作会在两个哈希表上进行

步骤:

1.为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。

2.在字典中维持-一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。

3.在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。

4.随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

5.最后将h[1]的地址设置给h[0],并将h[1]设置为null,也就是将新哈希表替换旧hash表。

渐进式rehash的好处在于它采取分而治之,的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量

共存的策略

  • 所有增删改查都会先访问ht[0],再访问ht[1].比如查询会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类
  • rehash期间所有新增的键值对都会添加到h[1]里,保证ht [0]的键值对数量会只减不增,最终会变成空表

🍎单机功能及原理

上一章介绍了redis的数据类型和底层实现,本章将会着重介绍redis的单机数据库的底层细节,包括客户端,服务端,数据过期,持久化,过期键等实现细节。

🍉数据库

redis服务端一般有16个数据库,默认情况下都在0号数据库操作,我们也可以通过命令来实现数据库的切换。

redis> SET msg "hello world"
OK
redis> GET msg
"hello world"
redis> SELECT 2
OK
redis[2]>GET msg
(nil)

不同数据库间的数据都是隔离的,那么redis底层是如何实现这个功能的呢?首先我们要引入redis服务端最重要的一个结构

redisServer。所有的相关信息都在这个结构上,由于字段太多,我们先引入我们关心的字段即可,剩下的之后再慢慢介绍。

struct redisServer{
    //服务器的数据库数量
    int dbnum;
    //一个数组,保存着服务器中的所有数据库
    redisDb *db;
}

服务器会在初始化的时候根据dbnum属性来决定创建多少个数据库,而dbnum的值又来源于配置文件,默认16.

还有个重要的结构,是客户端redisClient,这个结构会在每个客户端连接上的时候创建,每个连接都有一个单独的redisClient结构

typedef struct redisClinet{
    //当前客户端正在使用的数据库
    redisDb *db;
}redisClient;

image.png 如果客户端执行命令select3,那么指针就指向db[3]的位置,就是这么的简单。

Redis目前没有可以返回客户端目标数据库的命令,使用危险命令时需注意自己当前所在数据库

后续你还会发现,redis所有的命令执行,都依赖于数据结构里的数据变更。

每个数据库的结构里又保存着我们上一章提到的字典

typedef struct redisDb{
    //数据库里的字典
    dict *dict;
}redisDb;

image.png

对数据库的增删改查,本质上就是通过客户端指向数据库,查找里面的字典,匹配对应的key,取出对应的value

🍉键过期

redis是如何保存过期时间又是如何让key过期的?接下来就从数据结构的角度,来看看具体的原理。

首先redis设置过期时间有四个方式

EXPIRE <key> <ttl> #命令用于将键key的生存时间设置为ttl秒。
PEXPIRE <key> <ttl> #命令用于将键key的生存时间设置为tt1毫秒。
EXPIREAT <key> <timestamp> #命令用于将键key的过期时间设置为timestamp
PEXPIREAT <key> <timestamp> #命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳。

但实际上在redis的服务端,只有一种方式,就是采用PEXPIREAT这个命令,另外三个命令都会被计算,转化成PEXPIREAT来执行 那么这些过期时间在redis内部是如何被保存的呢?

typedef struct redisDb{
    //数据库里的字典
    dict *dict;
    //过期时间的字典
    dict *expires;
}redisDb;

这个过期字典和键值对字典一模一样,唯一不一样的就是过期字典的值是Long类型存的是时间戳。这里就能体现复用了键对象来节省空间的思想了。

image.png 过期策略

如果一个键过期了,那么什么时候会被删除了呢? 这里有三种策略

  • 定时删除:定时删除是对内存最友好的,但是对cpu不友好。键只要一过期,我们就立马去删除key,释放内存空间。但是这意味着cpu需要不断的去遍历所有的key,来判断对方有没有过期。影响到了我们正常的请求处理。
  • 惰性删除:惰性删除对内存不友好,但是对cpu很友好。只有到key被访问了,我们才会去先判断key是否过期,过期就直接删除key,并返回nil。但是如果有key长时间没有被访问到,内存就会一直被占用
  • 定期删除:整合了上面两者的缺点,每隔一段时间,我们就对一些随机key进行检查,删除里面过期的key。只要设定好频率和执行时间,就能很好的管理key,但是难点就在于频率和执行时间的设定上。

我们都知道redis的主从复制,是采用全量同步+增量同步的方式,增量同步类似于AOF。数据过期的时候,主库会执行一条del命令,这个命令也会记录在主库的AOF中,同时会将该命令传递给从库,让从库也达到数据过期的效果。 RDB对过期键的处理

在执行Save或者BGSAVE命令后,已过期的键不会被保存到新生成的RDB文件中.

载入时

  • 作为主服务器模式运行,未过期的key被载入,过期key会被忽略
  • 作为从服务器模式运行,文件中保存的所有key都会被载入.但在主从同步时,从库会被清空

SAVE就是主线程同步保存,在保存期间不接受外界的命令

BGSAVE会fork一个子进程来保存RDB,在这个期间能够正常接受外界命令,这时候如果有修改的语句,会采用copy on write的方式来进行修改。保存好的新的RDB后,会将新RDB替换旧的RDB。如果期间还有SAVE,或BGSAVE命令执行,会被拒绝,如果有BGREWRITEAOF命令,会在BGSAVE执行结束后再执行

AOF对过期键的处理

AOF (Append Only File) ,顾名思义,就是一条条追加的日志。

当某个键已经过期,但还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键产生任何影响

当被删除后,程序会向AOF文件追加一条DEL命令,显式记录键被删除

复制时对过期键的处理

当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制

主服务器在删除一个过期键后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个键

从服务器只有在接收到主服务器发来的DEL命令后,才会删除过期键

🍉RDB

RDB是持久化的一种方式,我们可以将redis数据库的数据保存为RDB文件,服务器在启动的时候初始化中,又会将RDB文件恢复成redis的缓存数据。

可执行的命令有SAVE和BGSAVE两个命令。

上面,我们已经对SAVE和BGSAVE做了解释

服务器在载入RDB文件时,不会工作直至载入工作完成

RDB触发规则

我们可以通过配置文件配置让服务器自动执行BGSAVE命令

save 900 1 #900s内至少修改1次
save 300 10 #300s内至少修改10次
save 60 10000 #60s内至少修改1000次

底层实现

上述是默认值,会根据该值设置redisServer结构的saveparams属性,该属性是个数组,每个元素都是一个saveparam

struct redisServer{
    //....
    //记录了保存条件的数组
    struct saveparam *saveparams;
    //...
}
struct saveparam{
    //秒数
    time_t seconds;
    //修改数
    int changes;
}

默认值的结构如下图所示

image.png 为了完成上述的功能,redis底层还有两个属性来记录修改次数和上次保存时间

struct redisServer{
    //记录修改次数
    long long dirty;
    //上次执行保存的时间
    time_t lastsave;
}

底层使用周期操作函数serverCron默认每100毫秒检查一次看是否满足条件,满足就执行BGSAVE;

RDB文件恢复

image.png

RDB文件结构

image.png RDB保存的是二进制文件

开头长度为5字节的REDIS字符

db_version 四个字节 记录RDB文件的版本号

databases包含零个或多个数据库

EOF 长度1字节 标志这RDB文件正文内容的结束

check_sum 8字节 由前几个计算得来的校验和 检查RDN文件是否出错或损坏

🍉AOF

例如

redis>SET msg "hello"
OK
redis>SADD fruits "apple" "banana" "cherry"
(integer) 3
redis> RPUSH numbers 128 256 512
(integer) 3

RDB持久化的方式是将msg,fruits,numbers三个键的键值对保存在RDB文件中

AOF持久化是将服务器执行的SET SADD RPUSH 三个命令保存到AOF文件中

AOF 持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)

命令追加

struct redisServer{
    //AOF缓冲区
    sds aof_buf;
}

aof有个缓冲区,里面记录的是所有修改的命令。新的修改命令会被追加在缓冲区的末尾。

写入与同步

redis的主进程就是一个无限的循环,里面会处理文件事件,时间事件等。这些事件都有可能会造成写操作。

所以一个循环的末尾,它都会调用 flushAppendOnlyFile 函数,考虑是否需要将 aof_buf 缓冲区的内容写入和同步到 AOF 文件里,这个过程可以用伪代码表示 三个方法循环处理三样不同的事情

def evenLoop():
   while True:
    # 处理文件事件,接受命令请求以及发送命令回复
    # 处理请求时可能会有新内容被追加到 aof_buf 缓冲区
    processFileEvents()
    # 处理时间事件   例如处理serverCron函数
    processTimeEvents()
    # 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里
    flushAppendOnlyFile()

flushAppendOnlyFile由我们配置的appendfsync选项来决定

  • always:每次都将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件
  • everysec:先将 aof_buf 缓冲区中的所有内容,写入到 AOF 文件,如果上次同步 AOF 文件的时间距离现在超过一秒钟,那么再次对 AOF 文件进行同步,并且这个操作是由一个线程专门负责执行的
  • no:将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,不对 AOF 文件进行同步,何时同步由操作系统来决定

上面三种操作都会将文件写入,但是是否同步就根据选项不一致,有不同的效果。具体的规则实际上和mysql的刷盘策略很像。都写到pagecache,是否刷盘可以自己配置,也可以根据操作系统自己的判断。

AOF文件载入

AOF 持久化默认是关闭的,通过将 redis.conf 中将 appendonly no,修改为appendonly yes来开启AOF 持久化功能

如果服务器开启了 AOF 持久化功能,服务器会优先使用 AOF 文件来还原数据库状态。只有在 AOF 持久化功能处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态。如图:

image.png

Redis读取AOF文件并还原数据库状态的详细步骤

image.png

AOF重写

因为 AOF 持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF 文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的 AOF 文件很可能对 Redis 服务器、甚至整个宿主计算机造成影响,并且 AOF 文件的体积越大,使用 AOF 文件来进行数据还原所需的时间就越长,这时候就需要对AOF文件进行重写。Redis服务创建一个新的AOF文件代替旧的AOF文件 重写效果类似于这样:

image.png

我们可以通过调用BGREWRITEAOF命令来后台重写AOF文件,也可以通过配置文件,程序在达到对应的规则自动调用BGREWRITEAOF来重写文件

重写流程:

1.fork一个子进程进行AOF重写,把现有的数据转成新增命令保存。

2.父进程正常接收客户端命令,所有新的写操作会把命令输入缓冲管道(也就是父子进程通信的管道)

3.子进程写完AOF后,再追加重写缓冲区里的新命令。

4.重写结束,新的AOF文件替换旧的AOF文件。

为了解决服务器当前状态和新的AOF文件所保存的数据库状态不一致,产生了AOF重写缓冲区

AOF缓冲区给旧aof文件提供数据 AOF重写缓存区给新aof文件提供数据

image.png

RDB+AOF

4.0版本引入了RDB+AOF的功能,整合了RDB的占用内存小的优点,和AOF实时性的优点。

aof-use-rdb-preamble yes

这样 BGREWRITEAOF的功能就变了,会把aof文件重写成rdb的格式,然后后续的追加仍然是aof。变成了RDB和AOF的一个结合。