1.Redis 缓存穿透问题
缓存穿透:当请求的数据在缓存和数据库中不存在时,该请求就跳出我们使用缓存的架构(先从缓存找,再从数据库查找、这样就导致了一直去数据库中找),因为这个数据缓存中永远也不会存在。导致后续所有的这个请求(被恶意的人发现后)都会直接请求数据库。恶意用户一直发送该请求会导致数据库服务宕机。
解决方法常用的两种
一:缓存空数据,二,使用布隆过滤器进行校验。
缓存空数据
在数据库查询到不存在的数据时,对该数据进行缓存为空(可以设置稍短的3~5分钟的TTL),之后相同的请求,就会在缓存中查到,而不去请求数据库。
代码案列
java
/**
* 查询商户信息
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
//查询缓存
String string = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);
//hutool 工具类 符合条件"adc" 不符合条件"",null, "/t/n"
if (StrUtil.isNotBlank(string)){
Shop shop = JSONUtil.toBean(string, Shop.class);
return Result.ok(shop);
}
//若是 " " 上面已经判断了不是"" 不是null ,
if(string != null){
return Result.fail("商户不存在");
}
// 缓存不存在 查数据库
Shop shop = getById(id);
if (shop ==null) {
//将空值写入缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("商户不存在");
}
//写入缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+ id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
优点
- 实现简单
缺点
- 缓存空值,会占用redis的内存空间(可以设置过期时间),可能会导致短期数据不一致问题(除非在新增数据的时候,删除redis中的数据)。
布隆过滤器
扩展
其实不仅仅是这两种解决缓存穿透的方案。
此外还可以
- 增强id的复杂性,避免id被猜到规律。
- 加强用户权限校验
- 做好热点数据限流。
此外,还应该做好数据的校验,对于一些不符合业务逻辑数据的请求直接拦截掉,不在请求数据库。
还可以采用对接口进行限流。甚至黑名单封禁。
2.Redis的哨兵模式
为了提高Redis的性能搭建主从集群后,当主节点出现问题,Redis服务就不可以进行写操作,服务就不可用,Redis提供了哨兵机制,来实现主从集群的自动故障恢复。
哨兵的结构和作用
- 监控:Sentinel 会不断检查您的master和slave是否按预期工作。
- 具体来说就是Sentinel 一直发送ping,接收pang,说明该节点正常可用,反之就是主观下线,当多个Sentinel (一般是一半哨兵监控redis节点为不可用)检测一个redis节点都说明该节点不可用后,该节点是客观下线(服务不可用)。
- 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主。
- 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端。

补充
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
哨兵选主规则
首先判断主与从节点断开时间长短,如超过指定值就排该从节点
然后判断从节点的slave-priority值,越小优先级越高
如果slave-prority一样,则判断slave节点的offset值,越大优先级越高
最后是判断slave节点的运行id大小,越小优先级越高。
脑裂问题
当哨兵网络与Redis主节不在同一个网络下,监控就会出问题,但是Redis主节点并没有问题,服务仍在Redis主节点写,由于网络问题哨兵通过监控认为Redis主节点出现了问题,就会在从节点选一个做为主节点,这样就出现了两个主节点,这就是脑裂问题,当网络原因回复后,原来的主节点为变成从节点,以新的主节点为主,原来的主节点会同步新的主节点信息,就会导致数据丢失。
解决方法 redis.config
shell
min-replicas-to-write 1 表示最少的salve节点为1个
min-replicas-max-lag 5 表示数据复制和同步的延迟不能超过5秒
3.Redis 主从复制,同步流程
单节点的Redis服务并不可靠,并发有上限。
- 如果服务器发生了宕机,由于数据恢复是需要点时间,那么这个期间是无法服务新的请求的;
- 如果这台服务器的硬盘出现了故障,可能数据就都丢失了。为了提高Redis 服务的可靠性,以及高性能,采用集群模式------主从复制。在主节点进行写操作,在从节点进行读操作。
具体的主从Redis节点的同步流程是这样子,分为首次同步,和增量同步。
首次同步,也就是全量同步


增量同步
- Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
- offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
4.Redis持久化策略
Redis是内存服务,但是提供了两个持久化策略AOF,RDB来持久化Redis的数据。
AOF 日志文件
Redis 每执行一条写操作命令成功后,就把该命令以追加的方式写入到一个文件里,然后重启Redis 的时候,先去读取这个文件里的命令,并且执行它,这不就相当于恢复了缓存数据了


在Redis中AOF持久化功能默认是不开启的,在redis.config文件中设置


AOF的记录命令的频率可以通过redis.config文件来配置
shell
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
shell
# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
RDB
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据.
Redis 服务默认开启。用户可以手动备份
shell
redis -cli
save # 由Redis主进程来执行RDB,会阻塞所有的命令
bgsave # 开启子进程执行RDB,避免主进程收到影响
当然Redis内部有自动触发RDB的机制,在redis.config中
plain
# 900秒内,如果至少有1个key被修改,则执行bgsave
save 900 1
save 300 10
save 60 10000
这里提一点,Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。 所以可以认为,执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更 多。 通常可能设置至少 5 分钟才保存一次快照,这时如果 Redis 出现宕机等情况,则意味着最多可能丢失 5 分钟数据。 这就是 RDB 快照的缺点,在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多,因为 RDB 快照是全量快照的方式,因此执行的频率不能 太频繁,否则会影响 Redis 性能,而 AOF 日志可以以秒级的方式记录操作命令,所以丢失的数据就相对更少。
二者比较
这两种技术都会用各用一个日志文件来记录信息,但是记录的内容是不同的。
- AOF 文件的内容是操作命令;
- RDB 文件的内容是二进制数据。
RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作 命令的步骤才能恢复数据
5.Redis与数据库数据一致性问题
为了提高查询效率引入了redis作为缓存,但是出现了新的问题就是,缓存中的数据和数据库中的数据不一致问题。
解决数据不一致问题的方法,最简单的就是对缓存数据设置较短的过期时间,在过期时间后,会从数据库查询新的数据更新缓存,但是这种被动的等待过期时间,一致性是不符合大部分应用场景的。
业内的解决方案
-
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案。
-
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理。
-
Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致。
通常使用第一种方案,在更新数据库的同时更新缓存(删除缓存)。
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新缓存动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来。
我们需要考虑一下几点
- 在数据库更新时,缓存是更新还是删除?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多。
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存。√
- 怎么确保数据库更新,缓存也更新(删除),
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库?
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
先操作数据库在删除缓存,理论上也会出现问题,线程1查询在缓存中没有的数据,就会查询数据库将数据库查到的age =20,写入缓存,但在这时还未写入缓存,线程2,操作数据库age=21,删除缓存。此时线程1继续写入缓存age=20,又会出现不一致现象。但是在实际中并不太可能发生,因为写入缓存的时间是极快的。
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存
6.Redis 缓存击穿
缓存击穿是指在高并发的情况下,当某个热点数据的缓存突然失效 (过期或被删除)并且缓存重建业务较复杂,大量请求直接穿透到后端数据库,导致数据库负载过高,甚至崩溃的问题。由于并发用户特别多,同时读缓存没读到数据,又去数据库中取数据,引起数据库压力瞬间增大。
解决方法:互斥锁构建缓存和逻辑过期时间
互斥锁构建缓存
在热点key失效后,加锁,确保只有一个线程查询数据库并构建缓存。其他的线程等待并重试在缓存中取值。
优点
- 一致性高
缺点
- 由于使用了锁,线程等待,性能低,还可以能出现死锁。
代码实现
java
/**
* 获取锁
* @param key
* @return
*/
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 20, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
private void unlock(String key){
stringRedisTemplate.delete(key);
}
/**
* 查询商户信息 缓存击穿互斥锁
* @param id
* @return
*/
public Shop queryWithMutex(Long id){
String shopKey = CACHE_SHOP_KEY+ id;
// 1. 从redis中查询店铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
//2.判断是否命中缓存 isnotblank false: "" or "/t/n" or "null"
if(StrUtil.isNotBlank(shopJson)){
// 3.若命中则返回信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//数据穿透判空 不是null 就是空串 ""
if (shopJson != null){
//返回错误信息
// return Result.fail("没有该商户信息(缓存)");
return null;
}
//4.没有命中缓存,查数据库
//todo :解决缓存击穿 不能直接查数据库。 利用互斥锁解决
/**
* 实现缓存重建
* 1. 获取互斥锁
* 2. 判断是否成功
* 3. 失败就休眠重试
* 4.成功 查数据库
* 5 数据库存在该数据写入缓存
* 6 不存在返回错误信息并写入缓存""
* 7 释放锁
*
*/
//获取互斥锁 失败 休眠重试
String lockKey = "lock:shop" + id;
Shop shop=null;
try {
boolean isLock = tryLock(lockKey);
//获取锁失败
if (!isLock) {
System.out.println("获取锁失败,重试");
Thread.sleep(50);
return queryWithMutex(id);//递归 重试
}
// 获取锁成功,再次检测缓存是否存在,存在就无需构建缓存,因为可能有的线程刚构建好缓存并释放锁,其他线程获取了锁
//检测缓存是否存在 存在
shopJson = stringRedisTemplate.opsForValue().get(shopKey);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
if (shopJson !=null){
return null;
}
// 缓存不存在
// 查数据库
shop = super.getById(id);
Thread.sleep(200);//模拟你测试环境 热点key失效模拟重建延迟
if (shop == null){
//没有该商户信息
stringRedisTemplate.opsForValue().set(shopKey,"",CACHE_NULL_TTL,TimeUnit.SECONDS);
return null;
}
//有该商户信息
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+ id, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
return shop;
}
逻辑过期时间
对热点key不设置过期时间,仅仅添加个expire 字段。
当使用该缓存时,根据过期字段判断是否更新缓存,若在期限内就直接使用改缓存,若不在期限内就更新缓存。
更新缓存的具体细节,利用锁确保一个线程重构缓存,防止数据库压力过大。在获得锁后,异步执行,新开线程执行重构缓存,同时原线程直接使用已经过期的数据。在此期间其他线程也发现缓存逻辑过期了,也会获得锁,但是获取锁失败,那就使用原来的老数据。
优点
- 性能高
缺点
- 数据不一致
代码实现
对类添加一个过期字段,为了满足开闭原则,可以自定义个新的类继承原来的类并添加expire字段,不过推荐如下写法
自定义个逻辑过期类,所有的逻辑过期类都可以使用(Object data 存原来的类)。
java
/**
* 逻辑过期类
*/
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
数据预热
java
/**
* 添加逻辑过期时间
* @param id
* @param expireSeconds
*/
public void savaShop2Redis(Long id ,Long expireSeconds){
// 查询店铺数据
Shop shop = getById(id);
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
redisData.setData(shop);
//写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
正式代码
java
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
7.内存淘汰策略(Redis内存满了怎么办)
Redis中,在配置文件有设置maxmemory
大小,当超过这个大小,Redis回触发内存淘汰机制,默认的淘汰策略就是noeviction
Redis服务中提供的内存淘汰策略有八种
noeviction
:它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。volatile-ttl
:优先淘汰更早过期的键值。allkeys-random
: 对所有key随机删除。volatile-random
:对设置ttl的key随机删除allkeys-lru
:对所有最近最久使用的key随机删除volatile-lru
: 对设置ttl并且最近最久使用的key随机删除allkeys-lfu
: 所有使用最近最少使用的key随机删除volatile-lfu
设置ttl的并且最近最少使用的key随机删除
8.Redis 缓存雪崩
缓存雪崩出现的原因是同一时间内大量的key同时失效或者Redis服务宕机,导致所有的请求都到数据库,数据库压力过大宕机。
根据产生雪崩的原因进行分析
key同时失效导致的雪崩
我们在做缓存预热时和添加缓存时,设置有效期的同时,额外的增加(1~3)的随机过期时间。
同时当key过期后,构建缓存,利用互斥锁构建缓存,防止数据库压力过大。
Redis服务宕机
利用Redis集群提高服务的可用性。
- 哨兵模式
- 集群模式
其他
给业务添加多级缓存, 如Guava或者Caffeine.
使用服务熔断或请求限流机制
我们可以启动服务熔断 机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。
但是这样在Redis服务宕机期间所有的业务都不可用,为了减少对业务的影响,可以启用请求限流机制,只将少部分请求发送到数据库,
更多的请求就只能拒绝服务,等Redis服务正常后并缓存预热完成在解除请求限流机制。
9.Redis 数据过期删除策略
Redis 对 key 设置过期时间后,需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。
Redis的过期策略:惰性删除和定期删除相配合使用。
惰性删除
在设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。
- 优点 :对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查
- 缺点 :对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放
定期删除
redis中的一个定时任务(100ms)执行一次,扫描设置了过期时间的键并判断是否过期。
具体细节:
redis并不会一次性扫描所有的设置过期时间的键,因为这样会浪费大量的过cpu资源,它会每次扫描时限制扫扫秒时间和数量,以免性能过大对redis正常的使用产生影响。
默认的话,每次获取20个key判断是否过期,如果过期的key占比超过25%,则继续拉20个,如果小于25%则停止。还有一次删除时间不能超过25ms,如果发现占比超过25%,就要判断目前是否花费了25ms,如果到时间也会结束。
定期清理有两种模式:
- SLOW模式是定时任务,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf 的hz 选项来调整这个次数
- FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms。
优缺点:
- 优点:能有效释放过期键占用的内存,可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。
- 缺点:难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。
可以看到上面两种各自的优点,所以Redis使用惰性删除和定期删除两种策略相互使用,以求在合理的使用cpu和避免使用内存浪费之前取平衡。
10.Redis 的脑裂问题
Redis脑裂(Split-Brain)是指在主从集群中,由于网络分区导致出现多个主节点同时接受写请求的情况,造成数据不一致的严重问题。
典型场景:主节点与部分从节点和哨兵网络断开,认为主节点客观下线,哨兵集群选举出新主节点,原主节点未真正下线,继续接受写请求,网络恢复后出现两个"主节点"。
脑裂问题的危害
数据不一致:两个主节点同时接受不同客户端的写入,相同key在不同节点有不同值
数据丢失:网络恢复后,旧主节点会被降级为从节点,其上的新写入数据会被清空(重新同步新主节点数据)
系统混乱:客户端可能连接到不同主节点
脑裂问题的主要原因
网络分区:主节点与哨兵/从节点间网络中断,但主节点与部分客户端连接仍保持
哨兵配置不当:quorum值设置过小,down-after-milliseconds时间过短
缺乏防护机制:未设置min-slaves参数,客户端未实现写失败处理
解决方案
- Redis服务端配置
关键参数配置:
shell
主节点必须有至少1个从节点才能写入
min-slaves-to-write 1
从节点延迟不超过10秒
min-slaves-max-lag 10
哨兵至少需要2个节点认为主节点不可用
sentinel monitor mymaster 127.0.0.1 6379 2
主节点失联30秒后才触发故障转移
sentinel down-after-milliseconds mymaster 30000
- 架构设计优化
多机房部署:哨兵和从节点分布在不同的物理机房,避免单机房故障导致误判
网络冗余:主节点与哨兵间多条网络路径,使用心跳检测+冗余网络
更多更新的知识:Redis 面试题
如果有用请点赞收藏关注。