Redis 学习笔记

本贴最后更新于 235 天前,其中的信息可能已经沧海桑田

Redis 学习记录

记录一下 Redis 的学习,仅供参考。

SDS 简单动态字符串

redis 没有直接使用 C 语言的传统字符串表示,而是自己构建了(simple dynamic string)的抽象类型,并且广泛运用在 redis 的代码当中。

传统的 c 字符串只会在 redis 的代码中充当字符串字面量使用,也就是类似于打印日志时 log("xxxxx")这样使用。

sds.h/sdshdr 定义了 sds 的结构

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

image.png

并且还沿用了 c 字符串的以空字符串'\0'结尾,这样可以重用一部分 c 字符串函数库里面的函数。

C 字符串 SDS
获取字符串长度的复杂度为 O(n) 获取字符串长度的复杂度为 O(1)
API 是不安全的,可能造成缓冲区溢出 API 是安全的,不会造成缓冲区溢出
修改字符串长度 N 次必然要执行 N 次内存重分配 修改字符串长度 N 次最多执行 N 次内存重分配
只能保存文本数据 可以保存文本或者二进制数据

对象的类型与编码

redis 内置有 5 种对象:字符串,列表,哈希,集合,有序集合。而 redis 中也自己实现了许多的数据结构例如:SDS,双端链表,字典,跳表,压缩列表,整数集合,哈希表 等等,这里不会讨论如何实现这些数据结构,但是 redis 是用这些实现的数据结构来实现它的 5 种内置对象的,每种对象都用到了至少一种我们刚才介绍的数据结构。

针对不同的场景,我们可以为对象设置多种不同的数据结构实现,可以优化对象在不同场景下的使用效率。

对象

类型常量 对象
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合的对象

编码

编码常量 数据结构
REDIS_ENCODING_INT long 类型的整数
REDIS_ENCODING_EMBSTR embstr 编码的简单动态字符串
REDIS_ENCODING_RAW 简单动态字符串
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 有序集合的对象
REDIS_ENCODING_ZIPLIST 压缩列表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳表,字典

对象与编码的关系

类型 编码 对象
REDIS_STRING REDIS_ENCODING_INT 使用整数实现的字符串对象
REDIS_STRING REDIS_ENCODING_EMBSTR 使用 embstr 编码的动态字符串实现的字符串对象
REDIS_STRING REDIS_ENCODING_RAW 使用简单动态字符串实现的字符串对象
REDIS_LIST REDIS_ENCODING_ZIPLIST 使用压缩列表实现的列表对象
REDIS_LIST REDIS_ENCODING_LINKEDLIST 使用双端列表实现的列表对象
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象
REDIS_HASH REDIS_ENCODING_HT 使用字典实现的哈希对象
REDIS_SET REDIS_ENCODING_INTSET 使用整数集合实现的集合对象
REDIS_SET REDIS_ENCODING_HT 使用字典实现的集合对象
REDIS_ZSET REDIS_ENCODING_ZIPLIST 使用压缩列表实现的有序集合对象
REDIS_ZSET REDIS_ENCODING_SKIPLIST 使用跳表和字典实现的有序集合对象

谨慎处理多数据库程序

到目前为止,Redis 仍然没有可以返回客户端目标数据库的命令,虽然 redis-cli 客户端会在输入符旁边提示当前所使用的目标数据库,但在其他的 redis-sdk 中并没有继承,所以为了避免对数据库进行误操作,最好先执行下 select 命令。

RDB 和 AOF

rdb 和 aof 都是 redis 提供的用于持久化的功能。

RDB 持久化保存数据库状态的方法是将数据编码后保存在 RDB 文件当中,而 AOF 则是记录执行的 SET,SADD,RPUSH 三个命令保存到 AOF 文件当中。

两种恢复手段的载入判断流程。

 st=>start: 服务器启动
 e=>end: 载入AOF文件
 e2=>end: 载入RDB文件
 op=>operation: 执行载入程序
 cond=>condition: 已开启AOF持久化功能?
 io=>inputoutput: 输入/输出
 st->op->cond
 cond(yes)->e
 cond(no)->e2

ps: 为何 lute 没有识别出流程图的语法呢?

image.png

SAVE 和 BGSAVE

这两个命令都用来生成 RDB 文件,她们主要的区别如下:

SAVE 命令会阻塞 Redis 服务器进程,直到 RDB 文件创建完毕,在此期间,redis-server 不能处理任何命令请求。

BGSAVE 命令会派生出一个子进程,由它来负责创建 RDB 文件,服务器进程继续进行命令请求。

伪代码:

def save():
   rdbSave()

def bg_save():
   pid = fork() //创建子进程

   if pid == 0
     rdbSave()
     signal_parent() //告诉父进程
   elif pid > 0 //父进程继续处理命令请求,并通过轮训等待子进程的信号
     handle_request_and_wait_signal()
   else:
     //处理出错情况
     handle_fork_error()

与生成 rdb 文件不同,rdb 的载入工作是服务器启动时自动执行的,所以 redis 并没有专门用于载入 rdb 文件的命令。

由于 BGSAVE 命令的保存工作是由子进程执行的,所以在子进程创建 RDB 文件的过程中,Redis 服务器仍然可以处理客户端的命令请求,但是在此期间服务器处理 SAVE,BGSAVE,BGREWRITEAOF 三个命令的方式会和平时有所不同。

AOF 持久化的实现

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

命令追加

当 AOF 功能正处在打开状态时,客户端发送一条写入命令,服务器执行完之后,会以协议格式将这条命令追加到 aof_buf 缓冲区末尾

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

这就是 AOF 持久化命令追加步骤的实现原理。

AOF 文件的写入与同步

Redis 的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复。那么如果打开了 AOF 功能,则会将命令尾加到 aof_buf 缓冲区中,所以在事件结束前都会调用 flushAppendOnlyFile 函数来考虑是否要将缓冲区里的内容写入和保存到 AOF 文件当中。

伪代码:

def eventLoop():
   while True:
     //处理文件事件,接收命令请求以及发送命令回复
     processFileEvents();
     //处理时间事件
     processTimeEvents();
     //考虑是否将aof_buf中的内容写入AOF缓冲区
     flushAppendOnlyFile();

flushAppendOnlyFile 这个函数的行为由服务器配置的 appendfsync 选项的值来决定

image.png

AOF 文件重写的实现

为了解决 AOF 文件体积膨胀的问题,Redis 提供了 AOF 文件重写功能,新生成一个 AOF 文件来替代现有的 AOF 文件,新旧两个文件所保存的数据库状态相同,但新文件不会包含任何冗余命令,所以新 AOF 文件的体积会比旧的文件小。

redis 的重写 aof 算法非常的聪明。

直接读取 key 的值,获取最新的 key 当前的值,然后用一条命令就可以做为这个 key 的当前状态。

伪代码:

def aof_rewrite(new_aof_file_name):
    # 创建新的AOF文件
  	f = create_file(new_aof_file_name)

    # 遍历数据库
    for db in redisServer.db:
       # 忽略空数据库
       if db.is_empty(): continue

       # 显示指定数据库
       f.write_command("SELECT "+ db.id)

       for key in db:
       	# 忽略已过期的key
       	if key.is_expired(): continue
         # 根据key的类型对key进行重新
         switch(key.type):
             case String:
               rewrite_string(key) #根据key获取到所有的value 然后拼成写入命令即可
             case List:
               rewrite_list(key)
             case Hash:
               rewrite_hash(key)
             case Set:
               rewrite_set(key)
             case SortedSet:
               rewrite_sorted_set(key)
  				if key.have_expire_time()
             rewrite_expire_time(key)
     #写入完毕,关闭文件
     f.close()

**ps:**在实际中,重写程序在处理列表,哈希表,集合,有序集合这四种带有多个元素的键时,会先检查键所包含的元素数量,如果元素的数量超过了 redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD 常量的值,那么重写程序将使用多条命令来记录键的值,而不是单单一条命令。在 redis 2.9 版本中这个常量的值为 64。

AOF 后台重写

因为 redis 是使用单线程来处理请求命令,为了不阻塞主进程,所以 AOF 重写的工作会起一个子进程来进行。

但这样做的同时会导致一个问题,如果子进程在进行重写的同时,主进程继续处理命令请求,而新的命令可能会对现在的数据库状态进行修改,从而使得重写前后的文件保存的数据库状态不一致。

image.png
为了解决这个问题,redis 服务器设置了一个 AOF 重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当执行完一个写命令之后,他会同时将这个写命令发送给 AOF 缓冲区和 AOF 重写缓冲区,这样子进程开始后,服务器执行的所有写命令都会被记录到 AOF 重写缓冲区里面,这样就能解决上面这个问题了。

在整个过程中只有重写完成后的信号处理函数会对主进程造成阻塞,其他时候都不会造成阻塞。

这就是 AOF 后台重写,也就是 BGREWRITEAOF 命令的实现原理。

事件

redis 服务器于客户端或者其他 redis 服务器通信是基于 socket 套接字,并且是事件驱动的。

redis 需要处理以下两种事件:

  1. 文件事件(file event)
    文件事件就是 redis socket 通信的抽象,我认为就是数据交换格式。通过监听各种文件事件来完成一系列的网络通信操作。
  2. 时间事件(time event)
    redis 服务器中有一系列的操作需要在指定时间执行,时间事件就是这类定时操作的抽象。

文件事件

redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称作文件处理器。

使用 I/O 多路复用来监听多个套接字,然后根据套接字当前执行的任务来关联不同的事件处理器

当套接字准备应答(accpet),读取(read),写入(write),关闭(close)时,相应的事件就会产生,然后文件处理器就会调用关联好的事件处理器来处理这些事件。

通过 I/O 多路复用,虽然是文件事件处理器虽然是单线程,但是却实现了高性能的网络通信模型,又方便与 redis 中其他的单线程模块进行对接,保持了 redis 内部单线程简单性。

serverCron 函数

redis 服务器中的 serverCron 函数默认每隔 100ms 执行一次,这个函数负责管理服务器的资源,并保持服务器自身的良好运转。

更新服务器时间缓存

redis 服务器中有很多的功能需要获取系统时间,例如记录日志,设置键过期时间等,而每次获取系统的当前时间都需要执行一次系统调用,为了减少调用次数,服务器的结构体里面 unixtimemstime 属性被当作当前时间的缓存。

struct redisServer {
  //...
  time_t unixtime; //保存秒级的当前时间戳 
  long long mstime;//保存毫秒级的当前时间戳
}

serverCron 每一百毫秒执行一次,所以这两个属性的精度不高,所以会在打印日志等需要精度不高的服务才会使用,对于为键设置过期时间等高精度功能来说,还是会去执行系统调用。

更新服务器内存峰值

服务器状态里面 stat_paek_memory 属性记录了服务器的内存峰值大小:

struct redisServer {
  
  //...
  size_t stat_peak_memory;
}

每次 serverCron 函数执行时,程序都会查看服务器当前使用的内存数量并且与 stat_peak_memory 进行比较,如果大于这个值则更新。

管理数据库资源

serverCron 函数每次执行都会调用 databaseCron 函数,这个函数会对服务器中的一部分数据库进行检查,删除其中过期的键,并在有需要的时候,对字典进行收缩操作

管理客户端资源

serverCron 函数每次执行都会调用 clientsCron 函数,这个函数会对一定数量的客户端进行以下两个检查:

执行被延迟的 BGREWRITEAOF

如果 BGSAVE 命令正在执行,那么 BGREWRITEAOF 的执行时间会被延迟到 BGSAVE 命令执行完成之后。

redisServer 的结构体中维护一个参数

struct redisServer {
  int aof_rewrite_scheduled;//如果值为1,那么表示有 BGREWRITEAOF 命令被延迟了
}

每次 serverCorn 函数执行的时候,都会检查 BIGSAVE 或者 BIGREWRITEAOF 命令是否正在执行,如果都没有执行,并且 aof_rewrite_scheduled 属性的值为 1,那么服务器就会执行被推迟的 BGREWRITEAOF 命令。

检查持久化操作的运行状态

用流程图来表示这个检查过程

image.png

复制

在 redis 里面可以通过 slaveof 命令来让一个 redis 服务器已复制另一个服务器,也就是主从配置

主从服务器双方的数据库将保存相同的数据

redis2.8 以前

redis 复制功能分为同步(sync)和命令传播(command propagate) 两个操作

同步

当一个服务器使用 slaveof 成为另一个服务器的 slave 时,他就需要发送同步操作给主人,步骤如下:

  1. 从服务器向主服务器发送 sync 命令
  2. 收到 sync 命令的主服务器执行 BGSAVE 命令,在后台生成 rdb 文件,并使用一个缓冲区记录从现在开始执行的所有命令。
  3. 当主服务器的 BGSAVE 命令执行完毕时,主服务器会将生成的 RDB 文件发送至从服务器,从服务器接收并载入这个 rdb 文件,将自己的数据库状态更新至主服务器执行 BGSAVE 命令时的数据库状态。
  4. 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器当前的状态

image.png

命令传播

在同步操作完成后,主从双方的数据库状态不是一成不变的,。每当主服务器执行客户端的写入命令时,双方的状态就有可能不一致。

因此,需要主服务器对从服务器进行命令传播操作:主服务器会将自己执行的写命令,也即是造成主服务器不一致的那条命令,发送给从服务器执行,当从服务器执行完后,双方再次回到一致状态。

旧版复制功能的缺陷

复制又分两种情况

因为断线重新复制,可能主服务器只写入了或者更新了少量的数据,而却重新进行了 sync 的操作,这样效率是非常低的,因为 sync 需要大量的磁盘 IO 和网络 IO

redis2.8 版本之后

为了解决旧版复制功能在处理断线重复情况时的低效问题,2.8 版本后使用 PSYNC 命令来代替 SYNC 命令来执行复制时的同步操作。

PSYNC 命令具有完全重同步(full resynchronization) 和部分重同步(partial resynchronization)两种模式

部分重同步的实现

部分重同步功能由以下三个部分构成:

复制具体流程

  1. Slaveof 设置主服务器地址和端口
  2. 建立 socket 连接,如果连接成功,从服务器将为这个套接字关联一个专门用于处理复制工作的文件时间处理器来负责后续的复制工作,而主服务器在 accept 从服务器的 socket 之后,就会为这个套接字创建相应的客户端状态,并且将从服务器看作是一个主服务器的客户端来对待,此时从服务器同时具有服务器(server)和客户端(client)两个身份。
  3. 发送 PING 命令检查是否能正常通信
  4. 身份认证,如果从服务器设置了 masterauth 选项,那么进行身份认证,如果没有,则不进行身份认证
  5. 发送端口信息,将从服务器监听的端口发送给主服务器,并记录在客户端状态(redisClient 这个结构体中),用于主服务器执行 INFO replication 命令时打印出从服务器的端口号。
  6. 同步,发送 PSYNC 命令,更新自己的数据库状态与主服务器一致
  7. 命令传播:这时主服务器只要一直将自己执行的写命令发送给从服务器,从服务器只要一直接收并执行,就可以保证主从一致

心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:

REPLCONF ACK <replication_offset>

replication_offset 是从服务器自己当前的复制偏移量

发送 REPLCONF ACK 命令对于主从服务器有三个作用

Sentinel

Sentinel(哨兵)是 redis 的高可用(high availability)解决方案:由一个或者多个 Sentinel 实例(instance)组成的系统,可以监视任意多个主服务器。以及这些主服务器属下的所有从服务器。并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求

哨兵选举算法:raft 算法,八股文请看 《etcd 基本使用和原理》

集群

redis 集群是 redis 提供的分布式数据库方案,集群通过分片(sharding) 来进行数据共享,并提供复制和故障转移功能。

事务

Redis 通过 MULTI,EXEC,WATCH 等命令来实现事务(transaction)功能。事务提供了一种将多个命令请求打包,然后一次性,按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端命令,他会将事务中的所有命令都执行完毕,然后才去执行其他客户端的命令请求。

事务的实现

一个事务从开始到结束通常会经历以下三个阶段:

  1. 事务开始
  2. 命令入队
  3. 事务执行

事务开始

MULTI 命令的执行标志着事务的开始

此命令可以将该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的 flags 属性中打开 REDIS_MULTI 标识来完成的

命令入队

当一个客户端处于非事务状态时,这个客户端的命令会立即被服务器执行,与此不同的是,当一个客户端切换至事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:

image.png

事务队列

每个 Redis 客户端都有自己的事务状态,这个事务状态保存在客户端状态的 mstate 属性中:

typedef struct redisClient {
  // ...
  // 事务状态
  multiState mstate; 
  //...
}

事务状态包含一个事务队列,以及一个已入队命令的计数器(也可以说是事务队列的长度):

typedef struct multiState {
  // 事务队列,FIFO顺序
  multiCmd *commands;
  
  //已入队命令计数
  int count;
}

事务队列是一个 multiCmd 类型的数组,数组中的每个 multiCmd 结构都保存了一个已入队命令的相关信息,包括指向命令实现函数的指针,命令的参数,以及参数的数量:

typedef struct multiCmd {
  // 参数
  robj **argv;
  //参数数量 
  int argc;
  //命令指针
  struct redisCommand *cmd;
}

事务队列以先进先出(FIFO)的方式保存入队的命令,较先入队的命令会被放到数组的前面,而较后入队的命令则会被放到数组的后面。

image.png

执行事务

当一个处于事务状态的客户端向服务器发送 EXEC 命令时,这个 EXEC 命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将命令所得的结果全部返回给客户端。

WATCH 命令

WATCH 命令是一个乐观锁,它可以在 EXEC 命令执行之前,监视任意数量的数据库键,并且在 EXEC 命令执行时,检查被监视的键是否有一个已经被修改过了,如果是,服务器将拒绝执行事务,并且返回 nil

事务的 ACID 性质

在 redis 中,事务总是具有原子性(Atomicity),一致性(Consistency)和隔离性(Isolation),并且当 Redis 运行在某种特定的持久化模式下时,事务也具有耐久性(Durability)

原子性

事务具有原子性指的是,数据库将事务中的多个操作当成一个整体来执行,服务器要么就执行事务中的所有操作,要么就一个操作也不执行。对于 redis 来说,事物队列中的命令要么全部执行,要么就一个都不执行性。因此 redis 事务可以说具有原子性的。

但是,redis 的事务和传统的关系性数据库事务最大的区别就是,redis 不支持事务回滚机制,即是事务队列中的某个命令在执行期间出现了错误,事务的后续命令也会继续执行下去,之前执行的命令也不会有任何影响。

一致性

事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然是一致的。

redis 通过谨慎的错误检测和简单的设计保证了事务的一致性。

  1. 入队错误,如果一个事务在入队命令的过程中,出现了命令不存在或者格式不正确等情况,那么 redis 将拒绝执行这个事务
  2. 执行错误,例如 set msg "hello" 后,rush msg "a" "b" ,这个在执行过程中会被服务器识别出来,并进行相应的错误处理,所有这些出错命令不会对数据库做出修改,也不会对事务的一致性产生任何影响。
  3. 服务器停机,可以使用 rdb 或者 aof 等持久化模式来保证停机前后的数据库一致性

隔离性

事务的隔离性,多个事务之间不会相互影响。

redis 使用单线程来执行事务,所以 redis 的事务总是以串行的方式运行,所以保证了隔离性。

持久性

Redis 的事务队列和命令都保存在内存中,也没有任何事务持久化功能,所以事务的持久性有 redis 所使用的持久化模式决定的

  • Redis

    Redis 是一个开源的使用 ANSI C 语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value 数据库,并提供多种语言的 API。从 2010 年 3 月 15 日起,Redis 的开发工作由 VMware 主持。从 2013 年 5 月开始,Redis 的开发由 Pivotal 赞助。

    250 引用 • 244 回帖 • 568 关注
4 操作
Gakkiyomi2019 在 2021-04-13 12:01:50 更新了该帖
Gakkiyomi2019 在 2021-03-30 17:42:27 更新了该帖
Gakkiyomi2019 在 2021-03-07 10:08:36 更新了该帖
Gakkiyomi2019 在 2021-02-22 18:16:40 更新了该帖

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...