CrabNote螃蟹笔记

Redis 常见面试题

本文《Redis 常见面试题》由小宇宙从原站整理搬运,详细讲解相关技术要点。

大家好,我是小宇宙。 不知不觉《图解 Redis (opens new window)》系列文章写了很多了,考虑到一些同学面试突击 Redis,所以我整理了 3 万字 + 40 张图的 Redis 八股文,共收集了 40 多个面试题。 发车! 认识 Redis 什么是 Redis? 我们直接看 Redis 官方是怎么介绍自己的。 Redis 官方的介绍原版是英文的,我翻译成了中文后截图的,所以有些文字读起来会比较拗口,没关系,我会把里面比较重要的特性抽出来讲一下。 Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此 读写速度非常快 ,常用于 缓存,消息队列、分布式锁等场景 。 Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),并且对数据类型的操作都是 原子性 的,因为执行命令由单线程负责的,不存在并发竞争的问题。 除此之外,Redis 还支持 事务 、持久化、Lua 脚本、多种集群方案(主从复制模式、哨兵模式、切片机群模式)、发布/订阅模式,内存淘汰机制、过期删除机制 等等。 Redis 和 Memcached 有什么区别? 很多人都说用 Redis 作为缓存,但是 Memcached 也是基于内存的数据库,为什么不选择它作为缓存呢?要解答这个问题,我们就要弄清楚 Redis 和 Memcached 的区别。 Redis 与 Memcached 共同点 : 1. 都是基于内存的数据库,一般都用来当做缓存使用。 2. 都有过期策略。 3. 两者的性能都非常高。 Redis 与 Memcached 区别 : Redis 支持的数据类型更丰富(String、Hash、List、Set、ZSet),而 Memcached 只支持最简单的 key value 数据类型; Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了; Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据; Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持; 为什么用 Redis 作为 MySQL 的缓存? 主要是因为 Redis 具备「高性能」和「高并发」两种特性 。 1、Redis 具备高性能 假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。 如果 MySQL 中的对应数据改变的之后,同步改变 Redis 缓存中相应的数据即可,不过这里会有 Redis 和 MySQL 双写一致性的问题,后面我们会提到。 2、 Redis 具备高并发 单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,Redis 单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。 所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。 Redis 数据结构 Redis 数据类型以及使用场景分别是什么? Redis 提供了丰富的数据类型,常见的有五种数据类型: String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合) 。 随着 Redis 版本的更新,后面又支持了四种数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增) 。 Redis 五种数据类型的应用场景: String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。 List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。 Hash 类型:缓存对象、购物车等。 Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。 Zset 类型:排序场景,比如排行榜、电话和姓名排序等。 Redis 后续版本又支持四种数据类型,它们的应用场景如下: BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等; HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等; GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车; Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。 TIP 想深入了解这 9 种数据类型,可以看这篇:2万字 + 20 张图 | 细说 Redis 常见数据类型和应用场景 (opens new window) 五种常见的 Redis 数据类型是怎么实现? 我画了一张 Redis 数据类型和底层数据结构的对应关图,左边是 Redis 3.0 版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是 Redis 7.0 及之后较新版本的。 String 类型内部实现 String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)。 SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串: SDS 不仅可以保存文本数据,还可以保存二进制数据 。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。 SDS 获取字符串长度的时间复杂度是 O(1) 。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。 Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出 。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。 List 类型内部实现 List 类型的底层数据结构是由 双向链表或压缩列表 实现的: 如果列表的元素个数小于 512 个(默认值,可由 list max ziplist entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list max ziplist value 配置),Redis 会使用 压缩列表 作为 List 类型的底层数据结构; 如果列表的元素不满足上面的条件,Redis 会使用 双向链表 作为 List 类型的底层数据结构; 但是 在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表 。 Hash 类型内部实现 Hash 类型的底层数据结构是由 压缩列表或哈希表 实现的: 如果哈希类型元素个数小于 512 个(默认值,可由 hash max ziplist entries 配置),所有值小于 64 字节(默认值,可由 hash max ziplist value 配置)的话,Redis 会使用 压缩列表 作为 Hash 类型的底层数据结构; 如果哈希类型元素不满足上面条件,Redis 会使用 哈希表 作为 Hash 类型的底层数据结构。 在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了 。 Set 类型内部实现 Set 类型的底层数据结构是由 哈希表或整数集合 实现的: 如果集合中的元素都是整数且元素个数小于 512 (默认值,set maxintset entries配置)个,Redis 会使用 整数集合 作为 Set 类型的底层数据结构; 如果集合中的元素不满足上面条件,则 Redis 使用 哈希表 作为 Set 类型的底层数据结构。 ZSet 类型内部实现 Zset 类型的底层数据结构是由 压缩列表或跳表 实现的: 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用 压缩列表 作为 Zset 类型的底层数据结构; 如果有序集合的元素不满足上面的条件,Redis 会使用 跳表 作为 Zset 类型的底层数据结构; 在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。 TIP 想深入了解这 9 种数据结构,可以看这篇:2万字 + 40 张图 | 细说 Redis 数据结构 (opens new window) Redis 线程模型 Redis 是单线程吗? Redis 单线程指的是「接收客户端请求 解析请求 进行数据读写等操作 发送数据给客户端」这个过程是由一个线程(主线程)来完成的 ,这也是我们常说 Redis 是单线程的原因。 但是, Redis 程序并不是单线程的 ,Redis 在启动的时候,是会 启动后台线程 (BIO)的: Redis 在 2.6 版本 ,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务; Redis 在 4.0 版本之后 ,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。 之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。 后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。 关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列: BIO\ CLOSE\ FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭; BIO\ AOF\ FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘, BIO\ LAZY\ FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象; Redis 单线程模式是怎样的? Redis 6.0 版本之前的单线模式如下图: 图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。 Redis 初始化的时候,会做下面这几件事情: 首先,调用 epoll\ create() 创建一个 epoll 对象和调用 socket() 创建一个服务端 socket 然后,调用 bind() 绑定端口和调用 listen() 监听该 socket; 然后,将调用 epoll\ ctl() 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数。 初始化完后,主线程就进入到一个 事件循环函数 ,主要会做以下事情: 首先,先调用 处理发送队列函数 ,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll\ wait 发现可写后再处理 。 接着,调用 epoll\ wait 函数等待事件的到来: 如果是 连接事件 到来,则会调用 连接事件处理函数 ,该函数会做这些事情:调用 accpet 获取已连接的 socket 调用 epoll\ ctl 将已连接的 socket 加入到 epoll 注册「读事件」处理函数; 如果是 读事件 到来,则会调用 读事件处理函数 ,该函数会做这些事情:调用 read 获取客户端发送的数据 解析命令 处理命令 将客户端对象添加到发送队列 将执行结果写到发送缓存区等待发送; 如果是 写事件 到来,则会调用 写事件处理函数 ,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll\ wait 发现可写后再处理 。 以上就是 Redis 单线模式的工作方式,如果你想看源码解析,可以参考这一篇:为什么单线程的 Redis 如何做到每秒数万 QPS ? (opens new window) Redis 采用单线程为什么还这么快? 官方使用基准测试的结果是, 单线程的 Redis 吞吐量可以达到 10W/每秒 ,如下图所示: 之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因: Redis 的大部分操作 都在内存中完成 ,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了; Redis 采用单线程模型可以 避免了多线程之间的竞争 ,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。 Redis 采用了 I/O 多路复用机制 处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。 Redis 6.0 之前为什么使用单线程? 我们都知道单线程的程序是无法利用服务器的多核 CPU 的,那么早期 Redis 版本的主要工作(网络 I/O 和执行命令)为什么还要使用单线程呢?我们不妨先看一下Redis官方给出的FAQ (opens new window)。 核心意思是: CPU 并不是制约 Redis 性能表现的瓶颈所在 ,更多情况下是受到内存大小和网络I/O的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。 除了上面的官方回答,选择单线程的原因也有下面的考虑。 使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题, 增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗 。 Redis 6.0 之后为什么引入了多线程? 虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是 在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求 , 这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上 。 所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。 但是对于命令的执行,Redis 仍然使用单线程来处理, 所以大家 不要误解 Redis 有多线程同时执行命令。 Redis 官方表示, Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上 。 Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io threads do reads 配置项设为 yes。 同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。 关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。 因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会 额外创建 6 个线程 ( 这里的线程数不包括主线程 ): Redis server : Redis的主线程,主要负责执行命令; bio\ close\ file、bio\ aof\ fsync、bio\ lazy\ free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务; io\ thd\ 1、io\ thd\ 2、io\ thd\ 3:三个 I/O 线程,io threads 默认是 4 ,所以会启动 3(4 1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。 Redis 持久化 Redis 如何实现数据不丢失? Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。 Redis 共有三种数据持久化的方式: AOF 日志 :每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里; RDB 快照 :将某一时刻的内存数据,以二进制的方式写入磁盘; 混合持久化方式 :Redis 4.0 新增的方式,集成了 AOF 和 RDB 的优点; AOF 日志是如何实现的? Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。 我这里以「 set name xiaolin 」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图: 我这里给大家解释下。 「\ 3」表示当前命令有三个部分,每部分都是以「$+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「$3 set」表示这部分有 3 个字节,也就是「set」命令这个字符串的长度。 为什么先执行命令,再把数据写入日志呢? Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。 避免额外的检查开销 :因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。 不会阻塞当前写操作命令的执行 :因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。 当然,这样做也会带来风险: 数据可能会丢失: 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。 可能阻塞其他操作: 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。 AOF 写回策略有几种? 先来看看,Redis 写入 AOF 日志的过程,如下图: 具体说说: 1. Redis 执行完写操作命令后,会将命令追加到 server.aof\ buf 缓冲区; 2. 然后通过 write() 系统调用,将 aof\ buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘; 3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。 Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填: Always ,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘; Everysec ,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘; No ,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。 我也把这 3 个写回策略的优缺点总结成了一张表格: AOF 日志过大,会触发什么机制? AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。 如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。 所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制 ,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。 AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。 举个例子,在没有使用重写机制前,假设前后执行了「 set name xiaolin 」和「 set name xiaolincoding 」这两个命令的话,就会将这两个命令记录到 AOF 文件。 但是 在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条 「set name xiaolincoding」命令记录到新的 AOF 文件 ,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。 重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。 重写 AOF 日志的过程是怎样的? Redis 的 重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的 ,这么做可以达到两个好处: 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程; 子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。 触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。 但是重写过程中,主进程依然可以正常处理命令 ,那问题来了,重写 AOF 日志过程中,如果主进程修改了已经存在 key value,那么会发生写时复制,此时这个 key value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢? 为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区 ,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。 在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会 同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」 。 也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作: 执行客户端发来的命令; 将执行后的写命令追加到 「AOF 缓冲区」; 将执行后的写命令追加到 「AOF 重写缓冲区」; 当子进程完成 AOF 重写工作( 扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志 )后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。 主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作: 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致; 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。 信号函数执行完后,主进程就可以继续像往常一样处理命令了。 TIP AOF 日志的内容就暂时提这些,想更详细了解 AOF 日志的工作原理,可以详细看这篇:AOF 持久化是怎么实现的 (opens new window) RDB 快照是如何实现的呢? 因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。 为了解决这个问题,Redis 增加了 RDB 快照。所谓的快照,就是记录某一个瞬间东西,比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。 所以,RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。 因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。 RDB 做快照时会阻塞线程吗? Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行: 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长, 会阻塞主线程 ; 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以 避免主线程的阻塞 ; Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置: 别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。 只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是: 900 秒之内,对数据库进行了至少 1 次修改; 300 秒之内,对数据库进行了至少 10 次修改; 60 秒之内,对数据库进行了至少 10000 次修改。 这里提一点,Redis 的快照是 全量快照 ,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。 RDB 在执行快照的时候,数据能修改吗? 可以的,执行 bgsave 过程中,Redis 依然 可以继续处理操作命令 的,也就是数据是能被修改的,关键的技术就在于 写时复制技术(Copy On Write, COW)。 执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。 如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。 TIP RDB 快照的内容就暂时提这些,想更详细了解 RDB 快照的工作原理,可以详细看这篇:RDB 快照是怎么实现的? (opens new window) 为什么会有混合持久化? RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。 AOF 优点是丢失数据少,但是数据恢复不快。 为了集成了两者的优点, Redis 4.0 提出了 混合使用 AOF 日志和内存快照 ,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。 混合持久化工作在 AOF 日志重写过程 ,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。 也就是说,使用了混合持久化,AOF 文件的 前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据 。 这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样 加载的时候速度会很快 。 加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得 数据更少的丢失 。 混合持久化优点: 混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。 混合持久化缺点: AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差; 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。 Redis 集群 Redis 如何实现服务高可用? 要想设计一个高可用的 Redis 服务,一定要从 Redis 的多服务节点来考虑,比如 Redis 的主从复制、哨兵模式、切片集群。 主从复制 主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是「读写分离」的方式。 主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。 也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。 注意,主从服务器之间的命令复制是 异步 进行的。 具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。 所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。 TIP 想更详细了解 Redis 主从复制的工作原理,可以详细看这篇:主从复制是怎么实现的? (opens new window) 哨兵模式 在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。 为了解决这个问题,Redis 增加了哨兵模式( Redis Sentinel ),因为哨兵模式做到了可以监控主从服务器,并且提供 主从节点故障转移的功能。 TIP 想更详细了解 Redis 哨兵的工作原理,可以详细看这篇:哨兵是怎么实现的? (opens new window) 切片集群模式 当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群 (Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。 Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。在 Redis Cluster 方案中, 一个切片集群共有 16384 个哈希槽 ,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步: 根据键值对的 key,按照 CRC16 算法 (opens new window)计算一个 16 bit 的值。 再用 16bit 值对 16384 取模,得到 0 16383 范围内的模数,每个模数代表一个相应编号的哈希槽。 接下来的问题就是,这些哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案: 平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。 手动分配: 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。 为了方便你的理解,我通过一张图来解释数据、哈希槽,以及节点三者的映射分布关系。 上图中的切片集群一共有 2 个节点,假设有 4 个哈希槽(Slot 0~Slot 3)时,我们就可以通过命令手动分配哈希槽,比如节点 1 保存哈希槽 0 和 1,节点 2 保存哈希槽 2 和 3。 然后在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 4 进行取模,再根据各自的模数结果,就可以被映射到哈希槽 1(对应节点1) 和 哈希槽 2(对应节点2)。 需要注意的是,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。 集群脑裂导致数据丢失怎么办? 什么是脑裂? 先来理解集群的脑裂现象,这就好比一个人有两个大脑,那么到底受谁控制呢? 那么在 Redis 中,集群脑裂产生数据丢失的现象是怎样的呢? 在 Redis 主从架构中,部署方式一般是「一主多从」,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。 这时,哨兵也发现主节点失联了,它就