Redis学习笔记 ---- 常见面试题

1 认识Redis

Redis 是一种基于内存数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存消息队列分布式锁等场景。

Redis 提供了多种数据类型来支持不同的业务场景,比如

String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),

并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。

Redis 和 Memcached 有什么区别?

Redis 与 Memcached 共同点:

都是基于内存的数据库,一般都用来当做缓存使用。
都有过期策略。
两者的性能都非常高。

·
Redis 与 Memcached 区别:

Redis 支持的数据类型更丰富(String、Hash、List、Set、ZSet),而 Memcached 只支持最简单的 key-value 数据类型;
Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了;
Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;
Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持

为什么用 Redis 作为 MySQL 的缓存?

Redis 具备「高性能」和「高并发」两种特性。

操作 Redis 缓存就是直接操作内存,速度相当快;直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的

2 Redis 数据结构

常见的有五种:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
随着Redis更新,又支持了四种数据类型:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。

Redis 数据类型以及使用场景分别是什么?

常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
Redis学习笔记 ---- 常见面试题_第1张图片
Redis学习笔记 ---- 常见面试题_第2张图片
五种常见数据类型的实现

1、String类型底层数据结构:int 和 SDS(简单动态字符串)

· SDS 不仅可以保存文本数据,还可以保存二进制数据。
		SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。
		
· SDS 获取字符串长度的时间复杂度是 O(1)
		SDS 结构里用 len 属性记录了字符串长度
		
· Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出
		SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容

2、List类型底层数据结构:双向链表或压缩列表

· 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),会使用压缩列表;否则使用双向链表

在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

3、Hash类型底层数据结构:压缩列表或哈希表

· 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,满足条件使用压缩列表;否则使用哈希表

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

4、Set类型底层数据结构:哈希表或整数集合

· 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合;否则使用哈希表

5、ZSet类型底层数据结构:压缩列表或跳表

· 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,使用压缩列表;否则使用跳表

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

3 Redis线程模型

Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的

但Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:

· Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
· Redis 在 4.0 版本之后,新增了lazyfree 线程(一个新的后台线程),用来异步释放 Redis 内存。

Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,通过把任务丢入任务队列,后台线程(BIO)轮询队列,取出任务去执行
Redis学习笔记 ---- 常见面试题_第3张图片
图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 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 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:

· Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构
· Redis 采用单线程模型可以避免了多线程之间的竞争
· Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求

Redis6.0之后,采用了多个I/O线程来处理网络请求
原因:

随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络I/O的处理上

通过将 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。可开启多线程处理客户端读请求
在这里插入图片描述
Redis.conf 配置文件提供了 IO 多线程个数的配置项
在这里插入图片描述

4 Redis持久化

三种持久化方式:

· AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
· RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;
· 混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点;

4.1 AOF日志如何实现

例子:
Redis学习笔记 ---- 常见面试题_第4张图片

「*3」表示当前命令有三个部分,每部分都是以「$+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「$3 set」表示这部分有 3 个字节,也就是「set」命令这个字符串的长度。

先执行写操作命令,再将该命令记录到AOF日志里
好处:

· 避免额外的检查开销
· 不会阻塞当前写操作命令的执行

坏处:

· 数据可能会丢失
· 可能阻塞其他操作

Redis写入AOF日志过程:
Redis学习笔记 ---- 常见面试题_第5张图片
Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;由内核决定什么时候写入到硬盘。

AOF写回策略
Redis学习笔记 ---- 常见面试题_第6张图片

当AOF文件内容过大(超过阈值)时,触发AOF重写机制

在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。

重写AOF日志过程

由后台子进程 bgrewriteaof 来完成的。子进程进行AOF重写期间,主进程可继续执行命令,父子进程是共享内存数据(只读),任意一方修改共享内存,触发写时复制,父子进程具有独立的数据副本。

为了解决重写过程中,主进程修改了已存在的键值对而造成的数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。
Redis学习笔记 ---- 常见面试题_第7张图片
子进程在追加 执行后的写命令,除了AOF缓冲区外还有AOF重写缓冲区;在子进程完成重写工作后,会向主进程发送一个信号,主进程在收到信号后调用一个信号处理函数,主要工作:

· 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
· 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。

4.2 RDB快照如何实现

由于AOF日志记录的是操作命令,如果在日志非常多的情况下做故障恢复,会造成Redis恢复操作缓慢。

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据。恢复数据效率要高

Redis通过两个命令来生成RDB文件(save和bgsave),区别在于是否在主进程里执行

· 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;
· 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞;

RDB快照为全量快照,即每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,依然用到写时复制技术

Redis学习笔记 ---- 常见面试题_第8张图片
执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的(复制父进程的页表,但指向的物理内存为同一个)

此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。

如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。(发生了写时复制后,RDB 快照保存的是原本的内存数据,而主线程刚修改的数据,是没办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照)

在使用 bgsave 快照过程中,如果主线程修改了内存数据,不管是否是共享的内存数据,RDB 快照都无法写入主线程刚修改的数据,因为此时主线程(父进程)的内存数据和子进程的内存数据已经分离了,子进程写入到 RDB 文件的内存数据只能是原本的内存数据。

4.3 混合持久化

Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

AOF日志重写过程

当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
Redis学习笔记 ---- 常见面试题_第9张图片
即 AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。

优点

· 开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

缺点

· AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
· 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了

5 Redis集群

5.1 主从复制

主从复制是高可用的保证,实现一主多从模式,主从服务器之间采用读写分离

主服务器可进行读写操作,写操作会同步给从服务器,从服务器一般是只读
Redis学习笔记 ---- 常见面试题_第10张图片
主从服务器之间的命令复制是异步的,无法实现强一致性保证(主从数据时时刻刻保持一致)。

5.2 哨兵模式

为解决 Redis 的主从服务器出现故障宕机的问题需要手动恢复。
可以监控主从服务器,并且提供主从节点故障转移的功能
Redis学习笔记 ---- 常见面试题_第11张图片
哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「主观下线」。这个「规定的时间」是配置项 down-after-milliseconds 参数设定的,单位是毫秒。

主观下线、客观下线

客观下线只适用于主节点

之所以针对「主节点」设计「主观下线」和「客观下线」两个状态,是因为有可能「主节点」其实并没有故障,可能只是因为主节点的系统压力比较大或者网络发送了拥塞,导致主节点没有在规定时间内响应哨兵的 PING 命令。

用多个节点部署成哨兵集群(最少需要三台机器来部署哨兵集群),通过多个哨兵节点一起判断,就可以就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况。

当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。
Redis学习笔记 ---- 常见面试题_第12张图片
当主节点被判定为客观下线后,需要进行主从故障转移,从哨兵集群中选举出一个leader,让leader执行主从切换

哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点就是候选者,所谓的候选者就是想当 Leader 的哨兵。

每个哨兵只有一次投票机会,如果用完后就不能参与投票了,可以投给自己或投给别人,但是只有候选者才能把票投给自己。在投票过程中,任何一个「候选者」,要满足两个条件:

· 第一,拿到半数以上的赞成票;
· 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

quorum 的值建议设置为哨兵个数的二分之一加1,例如 3 个哨兵就设置 2,5 个哨兵设置为 3,而且哨兵节点的数量应该是奇数。

主从故障转移过程
Redis学习笔记 ---- 常见面试题_第13张图片

· 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点。
· 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;
· 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;
· 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;

第一步:
首先,通过 down-after-milliseconds * 10 配置项(down-after-milliseconds 是主从节点断连的最大连接超时时间)过滤从节点

在down-after-milliseconds 毫秒内,主从节点无法通过网络联系上,且发生断连次数超过10次,则证明该节点网络不好,不适合作为新的主节点

其次,对所有从节点进行三轮考察:优先级复制进度ID 号。在进行每一轮考察的时候,哪个从节点优先胜出,就选择其作为新主节点。

· 第一轮考察:哨兵首先会根据从节点的优先级来进行排序,优先级越小排名越靠前,
· 第二轮考察:如果优先级相同,则查看复制的下标,哪个从「主节点」接收的复制数据多,哪个就靠前。
· 第三轮考察:如果优先级和下标都相同,就选择从节点 ID 较小的那个。

最后选出从节点后,哨兵 leader 向被选中的从节点发送 SLAVEOF no one 命令,让这个从节点解除从节点的身份,将其变为新主节点。

至此,新主节点选举出来

第二步:
哨兵 leader 下一步要做的就是,让已下线主节点属下的所有「从节点」指向「新主节点」,这一动作可以通过向「从节点」发送 SLAVEOF 命令来实现。命令包含有新主节点的ip及端口号

哨兵集群终于完成主从切换的工作

第三步:
通知给客户端,通过 Redis 的发布者/订阅者机制来实现,每个哨兵节点提供发布者/订阅者机制,客户端可以从哨兵订阅消息。

哨兵提供的消息订阅频道有很多,不同频道包含了主从节点切换过程中的不同关键事件,几个常见的事件如下:
Redis学习笔记 ---- 常见面试题_第14张图片
主从切换完成后,哨兵就会向 +switch-master 频道发布新主节点的 IP 地址和端口的消息,这个时候客户端就可以收到这条信息,然后用这里面的新主节点的 IP 地址和端口进行通信了。

第四步:
继续监视旧主节点,当旧主节点重新上线时,哨兵集群就会向它发送 SLAVEOF 命令,让它成为新主节点的从节点

哨兵集群如何组成?
哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的。

在主从集群中,主节点上有一个名为__sentinel__:hello的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
Redis学习笔记 ---- 常见面试题_第15张图片

5.3 切片集群

当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。

采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。有两种方案将哈希槽映射到具体的 Redis 节点上:

· 平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。
· 手动分配: 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。

Redis学习笔记 ---- 常见面试题_第16张图片

脑裂现象

由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。

解决方案:当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。

在 Redis 的配置文件中有两个参数我们可以设置:
		min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
		min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。

6 Redis过期删除、内存淘汰

6.1 过期删除

每当对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中。即,「过期字典」保存了数据库中所有 key 的过期时间。

当查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:

· 如果不在,则正常读取键值;
· 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。

过期删除策略:「惰性删除+定期删除」

6.1.2 惰性删除

不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。

优缺点:

优点:因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。

缺点:如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。

6.1.3 定期删除

每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

Redis 的定期删除的流程:

· 从过期字典中随机抽取 20 个 key;
· 检查这 20 个 key 是否过期,并删除已过期的 key;
· 当「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%(超过 5 个(20/4)),则继续重复步骤 1;否则,则停止继续删除过期 key,然后等待下一轮再检查。

优缺点:

优点:通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。

缺点:难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。

6.2 持久化时对过期键如何处理

RDB 文件生成阶段和加载阶段:

RDB 文件生成阶段:从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,过期的键「不会」被保存到新的 RDB 文件中

RDB 加载阶段:RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况:
	「主服务器」运行模式:在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键「不会」被载入到数据库中
	「从服务器」运行模式:在载入 RDB 文件时,不论键是否过期都会被载入到数据库中。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。

AOF 文件写入阶段和 AOF 重写阶段:

AOF 文件写入阶段:如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值。

AOF 重写阶段:执行 AOF 重写时,会对 Redis 中的键值对进行检查,已过期的键不会被保存到重写后的 AOF 文件中

6.3 主从模式中对过期键如何处理

从库不会进行过期扫描(从库对过期处理是被动的,主库在key到期时,会在AOF文件中追加一条del指令,同步给所有从库,从库再执行指令删除)

6.4 内存淘汰机制

在 Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制(阈值通过配置项为 maxmemory设置)

内存淘汰机制策略:

不进行数据淘汰的策略:

noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。

进行数据淘汰的策略:

在设置了过期时间的数据中进行淘汰:
		· volatile-random:随机淘汰设置了过期时间的任意键值;
		· volatile-ttl:优先淘汰更早过期的键值。
		· volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
		· volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
		
在所有数据范围内进行淘汰:
		· allkeys-random:随机淘汰任意键值;
		· allkeys-lru:淘汰整个键值中最久未使用的键值;
		· allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

6.4.1 LRU、LFU 区别

LRU 全称是 Least Recently Used 翻译为最近最少使用,会选择淘汰最近最少使用的数据。

传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。

Redis实现LRU算法:
实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。

无法解决缓存污染问题

比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

在 Redis 4.0 之后引入了 LFU 算法来解决这个问题

LFU 全称是 Least Frequently Used 翻译为最近最不常用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

Redis 对象的结构如下:
Redis学习笔记 ---- 常见面试题_第17张图片
Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。

  • 在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。

  • 在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。

Redis实现LFU算法:据访问频率来淘汰数据的,而不只是访问次数。
Redis 在访问 key 时,对于 logc 是这样变化的:

先按照上次访问距离当前的时长,来对 logc 进行衰减;
然后,再按照一定概率增加 logc 的值

redis.conf 提供了两个配置项,用于调整 LFU 算法从而控制 logc 的增长和衰减:

  • lfu-decay-time 用于调整 logc 的衰减速度,它是一个以分钟为单位的数值,默认值为1,lfu-decay-time 值越大,衰减越慢;
  • lfu-log-factor 用于调整 logc 的增长速度,lfu-log-factor 值越大,logc 增长越慢。

7 Redis缓存设计

7.1 缓存问题

7.1.1 缓存雪崩问题

当大量缓存数据在同一时间过期(失效)时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃

解决方案

  • 将缓存失效时间随机打散: 可以在原有的失效时间基础上增加一个随机值(比如 1 到 10 分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率。
  • 设置缓存不过期: 可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。

7.1.2 缓存击穿问题

业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据。
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮

解决方案

  • 互斥锁方案(Redis 中使用 setNX 方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
  • 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

7.1.3 缓存穿透问题

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增

出现该问题一般有两种情况:

· 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
· 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

解决方案

  • 非法请求的限制
  • 设置空值或者默认值
  • 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在

7.2 缓存策略

热点数据动态缓存的策略总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据。

常见的缓存更新策略共有3种:

  • Cache Aside(旁路缓存)策略;
  • Read/Write Through(读穿 / 写穿)策略;
  • Write Back(写回)策略;

Cache Aside(旁路缓存)策略

Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。

Redis学习笔记 ---- 常见面试题_第18张图片
写策略的步骤:

  • 先更新数据库中的数据,再删除缓存中的数据。(不能先删除缓存再更新数据库,会在读写并发时造成缓存与数据库不一致问题)

读策略的步骤:

  • 如果读取的数据命中了缓存,则直接返回数据;
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

Cache Aside 策略适合读多写少的场景,不适合写多的场景,对缓存命中率有影响,可以考虑两种解决方案:

  • 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
  • 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。

Read/Write Through(读穿 / 写穿)策略

应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。

Read Through 策略

  • 先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。

Write Through 策略

当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:

  • 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。
  • 如果缓存中数据不存在,直接更新数据库,然后返回;

注意
无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。在使用本地缓存的时候可以考虑使用这种策略。

Write Back(写回)策略

在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。

Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 并没有异步更新数据库的功能。
Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。

Write Back 策略特别适合写多的场景,但数据不是强一致性的,而且会有数据丢失的风险。

7.3 数据库、缓存如何保持一致

无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象。

「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。

但出现数据库、缓存不一致的原因在于,不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。

解决方案
1、重试机制

引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
		· 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
		· 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

2、订阅 MySQL binlog,再操作缓存。

	更新数据库成功,就会产生一条变更日志,记录在 binlog 里。通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除

两种方式均采用异步操作缓存

8 Redis实战

8.1 实现延迟队列

使用**有序集合(ZSet)**的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。
使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。

Redis学习笔记 ---- 常见面试题_第19张图片

8.2 大key处理

大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。一般而言,下面这两种情况被称为大 key:

  • String 类型的值大于 10 KB;
  • Hash、List、Set、ZSet 类型的元素的个数超过 5000个;

影响持久化

1、大key对AOF日志的影响
三种AOF日志写回硬盘的策略只是在控制 fsync() 函数的调用时机。

  • 当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,

  • 当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。

  • 当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程。

2、大 Key 对 AOF 重写和 RDB 的影响
AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。

有两个阶段会导致阻塞父进程:

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;

  • 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;

当页表过大时,执行fork函数会发生阻塞;可以执行 info 命令获取到 latest_fork_usec 指标,表示 Redis 最近一次 fork 操作耗时。
在这里插入图片描述
如果 fork 耗时很大,比如超过1秒,则需要做出优化调整:

  • 单个实例的内存占用控制在 10 GB 以下,这样 fork 函数就能很快返回。

  • 如果 Redis 只是当作纯缓存使用,不关心 Redis 数据安全性问题,可以考虑关闭 AOF 和 AOF 重写,这样就不会调用 fork 函数了。

  • 在主从架构中,要适当调大 repl-backlog-size,避免因为 repl_backlog_buffer 不够大,导致主节点频繁地使用全量同步的方式,全量同步的时候,是会创建 RDB 文件的,也就是会调用 fork 函数。

其它影响

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。

如何删除大key?

  • 分批次删除
  • 异步删除(Redis 4.0版本以上):用 unlink 命令(Redis 4.0+)删除大 key,因为该命令的删除过程是异步的,不会阻塞主线程。

8.3 Redis管道

管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。
使用管道技术可以解决多个命令执行时的网络等待

管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。

8.4 Redis事务是否支持回滚?

Redis 中并没有提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。不支持事务回滚指的是不支持事务运行时错误的事务回滚。

DISCARD 命令用法:
Redis学习笔记 ---- 常见面试题_第20张图片
事务执行过程中,如果命令入队时没报错,而事务提交后,实际执行时报错了,正确的命令依然可以正常执行,所以这可以看出 Redis 并不一定保证原子性

作者不支持事务回滚的原因有以下两个:

  • 他认为 Redis 事务的执行时,错误通常都是编程错误造成的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为 Redis 开发事务回滚功能;
  • 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。

8.5 实现分布式锁

分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。
Redis学习笔记 ---- 常见面试题_第21张图片

Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:

· 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
· 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

加锁操作

需要满足三个条件。

  • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,使用 SET 命令带上 NX 选项来实现加锁(原子操作);
  • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
  • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

满足这三个条件的分布式命令如下:
在这里插入图片描述

· lock_key 就是 key 键;
· unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
· NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
· PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。

解锁操作

解锁的时候,先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
Redis学习笔记 ---- 常见面试题_第22张图片
解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

优缺点

基于 Redis 实现分布式锁的优点:

1、性能高效(这是选择缓存实现分布式锁最核心的出发点)。
2、实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。
3、避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。

基于 Redis 实现分布式锁的缺点:

1、超时时间不好设置。
2、Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。

如何解决集群情况下分布式锁的可靠性?

Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。

它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。

Redlock 算法的基本思路:是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。

Redlock 算法加锁三个过程:

  • 第一步是,客户端获取当前时间(t1)。

  • 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:

    • 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
    • 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
  • 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

你可能感兴趣的:(个人学习笔记,学习笔记,redis,学习,数据库)