Redis基础知识与原理

Redis 学习笔记

本文章为学习过程中整理的知识笔记,部分图文、知识来源于小林。

Redis 数据结构

Redis是如何实现键值对数据库的

Redis 是使用了一个「哈希表」保存所有键值对,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对。哈希表存放的是指向键值对数据的指针,包括 void * key 和 void * value 指针,分别指向了实际的键对象和值对象。

img

img

String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
Hash 类型:缓存对象、购物车等。
Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
GEO(3.2 版新增):存储地理位置信息的场景,比如店铺地理位置;
Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,					支持以消费组形式消费数据。

String 类型内部实现

String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)。相对于C的原生字符串,SDS的优点:
1.SDS 不仅可以保存文本数据,还可以保存二进制数据。这是因为原生字符串使用\0 (空字符)标识结尾,这无法存图片等二进制数据。SDS使用len属性记录字符串的长度,并且SDS的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。

2.SDS 获取字符串长度的时间复杂度是 O(1)。SDS 结构里用 len 属性记录了字符串长度。
3.Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。拼接字符串之前会检查SDS空间,会自动扩容。

List 类型内部实现

List 类型的底层数据结构是由双向链表或压缩列表实现的:
1.如果列表的元素个数小于 512 个,且列表每个元素的值都小于 64 字节,选择压缩列表作为数据结构,否则选择双向链表。
2 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

为什么从双向链表改进成压缩列表,到改进成quicklist?
原因:1.链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。
     2.保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大。  所以根据这两点改成压缩列表

压缩列表是什么?
一种内存很紧凑的数据结构,占用一块连续的内存空间,类似数组。会根据不同长度的数据,进行空间大小分配

压缩列表的缺陷 :
1.不能保存过多的元素,否则查询效率就会降低,因为查询是逐个查询;
2.新增或修改某个元素时,如果空间不够,压缩列表的内存空间需要重新分配。而当新插入的元素很大时,可能会导致后续元素占用的空间都发生变化(连锁更新),导致每个元素的空间都需要重新分配。   根据这两个问题改成了quicklist来实现List

img

  • prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;
  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

Hash 类型内部实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
1.如果哈希类型的元素个数小于 512 个,且列表每个元素的值都小于 64 字节,选择压缩列表作为数据结构,否则选择哈希表。
2. Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了

Set 类型内部实现

Set 类型的底层数据结构是由整数集合或哈希表实现的:
1.如果集合中的元素都是整数且元素个数小于 512,选择整数集合作为Set类型的底层数据结构,否则选择哈希表。

ZSet 类型内部实现

Zset 类型的底层数据结构是由压缩列表或跳表实现的:
1.如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,选择压缩列表作为ZSet类型的底层数据结构。
2.由于链表的查询时间复杂度为O(n), 跳表是在链表的基础上改进而来,实现一种多层级索引的有序链表,查询复杂度为O(logn)。
实现跳的关键是跳表的结构体中有节点level数组,保存了指向下一个跳表节点的指针和跨度。
为什么用跳表而不用红黑树或者二叉树呢?
因为zset需要范围查询,这个跳表效率更高,实现起来也比较简单

BitMap类型内部实现

Bitmap 类型的底层数据结构是String类型
String类型是会保存二进制的字节数组,所有Redis就把字节数组的每个bit位利用起来,用来表示一个元素的二值状态

GEO类型内部实现

底层数据结构是Sorted Set类型
GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。

Stream类型内部实现

Redis5才提出,内部实现是链表
同一个消费组里的消费者不能消费同一条消息。
如何保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息?
也存在消费方的消息确认机制,Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。

压缩列表 vs quicklist vs listpack

压缩列表的优点
设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,节省内存开销。

压缩列表的缺点:
1.不能保存过多的元素,否则查询效率就会降低;因为是组个遍历检查
2.新增或修改某个元素时,如果空间不够,压缩列表的内存空间需要重新分配。而当新插入的元素很大时,可能会导致后续元素占用的空间都发生变化(连锁更新),导致每个元素的空间都需要重新分配。   根据这两个问题改成了quicklist来实现List

quicklist
quicklist 的结构体跟链表的结构体类似。quicklistNode 结构体里包含了前一个节点和下一个节点指针,这样每个 quicklistNode 形成了一个双向链表。但是链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。quicklist通过控制节点的压缩列表的大小,来尽量规避连锁更新的问题,但并没有完全解决连锁更新问题。
引入listpack

img

listpack 结构设计:
listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。
它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

img

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • data,实际存放的数据;
  • len,encoding+data的总长度;

Redis 线程模型

Redis 是单线程吗?

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

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

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;

Redis 单线程模式是怎样的?

img

初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:
1.首先查看发送队列是否有任务,如果有发送任务,则通过write函数进行发送数据出去。
2.接着,调用epoll_wait 函数等待事件的到来:
a.如果是连接事件,则调用连接事件处理函数。该函数会接收连接,将已经连接的socket添加到epoll,并注册读事件处理函数。
b.如果是读事件到来,则调用读事件处理函数。该函数会调用read获取客户端发送的数据,解析命令,执行命令,->把客户端对象添加到发送队列-> 并把执行结果写到发送缓冲区等待发送。
C.如果是写事件到来,调用写事件处理函数。把缓冲区的数据发送出去,如果这轮数据没有发送完,就会继续注册写事件处理函数,等待epoll_wait发现可写后再处理。

Redis 采用单线程为什么还这么快?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Redis 持久化

Redis 共有三种数据持久化的方式:

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

AOF 日志

Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。

img

AOF 写回策略有几种?

Redis 写入 AOF 日志的过程:

img
  1. Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
  2. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
  3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。由Redis.conf 配置文件中的 appendfsync 配置项控制:

img

AOF 文件重写机制

Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

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

重写 AOF 日志的过程

Redis 的**重写 AOF 过程是由后台子进程 \*bgrewriteaof\* 来完成的,这么做可以达到两个好处:

1.子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
2.子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。

触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。

如何解决重写AOF过程数据不一致问题

为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

img

在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:

  • 执行客户端发来的命令;
  • 将执行后的写命令追加到 「AOF 缓冲区」;
  • 将执行后的写命令追加到 「AOF 重写缓冲区」;

当子进程完成 AOF 重写工作后,给主进程发送一条信息(异步),主进程收到该信号之后,调用信号处理函数:

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

RDB 快照

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据(AOF的缺点)。

Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作(RDB的缺点)

RDB 做快照时会阻塞线程吗?

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:

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

RDB 在执行快照的时候,数据能修改吗?

执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的,关键的技术就在于写时复制技术。
写时复制技术:
1.执行 bgsave 命令的时候,会通过 fork() 创建子进程,快照持久化完全由子进程处理,父进程继续处理客户端的请求。此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主进程执行读操作,则主进程和 bgsave 子进程互相不影响。
2.如果主进程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主进程仍然可以直接修改原来的数据。

写时复制:

img

混合持久化

RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。
AOF 优点是丢失数据少,但是数据恢复不快。因此提出 混合使用 AOF 日志和内存快照。

混合持久化工作在 AOF 日志重写过程:
1.在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件
2.然后主线程处理的操作命令会被记录在重写缓冲区里
3.重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件
4.写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件

使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

优点:
混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动。加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。

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

Redis 集群

主从复制

将数据同步到多台redis服务器上,即一主多从模式,且主从服务器之间采用的是读写分离的方式。也就是从服务器只读,并接受主服务器同步过来的操作命令。注意,主从服务器之间的命令复制是异步的。

图片

第一次同步**-全量复制**

主从服务器间的第一次同步的过程可分为三个阶段:

  • 第一阶段是建立链接、协商同步;
  • 第二阶段是主服务器同步数据给从服务器;
  • 第三阶段是主服务器发送新写操作命令给从服务器。

图片

第一阶段:建立链接、协商同步
1.从服务器执行了 replicaof 命令后,从服务器就会给主服务器发送 psync(主服务器的 runID,复制进度) 命令,表示要进行数据同步。
2.主服务器收到 psync 命令后,会用 FULLRESYNC 作为响应命令返回给对方。FULLRESYNC==全量复制

第二阶段:主服务器同步数据给从服务器
1.主服务器会执行 bgsave 命令来生成 RDB 文件,然后把文件发送给从服务器。
2.从服务器收到 RDB 文件后,会先清空当前的数据,然后载入 RDB 文件。
3.主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里:
   主服务器生成 RDB 文件期间;主服务器发送 RDB 文件给从服务器期间;「从服务器」加载 RDB 文件期间;
   
第三阶段:主服务器发送新写操作命令给从服务器
1.主服务器将 replication buffer 缓冲区里所记录的写操作命令发送给从服务器.

主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 长连接。
后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。

增量复制

如果主从服务器间的网络连接断开了,那么就无法进行命令传播了,这时从服务器的数据就没办法和主服务器保持一致了,客户端就可能从「从服务器」读到旧的数据。

在 Redis 2.8 之前,如果主从服务器在命令同步时出现了网络断开又恢复的情况,从服务器就会和主服务器重新进行一次全量复制,很明显这样的开销太大了。

从 Redis 2.8 开始,网络断开又恢复后,主从服务器会采用增量复制的方式继续同步,也就是只会把网络断开期间主服务器接收到的写操作命令,同步给从服务器。

图片

增量复制过程:
1.从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1;
2.主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据;
3.然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。

主服务器怎么知道要将哪些增量数据发送给从服务器呢?
repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置。

 repl_backlog_buffer 缓冲区是在主服务器进行命令传播时,不仅将命令发送给从服务器,也会写到这个缓冲区。
 
网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作:
1.如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式;
2.相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式

图片

怎么判断 Redis 某个节点是否正常工作

  • Redis 判断接点是否正常工作,基本都是通过互相的 ping-pong 心态检测机制,如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应,集群就会认为这个节点挂掉了,会断开与这个节点的连接。
    
    Redis 主从节点发送的心态间隔是不一样的,而且作用也有一点区别:
    1.Redis 主节点默认每隔 10 秒对从节点发送 ping 命令,判断从节点的存活性和连接状态
    2.Redis 从节点每隔 1 秒发送 replconf ack{offset} 命令,给主节点上报自身当前的复制偏移量,目的是为了:
        - 实时监测主从节点网络状态;
        - 上报自身复制偏移量, 检查复制数据是否丢失, 如果从节点数据丢失, 再从主节点的复制缓冲区中拉取丢失数据。
    

如何应对主从数据不一致?

之所以会出现主从数据不一致的现象,是因为主从节点间的命令复制是异步进行的,所以无法实现强一致性保证。因为是主节点并不会等从节点执行完命令后才返回结果给客户端。

如何应对主从数据不一致?
第一种办法:尽量保证主从节点间网络状况良好
第二种方法:可以开发一个外部程序来监控主从节点间的复制进度。具体做法:

  1. Redis 的 INFO replication 命令可以查看主节点接收写命令的进度信息(master_repl_offset)和从节点复制写命令的进度信息(slave_repl_offset),master_repl_offset - slave_repl_offset = 进度差值,如果进度差值大于阈值,则让客户端不再和这个从节点连接进行数据读取。

主从切换如何减少数据丢失?

主从切换过程中,产生数据丢失的情况有两种:

  • 异步复制同步丢失
  • 集群产生脑裂数据丢失
异步复制同步丢失

减少异步复制的数据丢失的方案:Redis 配置里有一个参数 min-slaves-max-lag,表示一旦所有的从节点数据复制和同步的延迟都超过了 min-slaves-max-lag 定义的值,那么主节点就会拒绝接收任何请求。
那么对于客户端,当客户端发现 master 不可写后,我们可以采取降级措施,将数据暂时写入本地缓存和磁盘中。

集群产生脑裂数据丢失

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

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

即使原主节点是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从节点进行同步,自然也就无法和从节点进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主节点就会被限制接收客户端写请求,客户端也就不能在原主节点中写入新数据了

等到新主节点上线时,就只有新主节点能接收和处理客户端请求,此时,新写的数据会被直接写到新主节点中。而原主节点会被哨兵降为从节点,即使它的数据被清空了,也不会有新数据丢失

Redis 过期删除与内存淘汰

Redis 使用的过期删除策略是什么?

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

当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:
- 如果不在,则正常读取键值;
- 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,查看是否过期。

Redis 使用的过期删除策略是「惰性删除+定期删除

定时删除策略
在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
优点:保证过期的key会被尽快的删除
缺点:在过期key比较多情况下,删除过期key可能占用相当一部分cpu时间。

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

惰性删除策略的优点:
因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。
惰性删除策略的缺点:
如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,只要这个过期 key 一直没有被访问,它所占用的内存就不会释放


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

Redis 的定期删除的流程:
1.从过期字典中随机抽取 20 个 key;
2.检查这 20 个 key 是否过期,并删除已过期的 key;
3.如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 	25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

内存淘汰策略
当Redis内存占用达到上限时,会根据内存淘汰策略来选择一些键进行删除,腾出内存空间

Redis 持久化时,对过期键会如何处理的?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Redis 内存淘汰策略

Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是我们设置的最大运行内存。

不进行数据淘汰的策略
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 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。


Redis所采用的LRU(最近最少使用)算法
它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的优点:
不用为所有的数据维护一个大链表,节省了空间占用;
不用在每次数据访问时都移动链表项,提升了缓存的性能;
但是没有解决缓存污染问题

Redis所采用的LFU算法
LFU 算法(最近最不常用)是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
比LRU算法多了记录数据的访问频次信息。
LRU算法实现
算法思路:
1.新数据插入到链表头部
2.每当缓存命中,则将数据移到链表头部
3.当链表满的时候,将链表尾部的数据丢弃。
/**
 * 在版本一中,我们自己利用HashMap和一个简单的双向链表来实现LRU缓存
 */
class LRUCache {
    // 双向链表节点定义
    class Node{
        int key;
        int val;
        Node pre;
        Node next;
    }
    private int capacity;
    //创建虚拟的哨兵
    private Node first;
    private Node last;

    private Map<Integer, Node> map;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        map = new HashMap<>();
        first = new Node();
        last = new Node();
        first.next = last;  //哨兵初始化
        last.pre = first;
    }
    
    public int get(int key) {
        Node node = map.get(key);
        if (node == null) return -1;
        moveToHead(node);
        return node.val;
    }
    
    public void put(int key, int value) {
        Node node = map.get(key);
        if (node == null){
            node = new Node();
            node.key = key;
            node.val = value;

            if (map.size() == capacity){
                removeLast();
            }

            addToHead(node);
            map.put(key, node);
        }else {
            node.val = value;
            moveToHead(node);
        }
    }

    public void removeLast(){
        map.remove(last.pre.key);
        Node preNode = last.pre.pre;
        if (preNode != null){
            preNode.next = last;
            last.pre = preNode;
        }
    }

    public void addToHead(Node node){
        node.next = first.next;
        node.pre = first;
        
        first.next.pre = node;
        first.next = node;

    }

    public void moveToHead(Node node){
         //将node分离
        node.pre.next = node.next;
        node.next.pre = node.pre;

        //将node插入头部
        addToHead(node);
    }
}

//实现LFU:移除频次最少的,如果频次相同,移除最久未使用的
class LFUCache {
    //使用优先级队列处理node排序,node需要实现compareTo ,最小的元素就是优先级最高的元素
    class Node implements Comparable<Node>{
        int key;
        int value;
        int freq = 1;
        int index;
        public Node(){ }

        public Node(int key, int value, int index){
            this.key = key;
            this.value = value;
            this.index = index;
        }
		
        //升序排序
        @Override
        public int compareTo(Node o){
            //优先比较频次 freq,频次相同再比较index
            int minus = this.freq - o.freq;
            return minus == 0? this.index - o.index : minus;
        }
    }

    private int capacity;
    private int size; //当前缓存的元素个数
    private int index = 0;  //全局自增
    private Map<Integer, Node> map;
    private Queue<Node> queue;//优先队列

    public LFUCache(int capacity) {
        this.capacity = capacity;
        queue = new PriorityQueue<>(capacity);
        map = new HashMap<>();
    }
    
    public int get(int key) {
        Node node = map.get(key);
        if (node == null){
            return -1;
        }
        //每访问一次,频次和全局index都自增 1
        node.freq++;
        node.index = index++;
        // 每次都重新remove,再offer是为了让优先队列能够对当前Node重排序
        //不然的话,比较的 freq 和 index 就是不准确的
        queue.remove(node);
        queue.offer(node);
        return node.value;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null){//存在
            node.value = value;
            node.freq++;
            node.index = index++;
            queue.remove(node);
            queue.offer(node);
        }else {//不存在
            if (map.size() == capacity){
                map.remove(queue.poll().key);//移除第一个
            }
            Node newNode = new Node(key, value, index++);
            queue.offer(newNode);
            map.put(key, newNode);
        }
    }
}
class LFUCache { //使用双链表来实现
    // 双向链表节点定义
    class Node{
        int key; 
        int val;
        int fre =1; //频率
        Node pre;
        Node next;
    }
    private int capacity;
    //创建虚拟的哨兵
    private Node first;
    private Node last;
    private Map<Integer, Node> map;
    public LFUCache(int capacity) {
        this.capacity = capacity;
        map = new HashMap<>();
        first = new Node();
        last = new Node();
        first.next = last;  //哨兵初始化
        last.pre = first;
    }
    
    public int get(int key) {
        Node node = map.get(key);
        if (node == null) return -1;
        node.fre++;
        moveToPosition(node);
        return node.val;
    }
    
    public void moveToPosition(Node node){
        Node nextNode = node.next;
        //把node先删除
        node.pre.next = node.next;
        node.next.pre = node.pre;
        //遍历到符合要求的节点
        while (node.fre >= nextNode.fre && nextNode != last){
            nextNode = nextNode.next;
        }
        //把当前元素插入到nextNode前面
        node.next = nextNode;
        node.pre = nextNode.pre;
        nextNode.pre.next = node;
        nextNode.pre = node;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null){//存在
            node.val = value;
            node.fre++;
            moveToPosition(node);
        }else {//不存在
            if (map.size() == capacity){ //满了删除
                map.remove(first.next.key);
                //删除头节点下一个
                removeNode(first.next);
            }
                node = new Node();
                node.key = key;
                node.val = value;
                map.put(key, node);
                //插到头部
                node.next = first.next;
                node.pre = first;
                first.next.pre = node;
                first.next = node;
                //移动到合适的位置
                moveToPosition(node);
        }
    }
    //移除元素
    public void removeNode(Node node){
        node.pre.next = node.next;
        node.next.pre = node.pre;
    }
}

Redis缓存设计

常见的缓存更新策略

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

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

实际开发中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外两种策略应用不了。

Cache Aside(旁路缓存)策略---该策略又可以细分为「读策略」和「写策略」。
1.写策略的步骤:先更新数据库中的数据,再删除缓存中的数据。
2.读策略的步骤:先更新数据库,再更新缓存。
3.删除缓存,再更新数据库,有大概率出现缓存脏数据的问题。

Cache Aside 策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:
一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。


Read/Write Through(读穿 / 写穿)策略
(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。
    
1.Read Through 策略 : 
如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。
2.Write Through 策略:
当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:
如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。
如果缓存中数据不存在,直接更新数据库,然后返回;

Write Back(写回)策略
Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。类似page cache
Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。
但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险.

Redis 的大 key 如何处理

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如何找到大 key ?
1、redis-cli --bigkeys 查找大key
如:redis-cli -h 127.0.0.1 -p6379 -a "password" -- bigkeys
缺点:
这个方法只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey;
对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。元素个数!=内存占用

2、使用 SCAN 命令查找大 key
使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。

3、使用 RdbTools 工具查找大 key
使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。

如何删除大 key?
1.分批次删除

2.异步删除(Redis 4.0版本以上)
以采用异步删除法,用 unlink 命令代替 del 来删除。这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。

大key对持久化的影响

大 Key 对 AOF 日志的影响

当使用 Always 策略的时候,必须等到把数据写到AOF日志文件才返回结果给客户端。如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。

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

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

大 Key 对 AOF 重写和 RDB 的影响

当AOF日志写了很多大key,很快就会触发AOF重写机制。AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程):

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

  • 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。

Redis 管道

管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。
使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。

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

如何用 Redis 实现分布式锁

Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:
如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。
1.加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
2.锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
3.锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

满足这三个条件的分布式命令如下:SET lock_key unique_value NX PX 10000 

锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性。

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

基于 Redis 实现分布式锁的缺点:
1.超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。合理的超时时间设置可以使用Redission:Redisson内部提供·了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。
2.Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。

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

为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

Redlock 算法加锁三个过程:
1.客户端获取当前时间(t1)
2.客户端按顺序依次向 N 个 Redis 节点执行加锁操作
3.一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

哨兵机制

Redis 在 2.8 版本以后提供的哨兵(*Sentinel*)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。

如何判断主节点真的故障了?

img
哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「主观下线」。

主观下线:单个哨兵认为主节点下线     客观下线:多个哨兵一致认为主节点已下线,成为事实。

怎么判定主节点为「客观下线。
当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。投票大于阈值就认为客观下线。

哨兵判断完主节点客观下线后,哨兵就要开始在多个「从节点」中,选出一个从节点来做新主节点。

由哪个哨兵进行主从故障转移?

1.需要在哨兵集群中选出一个 leader,让 leader 来执行主从切换。候选者就是判断主节点为「客观下线」的哨兵。
在投票过程中,任何一个「候选者」,要满足两个条件:
第一,拿到半数以上的赞成票;
第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

主从故障转移的过程

主从故障转移操作包含以下四个步骤:

  • 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个网络状态良好的从节点,并将其转换为主节点。
  • 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;
  • 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;
  • 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;
步骤一:选出新主节点
首先把网络状态不好的从节点过滤掉了,接下来要对所有从节点进行三轮考察:优先级、复制进度、ID 号。在进行每一轮考察的时候,哪个从节点优先胜出,就选择其作为新主节点。

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

步骤二:将从节点指向新主节点-这一动作,哨兵可以通过向「从节点」发送 SLAVEOF 命令来实现。

步骤三:通知客户的主节点已更换
主要通过 Redis 的发布者/订阅者机制来实现,哨兵就会向 +switch-master 通道发布新主节点的 IP 地址和端口的消息,

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

哨兵集群是如何组成的

哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的

在主从集群中,主节点上有一个名为__sentinel__:hello的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
在下图中,哨兵 A 把自己的 IP 地址和端口的信息发布到__sentinel__:hello 频道上,哨兵 B 和 C 订阅了该频道。那么此时,哨兵 B 和 C 就可以从这个频道直接获取哨兵 A 的 IP 地址和端口号。然后,哨兵 B、C 可以和哨兵 A 建立网络连接。

img

那哨兵集群如何知道「从节点」的信息

主节点知道所有「从节点」的信息,所以哨兵会每 10 秒一次的频率向主节点发送 INFO 命令来获取所有「从节点」的信息。哨兵就可以根据从节点列表中的连接信息,和每个从节点建立连接,并在这个连接上持续地对从节点进行监控。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值