👨🎓作者简介:一位大四、研0学生,正在努力准备大四暑假的实习
🌌上期文章:首期文章
📚订阅专栏:Java后端面试
希望文章对你们有所帮助
Redis面试篇(原理+场景题)
缓存穿透
缓存穿透:查询一个不存在的数据,MySQL查询不到数据,也就不会直接写入Redis缓存,就会导致每次请求都查数据库,恶意攻击下容易把数据库搞崩。
解决方案
方案一——缓存空数据
缓存空数据,查询返回的数据为空,就把这个空结果缓存到Redis。
例如:{key:-1,value:null}
优点:简单
缺点:消耗内存(假如空数据的数量很多,Redis就要存储很多空数据)。
方案二——布隆过滤器
在请求和Redis之间增加布隆过滤器,用户先请求布隆过滤器,不存在就直接拒绝。这底层是通过算法实现的。
bitmap(位图):以位为单位的数组,只存储0、1。
布隆过滤器作用:检索一个元素是否在一个集合中。
但是可能会出现误判:
误判率:数组越小误判率越大,数组越大误判率越小,但是也会带来更大内存消耗。
实际应用的时候可以通过Redisson设置误判率,底层会自动创建相应的数组,做相应的操作。
优点:内存占用少,没有多余的key
缺点:复杂,存在误判
总结
缓存穿透:查询一个不存在的数据,MySQL查询不到数据,也就不会直接写入Redis缓存,就会导致每次请求都查数据库。
解决方案:
1、缓存空数据
2、布隆过滤器
缓存击穿
缓存击穿:给一个key设置了过期时间,当key过期了,key重建还没完成,恰好有大量的并发请求过来,可能会瞬间把数据库压垮。
解决方案
方案一——互斥锁
**特点:**强一致、性能差
方案二——逻辑过期
逻辑过期的方案,并不会给key设置过期时间,而是特意在value中设置了过期字段expire。
如果查询发现expire到期了,就会获取互斥锁,但这个锁只是为了开辟新线程做数据重建的,而数据重建完成之前,查询的数据都直接返回,当然了,这时候是旧数据。
特点:高可用、性能优
总结
缓存击穿:给一个key设置了过期时间,当key过期了,key重建还没完成,恰好有大量的并发请求过来,可能会瞬间把数据库压垮。
解决方案
1、互斥锁:强一致但性能差。
2、逻辑过期:高可用、性能优,但是不能保证数据的绝对一致性。
缓存雪崩
缓存雪崩:同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案
1、给不同的key的TTL设置随机值,这样就不会出现大量的缓存key同时失效。
2、利用Redis集群提高服务的可用性(哨兵模式、集群模式)。
3、给缓存业务添加降级限流的策略(nginx、SpringCloud网关):降级可以作为系统的保底策略,适用于穿透、击穿、雪崩。
4、给业务添加多级缓存。
双写一致性
Redis作为缓存,MySQL中的数据要和Redis的数据同步,即双写一致性问题。
这其实是分业务来看的,可能业务的要求是一致性要求要很高,可能业务的要求允许延迟一致,根据具体的业务来分析。
双写一致性:当修改了MySQL的数据,也要同时更新缓存的数据,保持数据的一致性。
- 读操作:缓存命中,直接返回;缓存未命中就查询MySQL,再写入缓存,设定超时时间。
- 写操作:
延时双删
延时双删
也就是:(1)删除缓存;(2)修改数据库;(3)延时后删除缓存。
需要思考一些问题:
1、先删除缓存还是先修改数据库?
答案是两种方式都会遇到问题,都有可能会读到脏数据,具体分析可以看我之前Redis专栏中的文章。
2、为什么要删除两次缓存?
先删除缓存,再修改数据库,还是有可能会读到脏数据,双删可以解决。
3、为什么要延时双删?
因为数据库一般是主从分离的,修改完数据库,要让主节点的数据同步到从节点。
但是因为延时双删的延时时间难以控制,所以还是有读脏数据的风险。
分布式锁(读写锁)
如果一定要避免数据一致性,可以加分布式锁。
但是分布式锁也会影响性能,因此适合读多写少的情况。
当我们读和写的时候都加锁,就会大大提升性能,同时保证强一致性。
共享锁:读读共享,写互斥
排他锁:读写互斥
总结
1、实时性要求不高:用异步方案同步数据(可以想到MQ)
2、实时性要求很高:需要保证数据的强一致,采用Redisson提供的读写锁来保证数据的同步
3、异步方案:允许延时一致的业务,采用异步通知。(MQ中间件:更新数据后,通知缓存删除)
4、强一致性:使用Redisson提供的读写锁
持久化
RDB
RDB(Redis Database Backup file,Redis数据备份文件),也叫Redis数据快照,把内存中的所有数据都记录到磁盘中,当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。
人工备份:
save # Redis主进程执行备份,会阻塞其他命令,不推荐
bgsave # 开启子进程执行备份,推荐
Redis内部有出发RDB的机制,可以配置redis.conf文件:
# 900s内有至少1个key被修改,执行bgsave
save 900 1
RDB的执行原理
bgsave开始时会fork主进程得到子进程,子进程共享
主进程的内存数据,完成fork后读取内存数据并写入RDB文件。
需要注意,当fork出子进程后,不会把物理内存拷贝过去,仅仅是拷贝页表过去,因为页表本身记录了虚拟地址与物理地址的映射关系
。
子进程写新的RDB文件,会替换磁盘中旧的RDB文件,但是如果此时主进程还在进行写操作,可能保存进去的又是脏数据,导致数据不一致。
因此,fork采用的是copy-to-write技术:
1、当主进程执行读操作时,访问共享内存
2、当主进程执行写操作时,会拷贝一份数据,然后再执行写操作
AOF
AOP(追加文件,Append Only File),Redis处理的每一个写命令都会记录在AOF文件,可以看作是命令日志文件。
AOF默认关闭,需要修改redis.conf配置文件来开启AOF:
appendonly yes
appendfilename "appendonly.aof" # AOF文件名
命令记录的频率也可以通过redis.conf配置:
# 每执行一次写,都会记录
appendfsync always
# 写命令执行完先放AOF缓冲区,再每隔1s将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
通过执行bgrewriteaof
命令,可以让AOF文件执行重写功能(可能一个key被修改多次,但是只有最后一次有用),用最少命令达到相同的效果。
例如,set num 123、set name jack、set num 666,执行完bgrewriteaof后变为mset name jack num 666
。
Redis也会在触发阈值时自动去重写AOF文件,在redis.conf中配置:
# 比上次文件增长多少百分比,则触发重写
auto-aof-rewrite-percentage 100
# 体积到了多大触发重写
auto-aof-rewrite-min-size 64mb
数据过期策略
一种常见的问法:Redis的key过期之后,会立即删除吗?这种问题其实就是在问数据过期的策略。
数据过期策略:Redis对数据设置数据的有效时间,数据过期之后,需要从内存中删除,可以按照不同的规则删除,这种删除规则被称为数据的删除策略(数据过期策略)。
在Redis中,有两种数据过期策略:惰性删除、定期删除
。
惰性删除
惰性删除:设置该key过期时间后,我们不去管,当需要该key时,我们再检查其是否过期,若过期,则删除,反之返回该key。
set name wxj 10
get name # 发现name过期,直接删除key
优点:对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查。
缺点:对内存不友好,若一个key已经过期但一直没使用,内存也不释放,造成内存浪费。
定期删除
定期删除:每隔一段时间,对一定的key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中的过期key)。
定期删除的两种模式:
- SLOW模式:定时任务,执行频率默认10hz,每次不超过25ms,通过修改redis.conf的hz选项来调整次数
- FAST模式:执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
优点:可以通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响。另外定期删除也能释放过期键占用的内存。
缺点:难以确定删除执行的时长和频率。
Redis的过期删除策略:惰性删除+定期删除
两种策略进行配合使用。
数据淘汰策略
假如缓存过多,内存被占满,就需要使用数据淘汰策略。
数据淘汰策略:当Redis内存不够用时,此时向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。
Redis只是8种策略来选择要删除的key:
- noeviction:不淘汰任何key,但内存满了不允许写入新数据(默认)
- volatile-ttl:对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
- allkeys-random:对全体key随机淘汰
- volatile-random:对设置了TTL的key随机淘汰
- allkeys-lru:对全体key,基于LRU(最近最少使用)算法进行淘汰
- volatile-lru:对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu:对全体key,基于LFU(最少频率使用)算法进行淘汰
- volatile-lfu:对设置了TTL的key,基于LFU算法进行淘汰
使用建议
1、优先使用allkeys-lru,它把最近最常访问的数据留在了缓存中,若业务有明显的冷热数据区分则非常建议使用(而LFU有缺陷,虽然也统计频率,但是无法展示出最近常用)。
2、业务数据访问频率差别不大,用volatile-random。
3、若业务有置顶的需求,可用volatile-lru,同时置顶数据不设置过期时间,这样就不会被删除了,就会淘汰其他设置过期时间的数据。
4、业务中有短时高频访问的数据,可以用allkeys-lfu或volatile-lfu。
常见面试题
1、数据库中有1000万数据,Redis只能缓存20W,如何保证Redis中的数据都是热点数据?
使用allkeys-lru策略,流下来的都是经常访问的热点数据
2、Redis的内存用完了会发生什么?
主要看数据的淘汰策略,若是默认的,则会直接报错
Redis分布式锁
使用场景
大家应该做过抢券的业务,如果不加锁,可能会出现超卖。对于一个单体项目,我们可以直接加一个锁synchronized来对这部分逻辑进行加锁,单体项目的情况下自然就不会出现超卖。
但若是在集群部署的项目下,synchronized只是针对当前的JVM的,因此不同的服务器端口的synchronized锁彼此并不会影响,依旧会发生超卖问题。
因此我们要对集群增加一个分布式锁,让不同JVM中的线程都可以互斥的进行抢券操作。
实现原理
setnx
setnx表示SET if not exists。
获取锁:
# NX表示互斥,EX表示超时时间
SET lock value NX EX 10
释放锁:
DEL key
为什么要设置超时时间?
如果不设置超时时间,此时若业务超时了,或者服务宕机了,其他的业务需要获取分布式锁就会失败,因此需要设置超时时间。
但Redis实现分布式锁是如何合理控制锁的有效时长的呢?
Redisson的看门狗机制可以实现
Redisson看门狗机制(面试高频)
其实Redisson本身也是对setnx做了很多的优化的,需要掌握加锁和释放锁的流程。
加锁流程:
对业务加锁,若成功,则可以操作Redis。但增加了看门狗(Watch Dog),看门狗会不断的监听持有锁的线程,且每隔
(releaseTime / 3)
的时间做一次续期,其中releaseTime的默认值为30,也就是说每隔10s就会做一个续期(续期就是回归之前的过期时间)
释放锁:
手动释放锁,需要通知一下对应线程的看门狗,让它不用再去做监听了,因为锁已经被删除了
除了这些操作,还有其他的细节,如果此时有其他线程尝试获取锁失败,则会进入while训练,不断尝试获取锁,但需要设定阈值(最大等待时间),这是Redisson看门狗机制中的重试机制
。
注意:加锁、设置过期时间等操作都是基于Lua脚本完成的,以此来保持原子性
。
可重入
方法一获取分布式锁成功,方法一的方法体中调用了方法二,方法二也尝试获取同一个分布式锁,这在Redisson中是可以获取成功的,这也就是Redisson分布式锁的可重入
特性。
这是因为方法一调用方法二,它们其实是用的同一个线程的,而分布式锁正是以线程为单位的,因此是可以获取成功的。
同时Redisson会利用hash结构
来记录线程id
和重入次数
,当同一个线程的某个方法释放了分布式锁,该分布式锁并不会真正的释放,而是将重入次数-1
,当重入次数为0时,该锁会被真正释放。
主从一致性
可以根据一个场景的问题来分析:
主节点是写数据的,从节点是读数据的。若此时进行写操作,主节点就会获取分布式锁并写数据,写完数据后要同步到从节点。但如果还没有同步完成主节点就宕机了,根据Redis的哨兵机制,会选出一个从节点来作为新的主节点。因为主从同步没完成,锁失效了,这样就会发生线程并发问题。
因此要保证主从一致性,就需要使用红锁RedLock:
不能只在一个Redis实例上创建锁,应该是在多个Redis实例上创建锁
(n/2+1)
,避免在一个Redis实例上加锁。
总结
Redisson实现分布式锁是如何合理控制锁的有效时长的?
在Redisson的分布式锁中,提供了一个
WatchDog
(看门狗),一个线程获取锁成功以后,WatchDog会给持有锁的线程续期
(默认10s续期一次)
Redisson是可重入的吗?
是的,多个锁重入需要判断是否为当前线程,在Redis中进行存储的时候使用的
hash结构
,来存储线程信息
和重入次数
Redisson锁能否解决数据的主从一致问题
不可以,可以用
红锁
解决,但性能太低,如果要求强一致性,推荐使用zookeeper
实现的分布式锁