掘金 后端 ( ) • 2024-04-20 16:58

基本概念

Redis 是我们常用的存储中间件,全称为 REmote DIctionary Server,是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。

与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。

image.png Redis是个单线程模型(Ps.单线程指的是 Redis 键值对读写指令的执行是单线程),在内存中运算;

  • 为什么使用单线程模型呢:CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是机器内存的大小或者网络带宽,所以使用单线程已经完全足够了并且可以减少切换带来的损耗

对于上游的客户端请求,Redis 采用了IO多路复用的原理,

  • Redis 会给每一个客户端套接字都关联一个指令队列,客户端的指令队列通过队列排队来进行顺序处理;
  • 同时 Reids 给每一个客户端的套件字关联一个响应队列,Redis 服务器通过响应队列来将指令的接口返回给客户端(基于reactor的事件分发机制,分开独立处理不同的事件)。

image.png

基本数据结构

1、string 即字符串类型,一般用于常规计数、分布式锁、共享 session 信息等

2、list 即链表类型(插入删除效率高,查询效率低),常用于做异步队列,消息队列(lpush,rpop)等

3、set 一个无序map,适合用于去重场景,相比于hash map,set只有key,没有value

4、hash,就是我们理解的哈希表,有key 有value

5、zset 即有序列表,有score字段,可用于做延时队列,排序场景等

每种数据结构的底层实现一般有多种方式,对于旧版本的list,hash,zset,在数据量小的时候会使用压缩表实现,这个数据量怎么衡量呢

  • 每个元素的长度小于64字节
  • 一个对象包含的元素小于512个(跳表是128)

image.png

压缩列表(zipList)

压缩列表(zipList) 实际上是利用数组来实现的链表,他的优点是结构紧凑占用内存少,连续内存读写快,但缺点是,频繁增加导致realoc重新分配内存,消耗性能。

而双向链表的特点是需要存储前后指针(浪费一定空间),增加删除、删除结点不用内存重分配,但缺点是存储的内存非连续,读写性能偏低,并且容易造成内存碎片。

先看看zipList的结构

image.png

一块连续的内存空间 (像内存连续的数组,但每个元素长度不同),一个 ziplist 可以包含多个节点(entry)。元素之间紧挨着存储,没有任何冗余空隙。

  • zlbytes: 记录整个压缩列表占用对内存字节数;
  • zltail: 记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen: 记录压缩列表包含的节点数量;
  • zlend: 标记压缩列表的结束点,固定值 0xFF(十进制255)。

有这个头部,让压缩表找头元素和尾元素非常快 O(1),但其他元素就是 O(N)了,所以不适合存储大量元素。

对于每个entry,其架构如图

PS.因为每一项数据占用的空间不同,而采用了变长编码

image.png

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

    • 如果前结点小于254字节,则entry中prelength占用1个字节来记录,如果大于等于254,prelength占用5个字节,第一字节固定254,后4个字节表示大小
  • data: 记录了当前节点的实际数据,类型和长度都由 encoding 决定;

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

    • 对于整数类型,encoding 11开头,分别对应不同类型的data类型
    • 最后1111xxxx表示超小数0-12。0000,1110,1111被占掉了,xxxx可以表示1-13,实际表示的值再-1。

image.png

  • 对于字符类型,图中pppqqqrrrsssttt都是用来表示字符串大小的

image.png

快速链表QuickList

但就如前面所说,这种结构和链表各有优劣,在新版的redis中通过quickList来实现List

quickList结合了二者的优点,每个节点都是一个双向链表节点,但每个节点又指向一个压缩表,分段压缩,避免单个压缩表太大。

image.png

跳表

Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。

简单理解,跳表就是一个多层的链表

image.png 链表在查询时,需要逐个遍历节点,所以复杂度时O(N);但是我们如果每隔几个元素抽一个元素组成第二层链表,则可以少遍历部分元素(前提,链表是有序的)

至于为什么使用跳表而不是我们熟悉的平衡树等结构呢,网上给的答案是:

1、跳表实现简单,平衡树涉及到插入时树的调整旋转,比较复杂,跳表只需要改变左右指针

2、btree这类结构为了迎合磁盘io的特性设计,内存占用会比跳表大

3、反过来,跳表的层级会比btree深(取决于抽取上一级的比例)—这是为什么mysql用b+树

使用场景

Redis一般使用场景除了用于缓存,还可以做

1、布隆过滤器

2、分布式锁

持久化方式

Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。

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

  • RDB:RDB 是对当前 Redis 的存储数据进行一次快照
  • AOF每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里日志只记录
  • 混合持久化

AOF

image.png Redis客户端在每执行完一条写命令之后,会记录命令到AOF文件中,这样redis宕机重启后就可以基于文件恢复数据。

  • 执行完再写的好处:1、不阻塞命令执行,2、避免语法有问题

实际上,命令并不是每次都直接sync到磁盘上的,而是先写到缓冲区(aof_buf),然后写到内核的pageCache内核缓存区中,由内核决定每隔一段时间同步到磁盘上,这个操作叫写回。写回有几种策略:

  • Always,每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;(可靠性高,开销大)
  • Everysec,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
  • No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。(性能开销小,可靠性不高,容易丢数据)

因为每个写命令都要追加到文件中,导致AOF文件会越来越大(但其实同一个key只有后面的命令有效,覆盖了前面的值),为了解决这个问题,redis提供了重写的机制。当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

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

重写的流程:

1、主进程(bgrewriteaof)fork一个子进程进行重写

2、重写过程中,主进程仍然可以提供服务

3、父子进程共享一份数据副本,只有当主进程写缓存时,会触发COW(copy on write)机制,复制一份数据副本(节约资源,高效,也不需要通过加锁来保证数据正确性)

4、主进程的命令会写入到AOF 重写冲区

5、子进程按上面的逻辑去读数据,写新AOF文件

6、子进程完成后,给主进程发一条信号, AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;

7、 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。

image.png 缺陷:AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。

RDB 快照

为了解决AOF的问题,Redis 增加了 RDB 快照。

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

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

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

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

过期机制和内存淘汰机制

redis 有的key是有生命周期的,对于这类key的过期机制有:

  • 惰性删除:Redis不会去主动删除,而是在客户端要获取某个key的时候,Redis会先去检测一下这个key是否已经过期,如果没有过期则返回给客户端,如果已经过期了,那么Redis会删除这个key,不会返回给客户端。
  • 定期删除:Redis默认每隔一段时间就随机抽取一些设置了过期时间的key,检测这些key是否过期,如果过期了就将其删掉。

Redis在使用内存达到某个阈值的时候,就会触发内存淘汰机制,选取一些key来删除。内存淘汰有许多策略如

  • random 随机
  • LRU 最近最少使用
  • LFU 最少使用

高可用

  • 主从复制:redis 支持主从复制模式来提供高可用的性能(主写从读),但是无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。

  • 哨兵模式:主从模式下,如果主宕机了,需要手动恢复;哨兵模式时为了解决这问题,

    • Sentinel 集群是一个由 3-5 个(可以更多)节点组成的,用来监听整个 Redis 的集群,如果发现 master 不可用的时候,会关闭和断开全部的与 master 相连的旧链接。这个时候 Sentinel 会完成选举和故障转移,新的请求则会转到新到 master 中.
    • 哨兵是 Redis 的一种运行模式,它专注于对 Redis 实例(主节点、从节点)运行状态的监控,并能够在主节点发生故障时通过一系列的机制实现选主及主从切换,实现故障转移,确保整个 Redis 系统的可用性。
  • 集群(cluster)模式:Redis 集群通过槽指派机制来决定写命令应该被分配到那个节点。整个集群对应的槽是由 16384 大小的二进制数组组成,集群中每个主节点分配一部分槽,每条写命令落到二进制数组中的某个位置,该位置被分配给了哪个节点,则对应的命令就由该节点去执行。(连接时要指定集群模式)

  • codis:在redis前面加了一层类似proxy角色的代理

缓存更新策略

  • Cache Aside(旁路缓存)策略:

    • 写:先更新数据库,再删缓存(先删再写容易数据不一致)
    • 读:从db读到后更新缓存
    • 适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。
  • Read/Write Through(读穿 / 写穿)策略:先写缓存,然后由缓存写db

  • Write Back(写回)策略:

    • Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。
    • Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。
    • 容易丢数据

常见面试题

大key问题:

  • 阻塞导致redis变慢

缓存击穿,雪崩,穿透是什么以及如何解决:

  • 击穿:热点key过期,导致请求直接打到db,容易将db打挂

    • 解决办法:1、分布式锁,只有一个请求可以打到db,2、业务逻辑续期,避免过期
  • 雪崩:大量热点key同时过期,请求直接打到db

    • 解决办法:过期时间随机化,避免同时过期
  • 穿透:数据既不在缓存又不在db,缓存无法起到作用

    • 解决办法:1、布隆过滤器 2、设置redis空值或致默认值,避免大量请求查db 3、限制非法请求(避免恶意请求一些不合法数据)