Skip to content

缓存设计

更新: 5/17/2025 字数: 0 字 时长: 0 分钟

虽然之前的文章有提到过Redis,但没有很系统的对缓存讲过,这边以缓存为重点来讲一下Redis

为什么用缓存

很多的小伙伴肯定会在自己的项目中写:用Redis实现了缓存

这个时候面试官很自然的就会问:你为啥要实现缓存,不实现行不行,那实现了有啥好处坏处

这里就着重说一下这个问题。

缓存的作用通常就两个:高并发和高性能

高性能

由于MySQL自身的问题,我们的数据量达到十万条甚至百万条的时候就会导致性能降低(响应速度>200ms),所以我们必须得找另一个解决方案,这里就是使用的缓存,通过缓存的方式,我们可以针对某些热点数据做单独处理,进而让查询的性能提升到200ms以内

高并发

根据测试MySQL由于自身的架构问题,其对高并发的适配较差,基本2000QPS就会报警,而对于一个用户量大的系统,高峰期来个1w的QPS直接给MySQL干成sb了,所以我们必须得作一层预防处理,这里就是使用的Redis

(其实你也可以换一个数据库,这里推荐一下TiDB(据说B站用的就是这个数据库))

Redis的线程模型是什么,Redis为什么单线程却能支持高并发

这应该是Redis原理最简单的问题了

Redis线程模型

这里得划分一下,Redis 6.0之前Redis确实是单线程的,但6.0以后,Redis引入了多线程,这里先说一下6.0之前的状况

Redis内部使用文件事件处理器file event handler,这玩意是单线程的,因此我们才会说Redis是单线程模型,它采用IO多路复用的机制(其实也就是NIO)监听了多个Socket,将产生事件的socket压入内存队列中,然后事件分派器根据socket上的事件类型来选择相应的事件处理器进行处理

(关于NIO我也在一篇文章中做了简单的介绍,如果对这一块有点懵的话可以去看一下)

一次完整的流程

搜先Redis客户端初始化的时候,会先将Server Socket中的 AE_READABLE 事件与连接应答处的处理器关联

客户端socket向Redis进程的Server socket发起连接请求,此时的Server Socket会产生一个AE_READABLE事件,NIO程序监听到Server Socket产生的事件后,将该Socket压入队列中,文件事件分派器从队列中获取socket,交给连接应答处理器,连接应答处理器会创建一个新的能与客户端通信的Socket(后文称为Connected Socket,这是我自己瞎起的名字,没啥意义),并将该Socket的AE_READABLE事件与命令请求器关联。

这里区分一下两个Socket

第一个由客户端发出的Socket用于与Server Socket来确定连接,ServerSocket只负责监听,绑定,其作用是在一个特定的端口上监听来自任何客户端的请求,是一个被动的等待者

而第二个Socket用于在监听Socket成功接收到一个连接后,专门与这个特定的客户端来进行通信的Socket,用来接收后续的命令等

这时假设客户端发起一个Redis命令,那么Connected Socket就会产生一个AE_READABLE事件,IO多路复用程序将Connected Socket压入队列,此时事件分派器会从队列中获取Connected Socket产生的AE_READABLE事件,然后交给命令处理器来处理。处理完成操作后,事件处理器会让回复处理器建立一个对Connected Socket的AE_WRITABLE事件的关联,等待AE_WRITABLE事件进入队列

当客户端准备好接受数据时,Connected Socket就会创建一个AE_WRITABLE事件,然后压入队列当中,当事件分派器找到了关联的命令回复处理器后,就会由命令回复处理器来将本词操作地方结果回复出去,然后接触事件回复处理器对该Connected Socket的AE_WRITABLE事件的关注

为啥单线程还能高效率

Redis的高效是大家有目共睹的,那为啥Redis单线程还能这么高效呢?

  • 纯内存操作,就是快
  • NIO的强大
  • C语言的强大
  • 单线程避免了上下文切换的耗时,也预防了多线程的竞争问题

Redis引入多线程

在Redis 6.0之后,Redis引入了多线程,使得Redis可以使用多线程模型

这主要是因为Redis发现网络开销也是单线程太慢了,读写网络的Read/Write占用了CPU的大部分时间,因此有必要重做Redis的网络读写部分,使其成为多线程的状态

这里需要特别注意的是,Redis仅仅在网络数据的读写和协议解析层面做了多线程处理,执行命令仍然还是单线程的

Redis的数据类型

这也是一个相对简单的问题,就是看你对Redis孰不熟练

Redis 主要有以下几种数据类型:

  • Strings
  • Hashes
  • Lists
  • Sets
  • Sorted Sets

Redis 除了这 5 种数据类型之外,还有 Bitmaps、HyperLogLogs、Streams 等。你也可以通过安装插件的方式引入Bloom Filter,Roaring Birmap等特别的数据结构

Strings

这就是最简单的KV数据库的形式

Hashes

V变成了HashMap

Lists

这玩意是个有序列表,支持随机位置的插入,同时对头尾的插入和取出也做了专门的处理,因此这玩意理论上也可以实现队列和栈,常说的基于Redis实现消息队列也是说的利用这个数据结构

Sets

无序集合,自动取重,这玩意最大的作用是用来取出重复的数据

Sorted Sets

有序集合,其顺序取决于一个Score,需要用户在set的时候传入

Redis的过期策略

从这里往后就是稍微偏难一点的问题了,首先是Redis的过期相关的问题,我们都知道可以向Redis中加入一个ExpireTime来设定一对KV的过期时间,但是在生产环境的 Redis 经常会丢掉一些数据,写进去了,过一会儿可能就没了。这都是Redis过期策略的问题

Redis采用的过期策略为 定期删除+惰性删除

定期删除

Redis的数据并不是真的时间到了就会删除,而是会有一个规定的时间(比如100ms),每隔这么一段时间Redis就会检测一次,然后将已经过期的数据删除掉

但是由于Redis中可能存放很多的数据(比如10w+),这个时候如果每100ms就对10w个数据进行检测,那算是彻底烂完了,CPU直接干废了,因此Redis的定期删除并非每隔一段时间就对所有的数据检查一次,而是对部分数据进行检查

但这样又会引入一个问题——那如果有个Key倒霉,过期了一直没有被删掉怎么办

这就引入了惰性删除

惰性删除

Redis的惰性删除就是说,当你在获取一个Key的时候,Redis会先去检查一下这个Key有没有过期,如果过期了,那么Redis就会主动的删除这个Key,然后不返回任何数据

但是这又引入一个新的问题——如果一个Key更倒霉,不光一直没有被定期删除掉,还一直没被人用过,那怎么办?

这里就要引入内存淘汰的机制

Redis的内存淘汰机制

Redis的内存淘汰机制有很多,这里就放出相对常用的

  • allkeys-lru:当内存不够写入新数据时,引出最近最少使用的Key
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

为什么会选择allkeys-lru为默认的策略

主要是相对另外两个,allkeys-lru更加稳定,另外两个有一个及其致命的问题,就是如果真的没有过期的key,那么就会导致后续的所有写入指令全部变为MMO,这样对我们的系统影响很大,因此为了保证系统的可用性,会采取allkeys-lru为Redis的默认淘汰机制

Redis如何实现高并发/Redis的主从架构

单机部署的Redis能承载的QPS大概是万级到十万级,而由于缓存自身的业务特点,因此其高并发主要在读上,故而我们将Reedis的架构做成主从架构/一主多重,主节点负责写入,从节点负责读出,让所有的读请求全部都走从节点,这样就实现了架构的轻松水平拓展与读高并发

核心机制

  • Redis会采用异步的方式将数据复制到slave节点(在2.8版本后,slave node会周期性的来确定自己每次复制的数据量)
  • 一个master node可以配置多个slave node
  • slave node 可以连接到其他的 slave node
  • slave node在复制的时候不会阻塞掉原本的查询操作,他会暂时用旧数据顶上,但删除旧数据加载新数据的过程必须得暂停对外的服务

需要特别注意的是,建议开启master的持久化机制,不建议让slave作为master的热备份,因为关闭的master重启后,slave可能会将空数据全部copy过来

除此之外还有对master的其他可用的要求,这一块主要在Redis的哨兵机制中讲解

核心流程

当启动一个Slave时,Slave会主动发送一个PSYNC命令给master

  • 如果是slave初次连接到master,那么会触发一次全量复制(full resynchronization)。此时master会启动一个后台线程,生成一份快照文件(RDB),然后将从客户端搜到的新命令缓存到内存中,RDB生成完成后,master会先将其发送给slave,然后slave会先将其写入磁盘,然后再从磁盘中读取并加载到内存中,接着master会将缓存的命令再发给slave让slave去同步这些数据
  • 如果不是初次连接(发生了master与slave的连接中断,比如断网了),那么slave重连后,slave会向master发送一个偏移量(slave_repl_offset),master会根据这个偏移量去寻找slave缺失的命令,然后将缺少的部分发送给slave

需要特别注意的是,slave也不负责过期的处理,master会在key过期时模拟一个del指令发给slave

Redis如何实现的高可用/Redis哨兵集群/Redis的持久化策略

Redis持久化

持久化可以说是高可用的一个部分,你的Redis故障后如果数据全丢了那不还是玩完,很多新手教程在教我们安装Redis的时候也会顺便让我们在Redis的配置文件中配置持久化,那Redis的持久化究竟是如何实现的呢?

  • RDB:Redis对内部数据进行一个周期性(间隔时间长)的持久化
  • AOF:Redis将每条写入命令作为日志写入日志文件中(间隔时间短),当Redis重启后们可以通过回放日志的方式恢复数据

Redis支持同时开启这两种持久化模式,使用AOF保证数据的不丢失,同时使用RDB作为不同程度的冷备份,当AOF文件顺坏或不可用时,我们可以通过RDB来恢复文件

Redis哨兵机制

Sentinel:哨兵 这是Redis集群中一个相当重要的组成部分,主要负责监控master和slave是否正常工作;在master故障时挑选一个slave转变为master;通知客户端master的变化

哨兵本身也是支持分布式的,我们可以通过部署多个哨兵来形成哨兵集群(建议三个以上,保证健壮性),这样就保证了一个哨兵出问题后,哨兵集群仍然可以实现哨兵的功能,同时对于新master的选举,需要一般以上的哨兵同意

sdown和odown

  • 当一个哨兵认为一个master宕机了,那么就是主观宕机
  • 如果 quorum 数量的哨兵认为一个 master 宕机了(多个sdown),那么就是客观宕机

那么sdown如何达成呢?其实就是一个哨兵ping一个master,超过了is-master-down-after-milliseconds,那么这个哨兵就认为这个master宕机了

选举机制

我们会从众多的slave中选取一个作为新的master,对于被选举的slave,我们要求他与master断开连接的时间小于down-after-milliseconds的 10 倍

接下来会对 slave 进行排序:

  • 按照 slave 优先级进行排序,slave priority 越低,优先级就越高。
  • 如果 slave priority 相同,那么看 replica offset,哪个 slave 复制了越多的数据,offset 越靠后,优先级就越高。
  • 如果上面两个条件都相同,那么选择一个 run id 比较小的那个 slave。

Redis集群模式

早年间Redis自己并没有办法实现集群,需要借助一些中间件来完成,但随着Redis的发展,Redis自己也原生的支持了分布式(Redis Cluster),这里就来聊一下这个东西

Redis Cluster核心机制

  • 自动将数据分片,然后每个Master上存放一部分的数据
  • 当部分master不可用时,保证集群整体的可用

Redis Cluster模式下,Redis需要开发两个端口:6379和16379,其中16379就是用来进行节点间通信的

Redis Cluster的高可用与哨兵机制类似

如果一个节点认为另一个节点宕机,那么就是主观宕机(pfail),如果多个节点认为一个节点宕机,那就是客观当即(fail)

然后就会从该宕机的master的slave中选取一个最为新的master,只有slave和master断开连接的时间小于cluster-node-timeout * cluster-slave-validity-factor的slave才有资格成为新的master

缓存的三大问题即解决方案

这东西基本上是必问的了,问缓存不问三大问题问啥

缓存雪崩

雪崩:就是说一个东西“哗”的倒下来

这里其实也就是缓存突然大量失效,导致大量的数据打到数据库怎么办?

一种常见的导致这种问题的情况就是你的缓存崩掉了(也就是所有的缓存全失效)

解决方案:

  • 架构高可用:哨兵啊,集群啊都搞起来
  • 本地缓存+Sentinel限流:保证即时缓存雪崩也不会打死数据库
  • Redis持久化:保证可以即时的找回雪崩的数据

缓存击穿

击穿:针对一个点的突袭

这里是说有一个Key特别火热,然后他失效的瞬间,由于新的缓存还没建立,因此会让大量的数据打到数据库

解决方案:

  • 对于一些永远都常用的数据设置永不过期
  • 通过本地缓存和分布式缓存形成二级缓存,设置过期时间不同(一般本地缓存也就缓存个1分钟左右)保证至少一个活着,同时相互更新

缓存穿透

这个主要是说有一个黑客故意找了一个不可能的Key一直攻击缓存,缓存里没有,自然就打到数据库了,数据库没有就不能同步缓存,这样就会导致这个请求一直都能直接的打到数据库

解决方案:

  • 设置黑名单+限流
  • 在缓存前再加上一层布隆过滤器,将所有可能的key全部存入布隆过滤器,由于布隆过滤器对不存在的数据判断一定正确,因此能直接返回

缓存如何和数据库保持一致?

这也是个很常见的问题,你既然用了缓存,那缓存就得和数据库的数据一致吧,那这个一致怎么保证呢?

一般的缓存读写机制/Cache Aside Pattern

  • 读的时候先读缓存,缓存没有再读数据库
  • 写的时候先写数据库,数据库更新完再删除旧的缓存

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

因为有的缓存缓存的数据和表不是1对1的关系,这种情况下更新就极其复杂,甚至会占用较多的资源,因此这里就使用了一个Lazy的思想,让缓存的更新永远停留在他访问不到的时候

潜在的问题

但是上面的机制会存在两种问题:

  • 缓存删除失败了:这时缓存中的旧数据被保留,用户获取的还是缓存中的旧数据

    这个问题的解决方案相对简单,我们可以采取延迟双删的策略,也就是删除缓存的指令发布后我们等待上1m再去发布一次删除命令(这里是假定1m的时间可以完成缓存的删除相应),这样就有了一次兜底的操作

  • 缓存拉取数据库数据与缓存同步数据到缓存中的这个时间间隔内,发生了一次数据库更新后的删除缓存指令,这次指令相当于把空重复删了一遍

    这个复杂的场景只有上亿并发的时候才可能遇见,解决方案是在更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新执行“读取数据+更新缓存”的操作,根据唯一标识路由之后,也发送到同一个 jvm 内部队列中。

Lua脚本

设计到高并发就会有一个原子性的问题,但是由于Redis内核是单线程的,因此就不涉及到原子性的问题(或者说是所有的命令都是原子性的)

但Redis有一个别的问题

那就是Redis没有像if一样的逻辑语句,这就导致我们操作Redis进行一些复杂操作的时候需要写两条甚至多条语句进行,而这个过程是否符合原子性就很难说了

为此Redis官方支持了Lua脚本,允许Lua脚本内调用Redis指令,然后Redis执行Lua脚本,这样就完成了一系列逻辑语句的原子性操作

这一操作减少了网络IO开销,同时支持了复杂逻辑

当然Redis使用Lua脚本也有自己的问题(这是我曾经看到的一个面试题)

由于Redis是单线程的,因此Lua脚本在执行的时候会阻塞Redis,一旦Lua脚本内部出现问题,或者Lua脚本本身执行太复杂的命令,就会导致整个系统的阻塞

说了这么多,其实就是Redis自身不太支持以原子的形式执行复杂命令的问题

本站访客数 人次      本站总访问量