写在前面

文章参考自:

小林coding

尚硅谷Redis6教程

黑马程序员Redis入门到实战教程

https://zhangc233.github.io/2021/05/02/Redis/

Redis基础知识

为什么使用Redis

当过多用户同时访问数据库时压力会很大,这样会导致在访问数据的时候速度很慢,使得用户在体验的时候响应很慢从而降低了用户体验,而Redis这种无关系型数据库是基于内存的,可以较快去进行访问数据,但毕竟内存是有限的,所以并不是因为它访问快而全部使用这种NoSQL数据库,它与MySQL这种关系型数据库之间是相互合作的,共同完成任务的。

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 具备「高性能」和「高并发」两种特性

Redis网络模型

五种IO模型

阻塞IO

应用程序想要去读取数据,他是无法直接去读取磁盘数据的,他需要先到内核里边去等待内核操作硬件拿到数据,这个过程就是1,是需要等待的,等到内核从磁盘上把数据加载出来之后,再把这个数据写给用户的缓存区,这个过程是2,如果是阻塞IO,那么整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。

1653897115346

具体流程如下图:

用户去读取数据时,会去先发起recvform一个命令,去尝试从内核上加载数据,如果内核没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,会把数据拷贝到用户态,并且返回ok,整个过程,都是阻塞等待的,这就是阻塞IO

总结如下:

顾名思义,阻塞IO就是两个阶段都必须阻塞等待:

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 此时用户进程也处于阻塞状态

阶段二:

  • 数据到达并拷贝到内核缓冲区,代表已就绪
  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

1653897270074

非阻塞IO

可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

跟阻塞IO相比,就是第一阶段尝试读数据时,前者是等待,后者是一直询问

1653897490116

⭐⭐⭐多路IO复用

解决多路复用模型是如何知道内核数据是否就绪的问题了

这个问题的解决依赖于提出的:

文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。

通过FD,我们的网络模型可以利用一个线程监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

阶段一:

  • 用户进程调用select,指定要监听的FD集合
  • 核监听FD对应的多个socket
  • 任意一个或多个socket数据就绪则返回readable
  • 此过程中用户进程阻塞

阶段二:

  • 用户进程找到就绪的socket
  • 依次调用recvfrom读取数据
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据

当用户去读取数据的时候,不再去直接调用recvfrom了,而是调用select的函数,select函数会将需要监听的数据交给内核,由内核去检查这些数据是否就绪了,如果说这个数据就绪了,就会通知应用程序数据就绪,然后来读取数据,再从内核中把数据拷贝给用户态,完成数据处理,如果N多个FD一个都没处理完,此时就进行等待。

用IO复用模式,可以确保去读数据的时候,数据是一定存在的,他的效率比原来的阻塞IO和非阻塞IO性能都要高

1653898691736

IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:

  • select
  • poll
  • epoll

其中select和poll相当于是当被监听的数据准备好之后,他会把你监听的FD整个数据都发给你,你需要到整个FD中去找,哪些是处理好了的,需要通过遍历的方式,所以性能也并不是那么好

而epoll,则相当于内核准备好了之后,他会把准备好的数据,直接发给你,咱们就省去了遍历的动作。

select

具体流程:

简单说,就是我们把需要处理的数据封装成FD,然后在用户态时创建一个fd的集合(这个集合的大小是要监听的那个FD的最大值+1,但是大小整体是有限制的 ),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据,

比如要监听的数据,是1,2,5三个数据,此时会执行select函数,然后将整个fd发给内核态,内核态会去遍历用户态传递过来的数据,如果发现这里边都数据都没有就绪,就休眠,直到有数据准备好时,就会被唤醒,唤醒之后,再次遍历一遍,看看谁准备好了,然后再将处理掉没有准备好的数据,最后再将这个FD集合写回到用户态中去,此时用户态就知道了,奥,有人准备好了,但是对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求

select模式

select模式存在三个问题:

  1. 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间

  2. select无法得知具体是哪个fd就绪,需要遍历整个fd_set

  3. fd_set监听的fd数量不能超过1024

poll

与select对比:

  • select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
  • 监听FD越多,每次遍历消耗时间也越久,性能反而会下降,所以其实是有上限的

epoll

epoll模式是对select和poll的改进,它提供了三个函数:

第一个是:eventpoll的函数,他内部包含两个结构:

  1. 红黑树(rb_root)-> 记录的事要监听的FD

  2. 链表(list_head)->一个链表,记录的是就绪的FD

第二个是:紧接着调用epoll_ctl操作,将要监听的数据添加到红黑树上去,并且给每个fd设置一个监听函数,这个函数会在fd数据就绪时触发,就是准备好了,现在就把fd把数据添加到list_head中去

第三个是:调用epoll_wait函数(监听函数)

就去等待,在用户态创建一个空的events数组,当就绪之后,我们的回调函数会把数据添加到list_head中去,当调用这个函数的时候,会去检查list_head,当然这个过程需要参考配置的等待时间,可以等一定时间,也可以一直等, 如果在此过程中,检查到了list_head中有数据会将数据添加到链表中,此时将数据放入到events数组中,并且返回对应的操作的数量,用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去拿数据。

epoll模式

优势:(对应的是select的三个缺点)

  1. 减少了FD的拷贝,select和poll都是每次都要重新把全部的FD再拷贝到内核态,而epoll每次只需要拷贝新来的FD到内核态中

  2. 从内核态拷贝FD到用户态时,不再是把全部的FD拷贝到用户态,而是只把就绪链表上的FD拷贝回内核态

  3. 可以尽可能多地监听FD的数量,因为红黑树增加再多结点也不会影响其性能

三种模式的对比与总结

select模式存在的三个问题:

  • 能监听的FD最大不超过1024

  • 每次select都需要把所有要监听的FD都拷贝到内核空间

  • 每次都要遍历所有FD来判断就绪状态

poll模式的问题:

  • poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降

epoll模式中如何解决这些问题的?

  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高

  • 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间

  • 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

事件通知机制

当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:

  • LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
  • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。

🔴🟢🟡结论:

  • LT:事件通知频率较高,会有重复通知,影响性能

  • ET:仅通知一次,效率高。可以基于非阻塞IO循环读取解决数据读取不完整问题

select和poll仅支持LT模式,epoll可以自由选择LT和ET两种模式

基于epoll的服务器端流程

我们来梳理一下这张图

服务器启动以后,服务端会去调用epoll_create,创建一个epoll实例,epoll实例中包含两个数据

1、红黑树(为空):rb_root 用来去记录需要被监听的FD

2、链表(为空):list_head,用来存放已经就绪的FD

创建好了之后,会去调用epoll_ctl函数,此函数会将需要监听的数据添加到rb_root中去,并且对当前这些存在于红黑树的节点设置回调函数,当这些被监听的数据一旦准备完成,就会被调用,而调用的结果就是将红黑树的fd添加到list_head中去(但是此时并没有完成)

3、当第二步完成后,就会调用epoll_wait函数,这个函数会去校验是否有数据准备完毕(因为数据一旦准备就绪,就会被回调函数添加到list_head中),在等待了一段时间后(可以进行配置),如果等够了超时时间,则返回没有数据,如果有,则进一步判断当前是什么事件,如果是建立连接时间,则调用accept() 接受客户端socket,拿到建立连接的socket,然后建立起来连接,如果是其他事件,则把数据进行写出

image-20231106145531092

⭐⭐⭐此处回看第171集:https://www.bilibili.com/video/BV1cr4y1671t?p=171&vd_source=fa7ba4ae353f08f1d08d1bb24528e96c

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

阶段一:

  • 用户进程调用sigaction,注册信号处理函数
  • 内核返回成功,开始监听FD
  • 用户进程不阻塞等待,可以执行其它业务
  • 当内核数据就绪后,回调用户进程的SIGIO处理函数

阶段二:

  • 收到SIGIO回调信号
  • 调用recvfrom,读取
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据

1653911776583

当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

异步IO

这种方式,不仅仅是用户态在试图读取数据后,不阻塞,而且当内核的数据准备完成后,也不会阻塞

他会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。

1653911877542

对比

最后用一幅图,来说明他们之间的区别

1653912219712

小林coding面试题

Redis 是单线程吗?

如果仅仅聊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)不停轮询这个队列,拿出任务就去执行对应的方法即可。

img

Redis 单线程模式是怎样的?

IO多路复用+事件派发

Redis 6.0 版本之前的单线模式如下图:

img

图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 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 采用单线程为什么还这么快?

  • 大部分操作都在内存中完成,并且采用了高效的数据结构(例如epoll中的红黑树)

  • 避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。

  • 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求:简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理

Redis 6.0 之前为什么使用单线程?

CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制

使用多线程可能带来的后果:增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗

Redis 6.0 之后为什么引入了多线程?

虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上

所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以不要误解Redis 有多线程同时执行命令


因此, 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数据类型

五种数据类型图

String

数据结构

String 的数据结构为int简单动态字符串 (Simple Dynamic String, 缩写 SDS),是可以修改的字符串,内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。

image-20231023212252966

如图中所示,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。

编码类型

  1. 其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512mb.

  2. 如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高。

不超过44的原因是,加上Redisobject的头信息刚好不会超过64,而Redis中的申请内存又是以2的几次方来申请的,这样能刚好避免内存碎片的问题。

  1. 如果存储的字符串是整数值,并且大小在LONG MAX范围内,则会采用INT编码:直接将数据保存在RedisObiect的ptr指针位置(刚好8字节),不再需要SDS了。

三种编码类型的图示

三种编码类型的图示

常用命令

  • SET key value
    设置指定key的值
  • GET key
    获取指定key的值
  • SETEX key seconds value
    设置指定key的值,并将key的过期时间设为seconds秒
  • SETNX key value
    只有在key不存在时设置key的值

应用场景

  1. 缓存对象
    • 直接缓存整个对象的 JSON,命令例子: SET user:1 '{"name":"xiaolin", "age":18}'
    • 采用将 key 进行分离为 user:ID:属性,采用 MSET 存储,用 MGET 获取各属性值,命令例子: MSET user:1:name xiaolin user:1:age 18 user:2:name xiaomei user:2:age 20
  2. 常规计数:计算访问次数、点赞、转发、库存数量等等
  3. 分布式锁
  4. 共享session信息:将session保存在redis来保存用户的会话(登录)状态

List

数据结构&&编码类型

List 类型的底层数据结构是由双向链表压缩列表实现的:

  • 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

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

常用命令

Redis列表是简单的字符串列表,按照插入顺序排序,常用命令:

  • LPUSH key value1 [value2]
    将一个或多个值插入到列表头部
  • LRANGE key start stop
    获取列表指定范围内的元素
  • RPOP key
    移除并获取列表最后一个元素
  • LLEN key
    获取列表长度
  • BRPOP key1 [key2 ] timeout
    移出并获取列表的最后一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止

应用场景

消息队列

消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性

Redis 的 List 和 Stream 两种数据类型,就可以满足消息队列的这三个需求。我们先来了解下基于 List 的消息队列实现方法,后面在介绍 Stream 数据类型时候,在详细说说 Stream。

  1. 如何满足消息保序需求?

List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了。

List 可以使用 LPUSH + RPOP (或者反过来,RPUSH+LPOP)命令实现消息队列。

img

  • 生产者使用 LPUSH key value[value...] 将消息插入到队列的头部,如果 key 不存在则会创建一个空的队列再插入消息。
  • 消费者使用 RPOP key 依次读取队列的消息,先进先出。

不过,在消费者读取数据时,有一个潜在的性能风险点。

在生产者往 List 中写入数据时,List 并不会主动地通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停地调用 RPOP 命令(比如使用一个while(1)循环)。如果有新消息写入,RPOP命令就会返回结果,否则,RPOP命令返回空值,再继续循环。

所以,即使没有新消息写入List,消费者也要不停地调用 RPOP 命令,这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。

为了解决这个问题,Redis提供了 BRPOP 命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用RPOP命令相比,这种方式能节省CPU开销。

img

  1. 如何处理重复的消息?

消费者要实现重复消息的判断,需要 2 个方面的要求:

  • 每个消息都有一个全局的 ID。
  • 消费者要记录已经处理过的消息的 ID。当收到一条消息后,消费者程序就可以对比收到的消息 ID 和记录的已处理过的消息 ID,来判断当前收到的消息有没有经过处理。如果已经处理过,那么,消费者程序就不再进行处理了。

但是 List 并不会为每个消息生成 ID 号,所以我们需要自行为每个消息生成一个全局唯一ID,生成之后,我们在用 LPUSH 命令把消息插入 List 时,需要在消息中包含这个全局唯一 ID。

例如,我们执行以下命令,就把一条全局 ID 为 111000102、库存量为 99 的消息插入了消息队列:

shell
1
2
> LPUSH mq "111000102:stock:99"
(integer) 1
  1. 如何保证消息可靠性?

当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List 中读取消息了。

为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存

这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。

好了,到这里可以知道基于 List 类型的消息队列,满足消息队列的三大需求(消息保序、处理重复的消息和保证消息可靠性)。

  • 消息保序:使用 LPUSH + RPOP;
  • 阻塞读取:使用 BRPOP;
  • 重复消息处理:生产者自行实现全局唯一 ID;
  • 消息的可靠性:使用 BRPOPLPUSH

List 作为消息队列有什么缺陷?

List 不支持多个消费者消费同一条消息,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。

要实现一条消息可以被多个消费者消费,那么就要将多个消费者组成一个消费组,使得多个消费者可以消费同一条消息,但是 List 类型并不支持消费组的实现

这就要说起 Redis 从 5.0 版本开始提供的 Stream 数据类型了,Stream 同样能够满足消息队列的三大需求,而且它还支持「消费组」形式的消息读取。

Set

数据结构&&编码类型

Set 类型的底层数据结构是由哈希表或整数集合实现的:

  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

Java 中 HashSet 的内部实现使用的是 HashMap,只不过所有的 value 都指向同一个对象。Redis 的 set 结构也是一样,它的内部也使用 hash 结构,所有的 value 都指向同一个内部值。

常用命令

Redis set是string类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据,常用命令:

  • SADD key member1 [member2]

    向集合添加一个或多个成员

  • SMEMBERS key
    返回集合中的所有成员

  • SCARD key
    获取集合的成员数

  • SINTER key1 [key2]
    返回给定所有集合的交集

  • SUNION key1 [key2]
    返回所有给定集合的并集

  • SDIFF key1 [key2]
    返回给定所有集合的差集

  • SREM key member1 [member2]

    移除集合中一个或多个成员

应用场景

  1. 点赞:Set 类型可以保证一个用户只能点一个赞
  2. 共同关注:Set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等
  3. 抽奖:存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次

Hash

数据结构&&编码类型

Hash 类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当 field-value 长度较短且个数较少时,使用 ziplist,否则使用 hashtable。

与zset很类似,就是少了一个跳表做排序功能,因为Hash也用不到排序。

具体为什么ziplist本身不满足键值唯一性判定等条件还能拿来做Hash的底层数据结构的原因在下面zset处已经说明,往下面看就看到了。

常用命令

Redis hash是一个string类型的field和value的映射表,hash特别适合用于存储对象,常用命令:

  • HSET key field value
    将哈希表key中的字段field的值设为value
  • HGET key field
    获取存储在哈希表中指定字段的值
  • HDEL key field
    删除存储在哈希表中的指定字段
  • HKEYS key
    获取哈希表中所有字段
  • HVALS key
    获取哈希表中所有值
  • HGETALL key
    获取在哈希表中指定key的所有字段和值

应用场景

  1. 缓存对象
  2. 购物车:以用户 id 为 key,商品 id 为 field,商品数量为 value,恰好构成了购物车的3个要素

ZSet

数据结构&&编码类型

Zset 类型的底层数据结构是由压缩列表跳表(还有hash)实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

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

🌈补充1:

这里说的跳表其实不准确,其实跳表还结合了hash来一起进行操作。

因为跳表可以根据score进行排序地查找score(可范围查询),但是还要结合hash来进行快速地键值的唯一性判断和根据key(member)来找value(score)。但是hash之占了其中的一小部分,且在RedisObject数据结构中的type类型上也只能填一个,故选择了跳表。但是其实hash也是缺一不可的。

c
1
2
3
4
5
6
7
// zset结构
typedef struct zset {
// Dict指针
dict *dict;
// SkipList指针
zskiplist *zsl;
}

dict+skiplist

🌈补充2:ziplist没有上面那些特性,为什么还能拿来存zset数据?

ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现

  • ZipList是连续内存,因此score和element是紧挨在一起的两个entry,element在前,score在后
  • score越小越接近队首,score越大越接近队尾,按照score值升序排列

ziplist

常用命令

Redis sorted set有序集合是string类型元素的集合,且不允许重复的成员。每个元素都会关联一个double类型的分数(score)。redis正是通过分数来为集合中的成员进行从小到大排序。有序集合的成员是唯一的,但分数却可以重复。常用命令:

  • ZADD key score1 member1 [score2 member2]
    向有序集合添加一个或多个成员,或者更新已存在成员的分数
  • ZRANGE key start stop [WITHSCORES]
    通过索引区间返回有序集合中指定区间内的成员
  • ZINCRBY key increment member
    有序集合中对指定成员的分数加上增量increment
  • ZREM key member [member …]
    移除有序集合中的一个或多个成员

应用场景

  1. 排行榜:有序集合比较典型的使用场景就是排行榜

🌈黑马点评中的点赞排行榜:

采用zset类型的数据结构原因:

  1. 可排序
  2. 唯一
  3. 列表

但是SQL语句查询出来的结果并不是按照我们期望的方式进行排,即不按时间排序,因为使用了in来进行查询,最后根据id升序排,但其实应该按时间升序排序,所以可以采用order by field(id, ids[0], ids[1] ...)的方式进行排序

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public Result queryBlogLikes(Integer id) {
String key = "blog:like:" + id;
//zrange key 0 4 查询zset中前5个元素
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
//如果是空的(可能没人点赞),直接返回一个空集合
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
//将ids使用`,`拼接,SQL语句查询出来的结果并不是按照我们期望的方式进行排
//所以我们需要用order by field来指定排序方式,期望的排序方式就是按照查询出来的id进行排序
String idsStr = StrUtil.join(",", ids);
//select * from tb_user where id in (ids[0], ids[1] ...) order by field(id, ids[0], ids[1] ...)
List<UserDTO> userDTOS = userService.query().in("id", ids)
.last("order by field(id," + idsStr + ")")
.list().stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}

sql
1
select * from tb_user where id in (ids[0], ids[1] ...) order by field(id, ids[0], ids[1] ...)

BitMap

数据结构

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。

image-20231024110252548

应用场景

  1. 签到统计
  2. 判断用户登录态
  3. 连续签到用户总数

HyperLogLog

应用场景

UV统计

GEO

数据结构

GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。

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

这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。

应用场景

附近的人

stream

Redis Stream 是 Redis 5.0 版本新增加的数据类型,Redis 专门为消息队列设计的数据类型。

在 Redis 5.0 Stream 没出来之前,消息队列的实现方式都有着各自的缺陷,例如:

  • 发布订阅模式,不能持久化也就无法可靠的保存消息,并且对于离线重连的客户端不能读取历史消息的缺陷;
  • List 实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一 ID。

通用命令

  • KEYS pattern
    查找所有符合给定模式( pattern)的 key
  • EXISTS key
    检查给定key是否存在
  • TYPE key
    返回key所储存的值的类型
  • TTL key
    返回给定 key的剩余生存时间(TTL, time to live),以秒为单位
  • DEL key
    该命令用于在key存在是删除key

Redis底层数据结构

Redis 数据类型(也叫 Redis 对象)和底层数据结构的对应关系图

对应关系图

SDS

C字符串的缺点

Redis是用C语言编写的,但是不直接使用C语言的字符串,是因为其本身有几处缺点

C 语言的字符串不足之处以及可以改进的地方:

  • 获取字符串长度的时间复杂度为 O(N);
  • 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

SDS数据结构

SDS数据结构图:

SDS数据结构

结构中的每个成员变量分别介绍下:

  • len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

自动扩容

当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小

  • 如果所需的 sds 长度小于 1 MB,那么最后的扩容是按照翻倍扩容来执行的,即 2 倍的newlen
  • 如果所需的 sds 长度超过 1 MB,那么最后的扩容长度应该是 newlen + 1MB

在扩容 SDS 空间之前,SDS API 会优先检查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」。

这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数

节省内存空间

SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。

Redis 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。

这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同

比如 sdshdr16 和 sdshdr32 这两个类型,它们的定义分别如下:

c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};


struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};

可以看到:

  • sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2 的 16 次方。
  • sdshdr32 则都是 uint32_t,表示表示字符数组长度和分配空间大小不能超过 2 的 32 次方。

之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。

除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐

比如,sdshdr16 类型的 SDS,默认情况下,编译器会按照 2 字节对齐的方式给变量分配内存,这意味着,即使一个变量的大小不到 2 个字节,编译器也会给它分配 2 个字节。

举个例子,假设下面这个结构体,它有两个成员变量,类型分别是 char 和 int,如下所示:

c
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

struct test1 {
char a;
int b;
} test1;

int main() {
printf("%lu\n", sizeof(test1));
return 0;
}

大家猜猜这个结构体大小是多少?我先直接说答案,这个结构体大小计算出来是 8。

img

这是因为默认情况下,编译器是使用「字节对齐」的方式分配内存,虽然 char 类型只占一个字节,但是由于成员变量里有 int 类型,它占用了 4 个字节,所以在成员变量为 char 类型分配内存时,会分配 4 个字节,其中这多余的 3 个字节是为了字节对齐而分配的,相当于有 3 个字节被浪费掉了。

如果不想编译器使用字节对齐的方式进行分配内存,可以采用了 __attribute__ ((packed)) 属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。

比如,我用 __attribute__ ((packed)) 属性定义下面的结构体 ,同样包含 char 和 int 两个类型的成员变量,代码如下所示:

c
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

struct __attribute__((packed)) test2 {
char a;
int b;
} test2;

int main() {
printf("%lu\n", sizeof(test2));
return 0;
}

这时打印的结果是 5(1 个字节 char + 4 字节 int)。

img

可以看得出,这是按照实际占用字节数进行分配内存的,这样可以节省内存空间。

双向链表

双向链表

链表的优势与缺陷

Redis 的链表实现优点如下:

  • listNode 链表节点的结构里带有 prev 和 next 指针,获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表
  • list 结构因为提供了表头指针 head 和表尾节点 tail,所以**获取链表的表头节点和表尾节点的时间复杂度只需O(1)**;
  • list 结构因为提供了链表节点数量 len,所以**获取链表中的节点数量的时间复杂度只需O(1)**;
  • listNode 链表节使用 void* 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值

链表的缺陷也是有的:

  • 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
  • 还有一点,保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大

对比数组和链表

对于数组和链表来说,其内存访问模式对CPU缓存的利用有很大影响:

  1. 数组在内存中是一段连续的存储空间,这使得它具有很好的局部性原则。当CPU访问数组中的元素时,往往会预先加载相邻的数据到缓存中,以满足可能的后续访问需求。这种连续的存储模式有利于CPU缓存的预取和缓存行填充,从而提高访问速度。因为数组元素在内存中是连续排列的,一旦加载了数组的一个元素,接下来的元素也很可能已经在缓存中。
  2. 相比之下,链表的节点在内存中是分散存储的,每个节点通常存储在不同的内存位置。这会导致访问非常间断,使得缓存预取和填充效率降低。即使访问链表的一个节点,其后续节点并不一定存储在相邻的内存位置,这就增加了缓存未命中的概率,降低了CPU缓存的利用效率。

压缩列表

压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销

结构设计

压缩列表结构图

各个字段的含义:

  • zlbytes,记录整个压缩列表占用对内存字节数;

  • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;

  • zllen,记录压缩列表包含的节点数量;

  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

  • prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;

  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。

  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

🔴🟡🟢总结:

这里既不像数组那样有连续的相同的内存大小进行寻址,也不像链表那样有指针进行寻址,

但是压缩列表在正序时下一个元素就是该元素的首地址加上自身长度,自身长度包括【1. prelen代表前一个结点的长度,2. encoding记录类型,3. data真实数据】,由这三部分组成,这三个部分的长度都是可以经过计算得出的,故可以通过将地址加上自身长度得到下一个元素。

逆序时:可以通过本身地址减去prelen,得到前一个元素的地址

所以也可以说这个压缩列表是双向的

连锁更新

概率极低的事情

ZipList的每个Entry都包含previous entry length来记录上一个节点的大小,长度是1个或5个字节:

  • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
  • 如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据

现在,假设我们有N个连续的、长度为250~253字节之间的entry(条件),因此entry的previous entry length属性用1个字节即可表示,如图所示:

连锁更新

ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。

缺陷

  1. 连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能。

  2. 不能保存过多的元素,否则查询效率就会降低。

哈希表

结构

三个结构:

哈希表结构如下:

c
1
2
3
4
5
6
7
8
9
10
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;

哈希表节点的结构如下:

c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct dictEntry {
//键值对中的键
void *key;

//键值对中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;

字典的结构如下:

c
1
2
3
4
5
6
7
typedef struct dict{
dictType *type; // dict类型,内置不同的hash函数
void *privdata; // 私有数据,在做特殊hash运算时用
dicht ht[2]; // 一个Dict包含两个哈希表,其中一个时当前数据,另一个一般是空,rehash时使用
long rehashidx; // rehash的进度,-1表示未进行
int16_t pauserehash; // rehash是否暂停,1则暂停,0则继续
} dict;

三种结构组成关系图:

三种结构组成关系图

Dict的结构

  • 类似java的HashTable,底层是数组加单向链表来解决哈希冲突
  • Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash

跟Java中的HashMap原理很像,但又有一点点差别

哈希冲突

当出现哈希冲突的时候采用的是头插法,原因是方便,不用遍历到链表的末尾进行插入。

Redis是单线程,所以不会出现采用头插法时造成循环链表的可能。

补充:当高并发时,多个线程并发地尝试在链表头部插入元素,在发生扩容且是头插法的情况下会导致循环链表,具体导致循环原因可参考本站:https://planbbbbb.github.io/2023/09/05/Java%E9%9B%86%E5%90%88/

rehash

Dict的rehash并不是一次性完成的。试想一下,如果Dict中包含数百万的entry,要在一次rehash完成,极有可能导致主线程阳寒。因此Dict的rehash是分多次、渐进式的完成,因此称为渐进式rehash。

流程如下:

  1. 计算新hash表的size,值取决于当前要做的是扩容还是收缩:

    • 如果是扩容,则新size为第一个大于等于dict.ht[0].used +1的2的n次方,即当前元素个数+1后的第一个2^n的数
    • 如果是收缩,则新size为第一个大于等于dict.htlol.used的2^n (不得小于4),与上面同理,找到比它大的第一个2的n次方的数
  2. 按照新的size申请内存空间,创建dictht,并赋值给dict.ht[1]

  3. 设置dict.rehashidx=0,标示开始rehash(未进行rehash时值为-1

  4. 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1](不能一次性全部转移,这样在数据量很大的时候可能会导致主进程的阻塞)

  5. 每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]

  6. 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存

  7. 将rehashidx赋值为-1,代表rehash结束

  8. 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空

🌈负载因子 = 哈希表已保存节点数量 /哈希表大小

触发 rehash 操作的条件,主要有两个:

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

跳表

结构

  1. 实现原理

跳跃表实质上是对一个有序的链表进行类似于二分查找的数据结构,其性能与红黑树,AVL树不相上下。

原本要依次遍历从1开始找17,现在从三级索引找到10,再从二级索引找到15,在一级索引就能找到17了。

跳跃表

  1. 具体结构

跳表节点结构:

c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;

//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;

跳表结构:

c
1
2
3
4
5
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;

配合上结构体的跳表结构图:

配合上结构体的跳表结构图

为什么用跳表而不是平衡树

  • 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
  • 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
  • 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。

整数集合

整数集合是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。

整数集合结构设计

整数集合本质上是一块连续内存空间,它的结构定义如下:

c
1
2
3
4
5
6
7
8
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;

contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值,对应的有16,32,64。

其实就是对应着Java里的short,int,long类型。

升级机制

目的是节省内存空间

当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,升级要先将原有的内存空间扩容到刚好能放的下加上新元素后,且所有元素都是新的数据类型的内存大小,再将原有的元素倒叙排入对应的位置,最后在将新的元素放在头或者尾

🌈解释:

其实就是原本的元素类型不够大,存不下新的数,就比如short类型的数据类型存不下新进来的50000,所以要进行元素类型上的升级。

这里倒叙的原因是防止覆盖原有的元素,倒叙不会覆盖到原有的元素。

新元素放在头或者尾的原因是只有当这个元素比所有元素都大或者比所有元素都小才有可能引发升级,才会使得原有的数据类型放不了该元素。

扩容图:

扩容

倒叙插入流程图:

倒叙插入流程

整数集合不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。比如前面的升级操作的例子,如果删除了 65535 元素,整数集合的数组还是 int32_t 类型的,并不会因此降级为 int16_t 类型。

🍕整数集合升级的好处

当元素全都是int32_t时,那么数组就会采用int32_t的元素类型,不会浪费空间,只有当有元素超过int32_t时才会去升级为int64_t,目的就是节省内存。

个人思考:说到节省空间,这一点整数集合其实做的没有SDS或者压缩列表做的好,SDS废除了字节对齐,让不同类型长度的元素都能放在一起,但是整数集合其实不能这么做,因为他底层是个数组,他是连续的内存空间,要靠第一个值的地址,通过固定的运算去快速得到后面的值。

查询方式

底层采用二分查找进行查询,所以当数据量很大的时候其实也不建议使用整数集合来进行存储了。

quicklist

在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。

其实 quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。

quicklist 解决办法,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。

结构

首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也即是压缩列表。它将所有的元素紧挨着一起存储,分配的是一块连续的内存。
当数据量比较多的时候才会改成 quicklist。因为普通的链表需要的附加指针空间太大,会比较浪费空间。比如这个列表里存的只是 int 类型的数据,结构上还需要两个额外的指针 prev 和 next。

结构图

Redis 将链表和 ziplist 结合起来组成了 quicklist。也就是将多个 ziplist 使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。

quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。

listpack

本质上还是压缩列表,只是在版本更迭的时候进行了优化

最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

listpack 结构设计

listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。

我们先看看 listpack 结构:

img

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了。

每个 listpack 节点结构如下:

img

主要包含三个方面内容:

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

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题

修改后不会影响遍历吗

在listpack中将原来的prelen替换为了len,原有的prelen是为了能从后向前去遍历元素的,现在改为了len,记录的是当前结点的长度。

解决办法是:

lpDecodeBacklen 函数就可以从当前列表项起始位置的指针开始,向左逐个字节解析,得到前一项的 entry-len 值。

这样依然能进行从后往前遍历。

RedisObject

  1. 每一个Redis对象最终都会封装为一个RedisObject对象,结构图如下:

image-20231105105905430

字段解释:

  • type,标识该对象是什么类型的对象(String 对象、 List 对象、Hash 对象、Set 对象和 Zset 对象);
  • encoding,标识该对象使用了哪种底层的数据结构;
  • lru,用于判断空闲时间太久的key,做内存淘汰处理
  • refcount,类似于jvm中的引用计数法,无人引用时可被回收
  • ptr,指向底层数据结构的指针。
  1. encoding的11种类型:

image-20231105105930395

  1. 五种基本数据类型的底层编码方式:

image-20231105105954746

补充:类似于bitmap,hyperloglog底层其实都是String类型,GEO底层其实就是ZSet类型

Redis应用

缓存穿透

定义

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

image-20230803213519682

常见解决方案

  1. 缓存空对象
  • 优点:方便简单

  • 缺点:可能造成额外的内存消耗,可能造成短期的不一致

若有用户恶意使用多个不同id进行查询,则redis会不断缓存很多没有用的null值造成浪费,但可以通过为key添加过期时间解决。

短期不一致是因为数据库已经更新数据而redis中仍为null值,其实可以通过插入数据的同时手动更改redis的值来解决。

image-20230803213834986

  1. 布隆过滤器过滤

在查询redis前先经过布隆过滤器,若redis中存在才放行。

布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性

  • 优点:内存占用少,没有多余的key
  • 缺点:可能误判,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。

image-20230803214619650

缓存雪崩

定义

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

image-20230804102636911

常见解决方案

  1. 给不同的Key的TTL添加随机值

  2. 后台更新缓存

将更新缓存的工作交由后台线程定时更新

解决方式有两种:

第一种方式,后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。

这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。

第二种方式,在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。

在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。

  1. 利用Redis集群提高服务的可用性

通过主从节点的方式构建 Redis 缓存高可靠集群

如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。

  1. 服务熔断或请求限流机制

因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作

为了减少对业务的影响,我们可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。

  1. 给业务添加多级缓存

同一时段大量的缓存key同时失效采用方案1和2和互斥锁方案。

redis宕机采用方案3,4,5,redis集群中主库挂了还要从库数据,用sentinel可以降级限流,多级缓存可用nginx,jvm等进行缓存。

缓存击穿

定义

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

常见解决方案

  1. 互斥锁

1000个进程来同时访问,一个进程拿到锁,其余999个进程都要等到线程1查询数据库重新写入缓存,释放锁之后才能访问,性能差,但是有强一致性

image-20230804105357783

  1. 逻辑过期

就是在redis保存数据的时候多保存一个过期时间字段,通常是在活动开始到结束那一个时间段内都不会过期,也就不会突然失效了。若失效(这里说的失效指的是逻辑时间过期了)了也是要通过锁的形式去重新查数据库,存缓存,释放锁。

不过相对于互斥锁而言,这里的锁不一样了。这里线程1获得锁之后交给另外的线程去执行查数据库等操作,而线程1自身则返回旧的数据。其他进程在释放锁之前来访问都是返回的旧数据,只有当释放锁,即完成了查数据库,存入缓存,更新逻辑过期时间后才能返回新数据。

image-20230804110145305

两种方案对比

解决方案 优点 缺点
互斥锁 没有额外的内存消耗,保证一致性,实现简单 线程需要等待,性能受影响,可能有死锁
逻辑过期 线程无需等待,性能较好 不保证一致性,有额外内存消耗,实现复杂

缓存与数据库保持一致问题

先更新数据库,还是先更新缓存?

  1. 先更新数据库再更新缓存

会出现并发问题

先更新数据库再更新缓存

  1. 先更新缓存再更新数据库

也会出现并发问题

先更新缓存再更新数据库

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

增加一些手段来解决这个问题,这里提供两种做法:

  • 在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
  • 在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。

先更新数据库,还是先删除缓存?

Cache Aside 策略:在更新数据时,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。

Cache Aside 策略

写策略的步骤:

  • 更新数据库中的数据;
  • 删除缓存中的数据。

读策略的步骤:

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

假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。

先删除缓存再更新数据库

最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。

可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题

针对「先删除缓存,再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删」。(对于上面那张图而言,延迟删除的时机就在请求B结束为止即可进行延迟删除

延迟双删实现的伪代码如下:

java
1
2
3
4
5
6
7
8
#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。

所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。

但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。

因此,还是比较建议用「先更新数据库,再删除缓存」的方案。

  1. 先更新数据库,再删除缓存

假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。

先更新数据库,再删除缓存

理论上来讲这样先更新数据库再删除缓存也是会导致数据库缓存的不一致的,但是实际上发生这样的情况的几率很小,因为将写缓存的操作是要远远快于更新数据库的。

这个方案的基础上再加上对key做过期处理,就是一个可行的方案了,即使有一段时间的数据不一致,也会有过期策略来保底。


但是上面的情况的前提是更新数据库删除缓存这两件事是同时发生且都成功的情况下的,要做到同时发生,可以使用lua脚本进行原子性的操作,要做到都成功有以下两种方法:

  • 重试机制。
  • 订阅 MySQL binlog,再操作缓存。
  1. 重试机制

我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

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

举个例子,来说明重试机制的过程。

消息队列重试机制

  1. 订阅 MySQL binlog,再操作缓存

先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

下图是 Canal 的工作原理:

图片

所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存

为什么是删除缓存,而不是更新缓存呢?

删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小。

比如商品详情信息,在底层可能会关联商品表、价格表、库存表等,如果更新了一个价格字段,那么就要更新整个数据库,还要关联的去查询和汇总各个周边业务系统的数据,这个操作会非常耗时。 从另外一个角度,不是所有的缓存数据都是频繁访问的,更新后的缓存可能会长时间不被访问,所以说,从计算资源和整体性能的考虑,更新的时候删除缓存,等到下次查询命中再填充缓存,是一个更好的方案。

🔴🟡🟢总结

先更新数据库,再删除缓存,搭配上消息队列和过期策略就是最好的方案。

数据要求一致性不强的时候就延迟双删,要求强一致就读写锁,性能差。

分布式锁

核心思想

分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路。

image-20230819135201384

实现代码

  1. 分布式锁
java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class SimpleRedisLock implements ILock {
//锁的前缀
private static final String KEY_PREFIX = "lock:";
//利用UUID增加复杂度
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
//具体业务名称,将前缀和业务名拼接之后当做Key
private String name;
//这里不需要@Autowired,因为该对象是我们使用构造函数手动new出来的
private StringRedisTemplate stringRedisTemplate;

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
UNLOCK_SCRIPT = new DefaultRedisScript();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}

public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}

@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁,使用SETNX方法进行加锁,同时设置过期时间,防止死锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
//自动拆箱可能会出现null,这样写更稳妥
return Boolean.TRUE.equals(success);
}

@Override
public void unlock() {
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
}
  1. lua脚本
lua
1
2
3
4
5
6
7
8
-- 这里的KEYS[1]就是传入锁的key
-- 这里的ARGV[1]就是线程标识
-- 比较锁中的线程标识与线程标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致则释放锁
return redis.call('del', KEYS[1])
end
return 0
  1. 使用锁
java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁对象
boolean isLock = lock.tryLock(1200);
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}

Redisson

上面实现的分布式锁存在不可重入,不可重试的问题,将使用redisson提供的框架来实现

解释:

上面实现的分布式锁是采用key-value形式的锁,在判断key相同之后则会直接认为这把锁已经被占用了,所以获取锁失败,但是redisson使用的是hash类型的数据结构,在看到锁已经存在后,不是直接认定获取锁失败,而是判断锁的标识是否是自己,如果是则value加一,代表重入次数加一,最后要删除锁也是要等到value为0,即程序运行到最外层的时候才能释放。

Redisson可重入锁原理

image-20230819160739146

重点:

使用lua脚本,等待重试,看门狗机制(用来续期锁)

关注推送

使用推模式

image-20230821152151166

滚动分页查询

zset

事务

定义

Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

Redis事务的主要作用就是串联多个命令防止别的命令插队。

Multi、Exec、discard

从输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入Exec后,Redis会将之前的命令队列中的命令依次执行。

组队的过程中可以通过discard来放弃组队。

image-20231024153825141

事务的错误处理

  1. 组队中某个命令出现了报告错误,执行时整个的所有队列都会被取消。

image-20231024154026128

  1. 如果执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚。

image-20231024154101032

watch

使用WATCH命令是一种手动实现乐观锁的方式,可以在多个事务之间防止竞态条件和数据不一致。如果不去使用WATCH,Redis默认不会自动阻止多个事务之间的冲突。

Redis事务三特性

  1. 单独的隔离操作

    • 事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  2. 没有隔离级别的概念

    • 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行,因为Redis的事务是单机的,没有并发读写的问题
  3. 不保证原子性

    • 事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

持久化篇

AOF

AOF日志

Redis 每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里,然后重启 Redis 的时候,先去读取这个文件里的命令,并且执行它,这就相当于恢复了缓存数据。

注意只会记录写操作命令,读操作命令是不会被记录的,因为没意义

image-20231030203646649

Redis 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。

  1. 避免额外的检查开销

因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。

而如果先执行写操作命令再记录日志的话,只有在该命令执行成功后,才将命令记录到 AOF 日志里,这样就不用额外的检查开销,保证记录在 AOF 日志里的命令都是可执行并且正确的。

  1. 不会阻塞当前写操作命令的执行

因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。

AOF的两种潜在风险

  1. 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险

  2. 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前写操作命令的执行,但是可能会给「下一个」命令带来阻塞风险

因为将命令写入到日志的这个操作也是在主进程完成的(执行命令也是在主进程),也就是说这两个操作是同步的。

image-20231030205624444

认真分析一下,其实这两个风险都有一个共性,都跟「 AOF 日志写回硬盘的时机」有关。

三种写回策略

Redis 写入 AOF 日志的过程,如下图:

image-20231030211449584

具体流程:

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

Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。

  • Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
  • Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘
  • No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

这 3 种写回策略都无法能完美解决「主进程阻塞」和「减少数据丢失」的问题,因为两个问题是对立的,偏向于一边的话,就会要牺牲另外一边,原因如下:

  • Always 策略的话,可以最大程度保证数据不丢失,但是由于它每执行一条写操作命令就同步将 AOF 内容写回硬盘,所以是不可避免会影响主进程的性能;
  • No 策略的话,是交由操作系统来决定何时将 AOF 日志内容写回硬盘,相比于 Always 策略性能较好,但是操作系统写回硬盘的时机是不可预知的,如果 AOF 日志内容没有写回硬盘,一旦服务器宕机,就会丢失不定数量的数据。
  • Everysec 策略的话,是折中的一种方式,避免了 Always 策略的性能开销,也比 No 策略更能避免数据丢失,当然如果上一秒的写操作命令日志没有写回到硬盘,发生了宕机,这一秒内的数据自然也会丢失。

大家根据自己的业务场景进行选择:

  • 如果要高性能,就选择 No 策略;
  • 如果要高可靠,就选择 Always 策略;
  • 如果允许数据丢失一点,但又想性能高,就选择 Everysec 策略。

image-20231030211756839

深入到源码后,就会发现这三种策略只是在控制 fsync() 函数的调用时机。

当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。

image-20231030211900137

如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync() 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。

  • Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
  • Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
  • No 策略就是永不执行 fsync() 函数;

AOF重写机制

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

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

重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。最后在重写工作完成后,将新的 AOF 文件覆盖现有的 AOF 文件。

为什么重写 AOF 的时候,不直接复用现有的 AOF 文件?

因为如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染,可能无法用于恢复使用。

所以 AOF 重写过程,先重写到新的 AOF 文件,重写失败的话,就直接删除这个文件就好,不会对现有的 AOF 文件造成影响。

⭐⭐⭐AOF后台重写

AOF重写过程其实是很耗时的,所以重写的操作不能放在主进程里。

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

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

子进程是怎么拥有主进程一样的数据副本的呢?

Redis主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个

img

这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读

不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发写保护中断,这个写保护中断是由于违反权限导致的,然后操作系统会在「写保护中断处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「**写时复制(Copy On Write)**」。

img

写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。

AOF后台重写阻塞情况:

  1. 写时复制
  2. 信号处理函数
  3. 复制父进程的页表:创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长,不过页表的大小相比实际的物理内存小很多,所以通常复制页表的过程是比较快的。

🔴🟡🟢重写期间,如果主进程修改了数据

当在 Redis 中启用了写时复制技术,特别是在 AOF(Append Only File)重写期间,如果主进程修改了数据,整体流程如下:

  1. 主进程执行写操作

    • 主进程收到客户端的写命令请求。
    • 主进程执行命令并将命令追加到 AOF 缓冲区和 AOF 重写缓冲区。
  2. 执行 AOF 后台重写

    • 在重写 AOF 期间,后台子进程(bgrewriteaof)负责扫描数据库并将数据库中的键值对转换为一条命令,然后将命令记录到新的 AOF 文件中。
  3. 主进程修改已存在的数据

    • 如果主进程在 AOF 重写期间修改了已存在的数据,根据写时复制机制,只会复制发生了修改的物理内存数据,未修改的部分仍然与子进程共享
    • 修改的数据在主进程的物理内存中,但在子进程的内存数据中是不一致的。
  4. AOF 重写子进程与主进程数据不一致问题

    • 为了解决数据不一致的问题,Redis 设置了 AOF 重写缓冲区,在创建 bgrewriteaof 子进程之后开始使用。
    • 主进程执行的写命令会被同时追加到 AOF 缓冲区和 AOF 重写缓冲区。
  5. 子进程完成 AOF 重写

    • 子进程完成 AOF 重写后,向主进程发送信号。
    • 主进程接收信号后,调用信号处理函数,将 AOF 重写缓冲区的内容追加到新 AOF 文件中,使得新旧 AOF 文件保存的数据库状态一致。

在这整个流程中,写时复制技术确保了只有在主进程发生修改时才会对物理内存数据进行复制,减少不必要的复制,同时 AOF 重写缓冲区帮助解决了主进程与子进程数据不一致的问题,保障了 AOF 文件的一致性。

RDB

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。

因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。

快照怎么用?

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

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

Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。

所以可以认为,执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。

通常可能设置至少 5 分钟才保存一次快照,这时如果 Redis 出现宕机等情况,则意味着最多可能丢失 5 分钟数据。

这就是 RDB 快照的缺点,在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多,因为 RDB 快照是全量快照的方式,因此执行的频率不能太频繁,否则会影响 Redis 性能,而 AOF 日志可以以秒级的方式记录操作命令,所以丢失的数据就相对更少。

⭐⭐⭐执行快照时,数据能被修改吗?

执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的。

技术依然是使用的写时复制技术,但是并不会像AOF那样还建立一个重写缓冲区,最后再通过信号函数重新追加命令到新的AOF文件中。

因为通过写时复制技术后,主进程和子进程所对应的那一块数据的内存区域就不一样了,所以子进程所记录的RDB文件中是不包含记录过程中新加入或修改的新数据的

RDB 和 AOF 合体

尽管 RDB 比 AOF 的数据恢复速度快,但是快照的频率不好把握:

  • 如果频率太低,两次快照间一旦服务器发生宕机,就可能会比较多的数据丢失;
  • 如果频率太高,频繁写入磁盘和创建子进程会带来额外的性能开销。

那有没有什么方法不仅有 RDB 恢复速度快的优点和,又有 AOF 丢失数据少的优点呢?

当然有,那就是将 RDB 和 AOF 合体使用,这个方法是在 Redis 4.0 提出的,该方法叫混合使用 AOF 日志和内存快照,也叫混合持久化。

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

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

image-20231030213846575

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失

Redis 大 Key 对持久化有什么影响?

大 Key 对 AOF 日志的影响

当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。

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

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

大 Key 对 AOF 重写和 RDB 的影响

  1. 复制页表期间:

随着 Redis 存在越来越多的大 Key,那么 Redis 就会占用很多内存,对应的页表就会越大。

Redis主进程在通过 fork() 函数创建子进程的时候,虽然不会复制父进程的物理内存,但是内核会把父进程的页表复制一份给子进程,如果页表很大,那么这个复制过程是会很耗时的,那么在执行 fork 函数的时候就会发生阻塞现象

  1. 写时复制期间:

如果创建完子进程后,父进程对共享内存中的大 Key 进行了修改,那么内核就会发生写时复制,会把物理内存复制一份,由于大 Key 占用的物理内存是比较大的,那么在复制物理内存这一过程中,也是比较耗时的,于是父进程(主线程)就会发生阻塞

高可用篇✏️

主从复制

主服务器写,从服务器读,在从机上写数据会报错

主从复制图

复制原理

  1. 当从服务器连接上主服务器之后,从服务器向主服务发送进行数据同步消息
  2. 主服务器接到从服务器发送过来同步消息,把主服务器数据进行持久化rdb文件,把rdb文件发送从服务器,从服务器拿到rdb文件进行读取

🌈补充:

主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里

  • 主服务器生成 RDB 文件期间;
  • 主服务器发送 RDB 文件给从服务器期间;
  • 「从服务器」加载 RDB 文件期间;

当完成 RDB 的载入后,主服务器将 replication buffer 缓冲区里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器 replication buffer 缓冲区里发来的命令,这时主从服务器的数据就一致了。

至此,主从服务器的第一次同步的工作就完成了。

  1. 每次主服务器进行写操作之后,和从服务器进行数据同步,基于长连接的命令传播

🌈补充:

主从服务器完成第一次数据同步后,TCP网络连接会一直维持着,后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。

  1. 当发生网络波动问题导致基于长连接的命令传播断开时:

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

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

主服务器怎么知道要将哪些增量数据发送给从服务器呢?

答案藏在这两个东西里:

  • repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
  • replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「」到的位置,从服务器使用 slave_repl_offset 来记录自己「」到的位置。

那 repl_backlog_buffer 缓冲区是什么时候写入的呢?

在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 缓冲区里,因此 这个缓冲区里会保存着最近传播的写命令。

网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作:

  • 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式;
  • 相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式。

当主服务器在 repl_backlog_buffer 中找到主从服务器差异(增量)的数据后,就会将增量的数据写入到 replication buffer 缓冲区,这个缓冲区我们前面也提到过,它是缓存将要传播给从服务器的命令。

图片

repl_backlog_buffer 缓行缓冲区的默认大小是 1M,并且由于它是一个环形缓冲区,所以当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。因此,当主服务器的写入速度远超于从服务器的读取速度,缓冲区的数据一下就会被覆盖。

那么在网络恢复时,如果从服务器想读的数据已经被覆盖了,主服务器就会采用全量同步,这个方式比增量同步的性能损耗要大很多。

因此,为了避免在网络恢复时,主服务器频繁地使用全量同步的方式,我们应该调整下 repl_backlog_buffer 缓冲区大小,尽可能的大一些

复制延迟

由于所有的写操作都是先在Master上操作,然后同步更新到Slave上,所以从Master同步到Slave机器有一定的延迟,当系统很繁忙的时候,延迟问题会更加严重,Slave机器数量的增加也会使这个问题更加严重。

一主二仆

当一台从服务器挂了之后,此时在这个期间主服务器写入数据,此时再将该挂掉了的从服务器重新启动,此时这台服务器已经不再是从服务器了,即与原来的主服务器失去了主从关系,它现在变成了一个独立的主服务器,再让该服务器去与原来的主服务器去建立联系,那么就会将主服务器的当前的所有内容(即包括在其挂掉的那期间的数据)全部复制到从服务器上。

当主服务器挂掉之后,剩下的从服务器不会发生改变,依然认为原来的主服务器是主服务器,并不会自己成为新的主服务器,等到原来的主服务器重启后,一切恢复正常。

级联复制

其实就是像个链表一样,将数据同步的方式由一台主服务器给多台服务器传达,转变为A->B->C这样的模式。

  • 优点:通过这种方式,主服务器生成 RDB 和传输 RDB 的压力可以分摊到充当经理角色的从服务器

  • 缺点:B作为C的主服务器,那么B服务器就可以进行写操作了,但B服务器却是A服务器的从服务器,B服务器如果写了数据那么A服务器是无法进行同步的,这就出现了数据的不同步问题。

  • 一般不会出现B服务器主动写的操作,B服务器的写操作只是继承自A服务器的数据而去写到C服务器。

薪火相传

小林coding面试题

  1. Redis主从节点时长连接还是短连接?

长连接

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

Redis 判断节点是否正常工作,基本都是通过互相的 ping-pong 心态检测机制,如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应,集群就会认为这个节点挂掉了,会断开与这个节点的连接。

  1. 主从复制架构中,过期key如何处理?

主节点处理了一个key或者通过淘汰算法淘汰了一个key,这个时间主节点模拟一条del命令发送给从节点,从节点收到该命令后,就进行删除key的操作。

  1. Redis 是同步复制还是异步复制?

Redis 主节点每次收到写命令之后,先写到内部的缓冲区,然后异步发送给从节点。

  1. 主从复制中两个 Buffer(replication buffer 、repl backlog buffer)有什么区别?

replication buffer 、repl backlog buffer 区别如下:

  • 出现的阶段不一样:
  • repl backlog buffer 是在增量复制阶段出现,一个主节点只分配一个 repl backlog buffer
  • replication buffer 是在全量复制阶段和增量复制阶段都会出现,主节点会给每个新连接的从节点,分配一个 replication buffer
  • 这两个 Buffer 都有大小限制的,当缓冲区满了之后,发生的事情不一样:
  • 当 repl backlog buffer 满了,因为是环形结构,会直接覆盖起始位置数据;
  • 当 replication buffer 满了,会导致连接断开,删除缓存,从节点重新连接,重新开始全量复制
  1. 如何应对主从数据不一致?

主从节点间的命令复制是异步进行的,所以无法实现强一致性保证(主从数据时时刻刻保持一致)。

第一种方法,尽量保证主从节点间的网络连接状况良好,避免主从节点在不同的机房。

第二种方法,可以开发一个外部程序来监控主从节点间的复制进度。如果监控到某个从节点总是很大程度上的,很长时间段的不一致,可以让客户端不再和这个从节点连接进行数据读取,这样就可以减少读到不一致数据的情况。

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

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

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

我们不可能保证数据完全不丢失,只能做到使得尽量少的数据丢失。

待补充。。。。。

  1. 主从如何做到故障自动切换?

Redis 哨兵机制可以实现:哨兵在发现主节点出现故障时,由哨兵自动完成故障发现和故障转移,并通知给应用方,从而实现高可用性。

哨兵模式

🔴🟡🟢一句话总结:

能够自动后台监控主机是否故障,如果故障了根据投票数自动将从库转换为主库。当从服务器切换为新的主服务器后,原来的主服务器就变成了其的从服务器。

哨兵模式

哨兵节点主要负责三件事情:监控、选主、通知

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

哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。

如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「主观下线」。

客观下线只适用于主节点。

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

所以,为了减少误判的情况,哨兵在部署的时候不会只部署一个节点,而是用多个节点部署成哨兵集群最少需要三台机器来部署哨兵集群),通过多个哨兵节点一起判断,就可以就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

具体是怎么判定主节点为「客观下线」的呢?

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

img

当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。

例如,现在有 3 个哨兵,quorum 配置的是 2,那么一个哨兵需要 2 张赞成票,就可以标记主节点为“客观下线”了。这 2 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

PS:quorum 的值一般设置为哨兵个数的二分之一加 1,例如 3 个哨兵就设置 2。

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

🔴🟡🟢总结

其实就是通过哨兵集群,超过半数的哨兵节点认为主服务器故障,那么就认为其真的故障了。

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

哨兵是以哨兵集群的方式存在的,需要在哨兵集群中选出一个 leader,让 leader 来执行主从切换。

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

那么在投票过程中,任何一个「候选者」,要满足两个条件

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

哨兵节点至少要有 3 个,而且是奇数,且quorum 的值建议设置为哨兵个数的二分之一加 1。

主从故障转移的过程是怎样的?

  1. 选出新主节点

执行流程图

  • 优先级在redis.conf中默认:slave-priority 100,值越小优先级越高
  • 偏移量是指获得原主机数据最全的
  • 每个redis实例启动后都会随机生成一个40位的runid
  1. 从节点指向新主节点

当新主节点出现之后,哨兵 leader 下一步要做的就是,让已下线主节点属下的所有「从节点」指向「新主节点」

  1. 通知客户的主节点已更换

通过 Redis 的发布者/订阅者机制来实现

  1. 将旧主节点变为从节点

故障转移操作最后要做的是,继续监视旧主节点,当旧主节点重新上线时,哨兵集群就会向它发送 SLAVEOF 命令,让它成为新主节点的从节点。

哨兵集群是如何组成的?

在主从集群中,主节点上有一个名为__sentinel__:hello的频道,不同哨兵就是通过它来相互发现,实现互相通信的。

在下图中,哨兵 A 把自己的 IP 地址和端口的信息发布到__sentinel__:hello 频道上,哨兵 B 和 C 订阅了该频道。那么此时,哨兵 B 和 C 就可以从这个频道直接获取哨兵 A 的 IP 地址和端口号。然后,哨兵 B、C 可以和哨兵 A 建立网络连接。

img

通过这个方式,哨兵 B 和 C 也可以建立网络连接,这样一来,哨兵集群就形成了。

哨兵集群会对「从节点」的运行状态进行监控,那哨兵集群如何知道「从节点」的信息?

主节点知道所有「从节点」的信息,所以哨兵会每 10 秒一次的频率向主节点发送 INFO 命令来获取所有「从节点」的信息。

如下图所示,哨兵 B 给主节点发送 INFO 命令,主节点接受到这个命令后,就会把从节点列表返回给哨兵。接着,哨兵就可以根据从节点列表中的连接信息,和每个从节点建立连接,并在这个连接上持续地对从节点进行监控。哨兵 A 和 C 可以通过相同的方法和从节点建立连接。

img

正是通过 Redis 的发布者/订阅者机制,哨兵之间可以相互感知,然后组成集群,同时,哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。

集群

参考文章:https://juejin.cn/post/7358670107901886501

集群脑裂导致数据丢失怎么办?

什么是脑裂?

先来理解集群的脑裂现象,这就好比一个人有两个大脑,那么到底受谁控制呢?

那么在 Redis 中,集群脑裂产生数据丢失的现象是怎样的呢?

在 Redis 主从架构中,部署方式一般是「一主多从」,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。

这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在「从节点」中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— 脑裂出现了

然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题

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

**解决方案:**其实都是限制主库不再接收客户端的写请求

功能篇

过期删除策略

如何判定 key 已过期了?

🔴🟢🟡一句话总结:设置过期时间会将该key带上过期时间存入过期字典(哈希表),在里面根据key找不到就是没设置过期时间,找到了就与系统当前时间进行比较。

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

过期字典存储在 redisDb 结构中,如下:

c
1
2
3
4
5
typedef struct redisDb {
dict *dict; /* 数据库键空间,存放着所有的键值对 */
dict *expires; /* 键的过期时间 */
....
} redisDb;

过期字典数据结构结构如下:

  • 过期字典的 key 是一个指针,指向某个键对象;
  • 过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间;

image-20231107084229499


字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:

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

流程图

过期删除策略有哪些?

  1. 定时删除

定时删除策略的做法是,在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。

定时删除策略的优点

  • 可以保证过期 key 会被尽快删除,也就是内存可以被尽快地释放。因此,定时删除对内存是最友好的。

定时删除策略的缺点

  • 在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。
  1. 惰性删除

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

  • 惰性删除策略的优点

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

  • 惰性删除策略的缺点

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

  1. 定期删除

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

特别强调下,每次检查数据库并不是遍历过期字典中的所有 key,而是从数据库中随机抽取一定数量的 key 进行过期检查。

定期删除策略的优点

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

定期删除策略的缺点

  • 内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。
  • 难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。

🌈补充:

Redis会设置一个定时任务serverCron(),按照server.hz的频率来执行过期key清理,模式为SLOW

Redis的每个事件循环前会调用beforesleep()函数,执行过期key清理,模式为FAST

SLow模式规则:

  1. 执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
  2. 执行清理耗时不超过一次执行周期的25%
  3. 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
  4. 如果没达到时间上限 (25ms)并且过期key比例大于10%,再进行一次抽样,否则结束

FAST模式规则(过期key比例小于10%不执行):

  1. 执行频率受beforesleep()调用频率影响,但两次FAST模式间隔不低于2ms
  2. 执行清理耗时不超过1ms
  3. 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
  4. 如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束

Redis 过期删除策略是什么?

Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

内存淘汰策略

当 Redis 的运行内存已经超过 Redis 设置的最大内存之后,则会使用内存淘汰策略删除符合条件的 key

Redis 内存淘汰策略有哪些?

Redis支持8种不同策略来选择要删除的key:

  1. noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。

  2. volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰

第2点补充:当运行内存超过最大设置内存时,不淘汰任何数据,但新增操作会报错。

  1. allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选

  2. volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。

  3. allkeys-lru: 对全体key,基于LRU算法进行淘汰

  4. volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰

  5. allkeys-lfu: 对全体key,基于LFU算法进行淘汰

  6. volatile-lfu: 对设置了TTL的key,基于LFU算法进行淘汰

淘汰策略流程:

淘汰策略流程

LRU 算法和 LFU 算法有什么区别?

🔴🟢🟡一句话总结:

LRU:最后一次访问时间隔得越久,这个淘汰优先级越高

LFU:最少频率使用,频率越低,淘汰优先级越高


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),低 8bit 存储 logc(Logistic Counter)。

img

  • ldt 是用来记录 key 的访问时间戳;
  • logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的logc 初始值为 5。

注意,logc 并不是单纯的访问次数,而是访问频次(访问频率),因为 logc 会随时间推移而衰减的

在每次 key 被访问时,会先对 logc 做一个衰减操作,衰减的值跟前后访问时间的差距有关系,如果上一次访问的时间与这一次访问的时间差距很大,那么衰减的值就越大,这样实现的 LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑 key 的访问是多长时间段内发生的。key 的先前访问距离当前时间越长,那么这个 key 的访问频率相应地也就会降低,这样被淘汰的概率也会更大。

对 logc 做完衰减操作后,就开始对 logc 进行增加操作,增加操作并不是单纯的 + 1,而是根据概率增加,如果 logc 越大的 key,它的 logc 就越难再增加。

所以,Redis 在访问 key 时,对于 logc 是这样变化的:

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

补充

redis读取事件采用的方式? 什么是阻塞读,非阻塞读,异步读?

在 Redis 中,读取事件采用的方式主要是阻塞读。下面简要解释阻塞读、非阻塞读和异步读:

阻塞读(Blocking Read)

阻塞读是指当没有数据可读时,读取操作会阻塞当前线程或进程,直到有数据可读或者超时为止。在 Redis 中,当客户端发起一个读取操作(比如 GET),如果没有数据可读,Redis 会阻塞该客户端连接,直到有数据可读或者超时。阻塞读使得客户端可以轻松地等待数据的到达,不需要频繁地进行轮询。

非阻塞读(Non-blocking Read)

非阻塞读是指读取操作不会阻塞当前线程或进程,即使没有数据可读。在 Redis 中,使用非阻塞读的方式,客户端可以通过设置非阻塞选项,在没有数据可读时立即返回,不会等待数据的到达。客户端可以周期性地轮询数据是否可读

异步读(Asynchronous Read)

异步读是指读取操作会立即返回,而不会阻塞当前线程或进程,但是需要提供一个回调函数或者采用事件驱动的方式,在数据准备好后进行通知或者处理。在 Redis 中,异步读可以通过事件驱动机制来实现,当数据准备好时,Redis 会通知客户端,客户端在接收到通知后进行数据读取或处理。

总结来说,阻塞读适用于需要实时获取数据并且没有其他任务的场景,非阻塞读适用于需要同时处理多个任务或者需要定时轮询的场景,而异步读适用于需要更高并发性和更灵活的事件处理的场景。在实际应用中,根据不同的需求和场景选择合适的读取方式可以提高系统的性能和效率。


在 Redis 中,读取事件指的是客户端向 Redis 服务器发送请求,并且服务器需要读取客户端发送的数据。这些请求可以包括读取操作(如 GET)、订阅/发布操作(如 SUBSCRIBE、PUBLISH)等。读取事件是 Redis 服务器接收客户端请求的一种事件类型。

Redis 使用事件驱动的方式来处理客户端请求。当有客户端连接到 Redis 服务器时,服务器会为每个客户端连接创建一个套接字,并且通过监听这些套接字来接收客户端的请求。当客户端发送请求时,服务器会检测到套接字上有可读事件发生,并进行读取操作,读取客户端发送的请求数据。这个过程就是读取事件。

读取事件的处理过程一般包括以下几个步骤:

  1. 服务器通过监听套接字来接收客户端连接,并为每个连接创建一个文件描述符(File Descriptor)。
  2. 当有客户端连接发送请求时,服务器会检测到对应文件描述符上有可读事件发生。
  3. 服务器通过读取文件描述符中的数据,获取客户端发送的请求。
  4. 服务器根据客户端请求的类型和内容进行相应的处理,比如执行读取操作、执行写入操作、订阅/发布消息等。
  5. 处理完客户端请求后,服务器可以向客户端发送响应数据,然后等待下一个请求。

总的来说,读取事件是指 Redis 服务器接收客户端请求并读取请求数据的过程,这是 Redis 服务器处理客户端请求的基础。