本章先总结一下redis常见的面试题,也作为一个知识笔记吧,虽然没有用过这么多功能,就当了解和学习了。
redis的使用场景有哪些
缓存、分布式锁(setnx、redisson)、计数器(incr)、存令牌(string)、消息队列(list)、延迟队列等。
缓存穿透
缓存穿透是指查询数据库中不存在的数据,请求到达服务器之后,服务器先查询redis,redis没有命中之后尝试查询数据库,数据库查询不到数据后不会向redis中写入数据,从而造成缓存持续未命中,所有请求全部打到数据库。
解决方法:
缓存空数据,当数据库查询不到对应的数据时,向缓存中写入一个表示空值的特殊数据,当下次查询到这个数据时直接返回空即可。
优点:实现简单。
缺点:对于每个被查询过的空数据都要维护一个空值,内存开销大。内存中会存在很多无效key。
布隆过滤器:布隆过滤器是一种可以快速判断数据库中是否存在某个数据的组件,在查询redis之前先经过布隆过滤器判断数据是否存在,可以有效减少打到redis和数据库的无效请求。
布隆过滤器核心由一个位图和多个hash函数构成,当我们向布隆过滤器添加一个数据时,该数据会先经过hash函数映射到位图的不同槽位上,将对应槽位置为1,查询该数据时,通过相同的hash函数获得该数据对应的槽位,如果槽位均为1,则说明数据存在。
在缓存预热时,可以先将所有数据预热到布隆过滤器中,减少无效请求打到redis或数据库中。
优点:使用位图存储,占用内存小,无效可以少,过滤效果好。
缺点:存在误判的可能,实现复杂。但是布隆过滤器的误判率是在我们可控范围内的,通过增大位图长度或hash函数的数量可以减少误判率,将误判率控制在可以接受的范围内。
缓存击穿
缓存击穿就是某一个热点key过期导致大量请求缓存未命中打到数据库从而压垮数据库。
解决方法:
互斥锁:当一个线程在redis中查询不到数据要访问数据库时先尝试获取互斥锁(分布式锁,全局唯一),如果拿到锁成功,则查询数据库并写入redis;如果拿到锁失败说明此时已经有其他线程正在重建缓存,休眠并定期重试查询redis。拿到锁的线程重建缓存完成后需要释放互斥锁。
优点:数据一致性强,不会查到过期数据。
缺点:并发量小,性能低。
逻辑过期:redis所有键都不设置物理意义上的过期时间,并且对所有键新增一个逻辑过期的字段,表示该键逻辑过期的时间。当有用户线程来查询该键时,先查询逻辑过期时间是否过期,如果过期则尝试获取一个互斥锁,获取锁成功则新开一个线程,新线程用来查询数据库并重建缓存,而原来的线程则直接返回旧数据;若获取锁失败说明已经有重建缓存的线程正在工作,直接返回旧数据,当缓存重建线程查询完成之后将新数据写入redis中。
优点:高可用性,性能好,响应快。
缺点:数据一致性差。
两种解决方法各有千秋,没有绝对的好坏之分,主要还是看实际场景来选择,如果是金融或者涉及支付等强一致性场景可以使用互斥锁,对于电商场景等对性能要求高则可以使用逻辑过期。
缓存雪崩
缓存雪崩是大量key同时过期或者redis服务宕机导致大量请求直接打到数据库造成数据库压力过大。
解决方法主要分为两类,一类针对解决大量key同时过期,另一列解决redis宕机问题:
随机过期时间:在创建键值对的时候对过期时间添加一个随机值,是不同key的过期时间不同,
增强redis服务的可用性(解决redis宕机问题):
搭建redis高可用集群
给缓存业务添加限流降级策略,例如在nginx中或者spring cloud gateway中添加限流降级策略。
给业务添加多级缓存,例如Guava、Caffeine作为一级缓存,redis作为二级缓存。
双写一致性
双写一致性就是当修改了数据库的数据之后也要同步修改缓存中的数据,保证数据库和缓存的数据一致性。
双写一致性一靠删除缓存和更新数据库实现,然而这两个操作无论哪一个在前都会存在并发问题。
如果先删除缓存再更新数据库:假设线程1尝试更新数据,先删除缓存,然后尝试更新数据库,在更新数据库之前,线程2查询相同的数据,由于缓存已经删除,线程2缓存未命中会直接读取数据库中的内容(此时数据还未更新)并写入缓存(写入的是旧数据),然后线程1更新数据库。此时就发生了缓存和数据库的数据不一致。
如果先更新数据库再删除缓存:假设线程1查询数据,发现未命中,于是查询数据库得到数据准备写入缓存,此时切换到线程2,线程2尝试更新数据库,更新数据库之后删除缓存,完成之后切换到线程1,线程1将刚刚查到的旧数据写入到缓存中,也出现了数据库和缓存的不一致问题。
解决方法:
延迟双删。
当更新数据库时,先删除缓存,再更新数据库,间隔一小段时间之后再次删除缓存。
说明:间隔一小段时间的原因,一防止其他请求将旧数据写到缓存中;二是等待数据库的主从同步
读写锁
如果对一致性要求较高,可以使用读写锁来保证双写的原子性。读写锁一般使用Redisson提供的读写锁。
消息队列
消息队列适用于对一致性要求较低,只最求最终一致性的场景。在数据服务更新之后,可以向MQ发送一条消息,缓存服务监听这个MQ,当收到消息之后再删除缓存。该方案的可靠性主要依赖MQ的可靠性。
redis的持久化
redis的持久化主要有两种方式:RDB和AOF。
RDB
RDB(Redis Database Backup),redis数据快照,简单来说就是把内存中所有数据记录到文件中。当redis实例重启之后可以读取RDB文件来恢复数据。
RDB原理:bgsave开始时会fork主进程得到子进程,子进程共享主内存的数据,当fork完成后子进程开始读取内存数据并写入RDB文件。子进程备份的执行是异步的,不会阻塞主进程,对性能的影响可以忽略不计。子进程通过copy父进程的页表从而实现共享内存的,页表和父进程相同意味着相同的逻辑地址会映射到同一块物理地址中。而在备份过程中数据是只读的,如果父进程需要向内存中写入数据,会copy一份数据副本,向副本中写入数据,当备份完成后用数据副本替换主数据。子进程写完RDB文件后会替换旧的RDB文件,在redis的配置文件中可以修改记录频率和规则。
AOF
AOF(Append-Only File)将每条写命令追加到磁盘日志文件,通过再执行一遍日志中的命令来恢复数据。由于每次写都会向日志文件中添加命令,因此AOF产生的文件会比RDB文件大很多。通过bgrewriteof命令可以让AOF执行重写功能,即对同一个键的多次操作,只记录最后一次有效的操作,用最少的命令达到相同的效果。
AOF有三种刷盘策略:
always:每次写入都记录到日志文件中,redis服务性能最差,但是数据安全性最高。
everysec:每秒记录一次,数据或安全性都适中,最多丢失一秒的数据。
no:不主动刷盘,取决于操作系统,数据安全性最差,性能最好。
RDB和AOF对比
在实际开发中通常会使用混合使用两种持久化策略,也就是同时开启两种方式,RDB做全量备份,AOF作为增量日志。
数据过期策略
数据过期策略是指如何处理已经过期的key,redis提供了两种策略来处理过期数据
惰性删除:当一个key过期时,不会立即删除该key,而是在查询该key时检查其过期时间,如果已经过期则删除并返回空。
优点:对CPU友好,系统不会消耗过多时间和资源在删除key上,只有在使用时顺带检查过期时间。
缺点:对内存不友好,如果有key已经过期但长时间没有被访问就会一直存放在内存中,占用内存。
定期删除:定期抽取部分有过期时间的key检查,如果过期则删除这些key,定期删除分为slow和fast两种模式。
slow模式:可以配置定期删除的频率,例如10hz表示一秒执行十次。slow模式的删除执行时间较长,但执行频率相对较低。
fast模式:执行频率不固定(由操作系统触发),但每次执行的时间很短。
redis实际使用的过期策略是惰性删除 + 定期删除两种策略结合使用,即避免了过期key会一直存在于内存中的问题,降低了CPU的压力。
数据淘汰策略
当redis的内存不足时,需要选择一部分key删除来释放内存,从而防止内存溢出,redis提供了8种数据淘汰策略:
noeviction:不淘汰任何key,当内存满时禁止写入数据,redis默认策略就是这种策略。
volatile-ttl:对设置了TTL的key,比较剩余时间,优先 淘汰剩余过期时间更短的key
allkeys-random:对全体key进行随机淘汰。
volatile-random:对设置了TTL的key进行随机淘汰。
allkey-lru:对于所有key基于LRU算法进行淘汰。
LRU(Least Recently Use):最近最少使用,用当前时间减去最后一次使用的时间,这个值越大则被淘汰的优先级越高,因为他是最久没有被使用的key了
valatile-lru:对所有设置了TTL的key基于LRU算法进行淘汰。
allkey-lfu:对所有key基于LFU算法进行淘汰。
LFU(Least Frequently Use):最少频率使用。会统计每个key一段时间内的访问频率。访问频率越低淘汰优先级越高。
volatile-lfu:对所有设置了TTL的key基于LFU算法进行淘汰。
使用建议
优先使用allkey-lru算法。充分利用LRU算法的优势,把最近经常访问的数据留在内存中,如果业务有明显的冷热数据之分,建议使用。
如果业务的数据访问冷热区别不大,访问频率都差不多,可以使用allkeys-random,随机淘汰。
如果业务有置顶数据的需求,可以使用volatile-lru算法,对置顶数据不设置过期时间,使其一直留在缓存当中,淘汰其他设置了过期时间的key。
如果业务有短时高频访问数据,可以使用allkeys-lfu和volatile-lfu,热点数据访问频率下降之后及时删除过时key。
扩展问题
数据库有1000万数据,而redis只能存200万数据,如何保证redis中的数据都是热点数据?
建议使用allkey-lru策略,将最近经常访问数据保存在缓存中。。
redis内存用完了会发生什么?
看选择的内存淘汰策略,如果是noeviction,则会直接报错。
redis实现分布式锁
想象一个优惠券秒杀的场景,我们的库存数据存放在redis中,如果我们的逻辑是获取redis中的数据,检查库存是否充足,如果重组则将库存 - 1,然后重写写入库存。在不加锁的情况下,这个逻辑是会出现并发问题的,即会超卖,此时对整个逻辑加上synchronized锁可以解决这个问题,但现代应用大多采用集群部署,synchronized锁是JVM层面的,不同的机器有不同的synchronized锁,因此synchronized锁无法在分布式场景下解决超卖问题。所以我们需要一个全局唯一的锁,这就是分布式锁。
分布式锁可以使用redis实现,最简单的实现是setnx命令,setnx命令是原子性的,因此可以保证锁只有一个,如果setnx成功,则代表拿到锁,执行完逻辑之后删除对应的key代表释放锁。在set的时候注意一定要设置过期时间,如果不设置过期时间可能会出现死锁的问题,例如执行业务的服务宕机,这代表不会执行删除key的逻辑,同时因为没有设置过期时间,锁会一直存在,其他线程永远拿不到锁,就会产生死锁。添加了过期时间之后,即使服务执行到一半宕机,达到过期时间之后锁也会自动释放。
同时过期时间的设置也是有讲究的,设置太短可能会导致锁提前释放,业务还没有执行完过期时间就到了,会出现并发问题。
合理设置锁的过期时间主要有两个方法:根据业务执行时间预估,但是业务执行时间是相当不可控的,有各种因素会干扰业务执行时间,例如网络抖动、操作系统线程切换等,所以这个方法不太现实,效果也难以确定;给锁续期,使用一个新线程监控分布式锁,当锁快要过期时,如果业务还在执行。则重置锁的过期时间,这种机制称为WatchDog(看门狗)机制,Redisson中的分布式锁就实现了看门狗机制。
redisson实现的分布式锁
redisson框架实现了一个相对比较完善的分布式锁,其原理也是基于setnx命令。
redisson实现的分布式锁主要有三个特点:
实现了看门狗机制,当线程申请锁成功之后,redisson会新建一个线程监听该线程,看门狗线程每隔一段时间会检查业务是否完成,如果业务没有完成会给分布式锁续期。
当线程获取锁失败后,不会立即返回失败信息,而是会不断尝试重新获取锁,这是因为一般业务的执行都是非常快的,短时间内重试几次有可能就获取到刚刚释放的锁了,可以提高性能。
可重入性,redisson的分布式锁是可重入的,其利用hash结构来存储线程id和冲入次数,key是锁的名字,value是一个hash数据类型,其中field是线程id,value是重入次数,当同一线程再次尝试获取锁时,会锁的线程id比较,如果相同则允许冲入,value + 1,释放锁的时候会将value - 1,当value为0的时候删除锁。
底层使用lua脚本保证加锁的原子性。
redisson的分布式锁不能保证主从一致性问题,因为有可能从节点还没来得及同步主节点的锁信息,主节点就宕机了,此时redis的哨兵会挑选一个从节点升级为主节点,但是这个新的主节点是没有原来的锁信息的,此时就会出现主从不一致。
redisson的分布式锁如何保证主从一致性
redisson提供的红锁(RedLock)可以保证主从一致性,红锁的原理是只有超过一半的节点全部添加了锁信息才算加锁成功。但红锁因为实现复杂、性能较差、运维繁琐等原因,使用场景并不多。
redis技术本身是基于AP思想实现的,即更加关注可用性。所以天然不适合作为对一致性要求极高的场景的分布式锁,如果业务真的有很高的一致性要求的话,可以采用CP思想的zookeeper作为分布式锁。
redis集群方案
现代应用中,对可用性、并发能力的要求越来越高,单点redis的性能已经无法满足我们的需求了,现在企业中大部分应用都在使用redis集群来提高服务的可靠性。
redis集群主要有以下三种模式:
主从复制
为了提高redis的并发能力,可以搭建redis的主从集群,实现读写分离。主从复制将redis集群中的节点分为主节点和从节点,其中主节点只负责写数据,从节点负责读数据,而在这个过程中需要解决的核心问题就是主从同步问题。
主从同步分为两种:全量同步和增量同步。
全量同步过程:
replid:Repilication Id,简称replid,是数据集的标记,id一致则说明是同一数据集,每一个master都有一个唯一的replid,master的slave都会继承这个master的replid。
offset:偏移量,随着记录在repl_backlog中的数据增多而增大。slave完成同步时也会记录当前的offset,如果slave的offset小于master的offset,说明slave的数据已经落后于master,需要更新,而master可以根据slave的offset来决定发送哪些数据。
1.从节点执行replicaof命令,与主节点建立连接。
2.从节点向主节点请求数据同步,发送自己的replid和offset。
3.根据replid判断是否是第一次同步。
4.replid不一致,说明从节点是第一次同步,需要返回master的数据版本信息,包括replid和offset.
5.从节点收到版本信息之后将自己的replid替换为主节点的版本信息
6.主节点执行bgsave,保存自己的RDB文件。保存完成之后发送RDB文件。
7.从节点清空本地数据,从收到的RDB文件中读取数据,进行同步。
8.在主节点执行bgsave到从节点恢复完RDB文件中的数据这段时间之内,主节点同时也在不断地执行写命令,需要将这期间的写命令全部记录到repl_backlog日志文件中,从节点加载完成RDB文件之后需要发送repl_backlog文件到从节点。
9.从节点接收到repl_backlog文件之后执行相应的命令。全量同步完成。
全量同步过程图示

增量同步:增量同步就是只同步主节点和从节点相差的部分
增量同步过程:
1.从节点执行replicaof,与主节点建立连接。
2.从节点向主节点发送自己的replid和offset。
3.主节点判断收到的replid和自己的replid一致,当前不是第一次同步,从节点包含了部分数据。本次应该进行增量同步。
4.根据从节点的offset寻找到repl_backlog中对应的数据。
5.将从节点的offset之后的所有数据发送给从节点。
6.从节点根据收到的数据更新自己的数据和offset。增量同步完成。
增量同步过程图示

哨兵模式
redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复,哨兵本身就由多个redis节点组成,这些节点不负责存储数据,主要责任有三个:
监控:哨兵会不断检查master和slave是否按期工作。
自动故障恢复:如果master故障,为了避免整个集群失去写能力,哨兵会自动选择一个slave升级为新的master,即使故障master恢复之后仍然以新的master为主。
通知:哨兵充当redis客户端的服务发现来源,当集群发生故障转移时,哨兵会将最新消息通知到redis客户端。
哨兵模式基于心跳检测机制监测服务状态,每个一段时间向集群中的节点发送ping命令:
主观下线:如果某个哨兵节点发现某个实例未在规定时间内响应,则认为该实例主观下线。
客观下线,如果超过指定数量的哨兵节点都认为该实例主观下线,则该实例客观下限。阈值最好超过哨兵节点数量的一半。
哨兵选主规则:
首先判断主从节点断开连接的时间长短,如果超过了指定时间就排除该节点。
然后判断节点的slave-priority值,值越小优先级越高
如果slave-priority值相同,则判断slave节点的offset值,offset越大优先级越高。
最后判断slave的运行id大小。越小优先级越高。
redis集群脑裂
脑裂:主节点和从节点和哨兵处于不同的网络分区,导致哨兵无法感知到主节点,于是哨兵会提升一个从节点为主节点,此时就存在了两个主节点,老主节点还在继续对外提供服务。当网络恢复后,老主节点会变成从节点,并从新主节点同步数据,导致网络故障这段时间写入的数据被覆盖,数据丢失。
发生过程:
主节点还活着,可以正常提供服务,但是因为网络原因,与哨兵集群的连接断开,网络不同。
哨兵检测不到主节点的心跳,认为主节点已经下线,挑选了一个从节点升级为主节点。
此时老主节点网络恢复,和新主节点一同对外提供服务,老主还在运行,不知道已经选了新主。
结果:两个主节点同时对外提供服务,同时存在两个主节点,其中的数据互不相同。等到老主节点恢复和哨兵的连接,老主节点会变成新的主节点,在这期间写入的数据会被全部清空覆盖,数据丢失。
通过配置两个参数可以解决脑裂问题:min-replicas-to-write和min-replicas-max-lag。
min-replicas-to-write N表示写入主节点的数据至少要同步给N个从节点才算写入成功。min-replicas-max-lag M参与计数的从节点,复制延迟不能超过M毫秒。
两者配合使用,只有在规定时间内拿到足够数量的从节点ACK才算写入成功,能防止脑裂问题。
哨兵不是会通知客户端主节点的变更吗,为什么客户端还会向老主节点写入数据?
哨兵本身不主动推送消息给客户端,只有客户端执行写命令失败时,才会询问哨兵新的master地址。哨兵本身不会主动断开已有的TCP连接。而老主节点虽然和哨兵的网络不通畅,但是和客户端的网络是好的。
分片集群
主从集群和哨兵已经解决了高并发读和高可用,但是还有两个问题没有解决:海量数据存储和高并发写。这就是redis分片集群所解决的问题。
分片集群有如下特征:
集群中有多个master,每个master保存的是不同的数据。
为了解决高并发读的问题,可以为每个master节点再设置slave节点实现读写分离。
master之间彼此通过ping检测心跳,代替了哨兵模式。
客户端请求可以访问任意节点,最终都会被路由到正确节点。
分片集群原理:redis分片汲集群引入了哈希槽的概念,redis分片集群共有16384个哈希槽,编号分别为0-16383,每个节点负责不同的编号范围。redis会对key的hashi值对16384取模,根据最后结果将key存储到对应节点,每个主节点都有路由功能,客户端的请求可以到达任意一个主节点,最终都会被路由到正确节点。
可以看到,redis分片集群是结合了主从集群和哨兵模式的产物,这也是分片集群被作为redis官方推荐的集群的原因。
redis为什么快
redis性能高的原因主要有三点:
纯内存操作,执行速度非常快。
采用单线程,避免了多线程不必要的上下文切换开销,同时由于纯内存操作,单线程也能支持超高并发。
采用IO多路复用和非阻塞IO模型。
前两点显而易见,这里对第三点做一个重点讲解。
常见的IO模型
阻塞IO
顾名思义,阻塞IO就是全程都在阻塞的IO过程。
阶段一:
用户进程尝试读取数据。
此时数据尚未到达,用户进程阻塞,内核也在等待数据。
阶段二:
内核收到数据并准备完毕。
内核将数据拷贝到用户空间缓冲区。
拷贝过程中,用户进程依然阻塞。
拷贝完成后通知用户进程,用户进程解除阻塞,处理数据。
可以看到阻塞IO的全过程用户线程都在阻塞等待数据,效率是非常低下的,浪费了很多时间和资源。

非阻塞IO
非阻塞IO是全程不阻塞的IO模型。非阻塞IO的recvfrom操作不会阻塞而是立即返回结果。
非阻塞IO过程:
用户进程尝试读取数据,此时数据尚未到达,内核等待数据。
内核返回异常给用户进程。
用户拿到error异常结果后继续recvfrom尝试获取数据
循环往复直到数据就绪。
内核数据到达并拷贝到内核缓冲区。
内核开始拷贝数据到用户进程,拷贝过程用户进程依然阻塞。
拷贝完成,用户应用解除阻塞,处理数据。
可以看到,虽然非阻塞IO的第一个阶段是非阻塞的,但是CPU依然花费了大量时间在自旋等待上,这期间CPU的资源一直在被浪费但不产生任何效果,所以效率相对阻塞IO并没有得到很明显的提升。

IO多路复用
IO多路复用就是使用单个线程来同时监听多个socket,在某个socket可读或可写时通知应用,从而避免无效的等待,充分利用CPU资源。
IO多路复用过程:
用户进程调用select,指定要监听的socket的集合。
内核监听对应的多个socket。
任意一个socket或多个socket数据继续则返回readable。
此过程中用户进程阻塞
用户进程找到就绪的socket。
依次调用recvfrom读取数据。
内核数据拷贝到用户空间。
用户进程解除阻塞,处理数据。
虽然在监听过程中用户进程依然阻塞,但是可以一次性监听多个socket,资源利用率更高。前面的阻塞IO和非阻塞IO就类比于一个服务员只服务于一个顾客,但是IO多路复用相当于一个服务员同时接待多个顾客,这样可以避免无效等待,提高CPU利用率。

redis就是利用了IO多路复用来提高了网络性能,结合多个事件处理器来应对多个socket请求。
连接应答处理器:redis在收到TCP连接之后负责初始化客户端、注册事件、准备后续读写命令
命令回复处理器:在redis6.0之后,为了更好地提升性能,使用了多线程来处理事件。
命令请求处理器:在redis6.0之后,将命令地转换使用多线程来执行,增加命令转换速度,在命令执行的时候依然是单线程。