【中间件:Redis】4、Redis缓存实战:穿透/击穿/雪崩的5种解决方案(附代码实现)

在Redis的实战场景中,"缓存三大问题"------穿透、击穿、雪崩是中大厂面试的必问项。面试官不仅会问"什么是缓存穿透",更会追问"怎么解决?、代码怎么实现?、生产环境选哪种方案?"

本文将从"问题本质→解决方案→代码实现→场景选择→踩坑点"五个维度,系统拆解这三大问题,每个方案都附Java实战代码(Spring Boot+Redis),帮你既懂原理又能落地。

一、缓存穿透:查不到的数据"穿透"到数据库

1. 问题定义与危害

定义 :客户端频繁请求"不存在的数据"(如查询ID=-1的用户、不存在的商品ID),由于缓存中没有这些数据,请求会直接穿透到数据库,导致数据库压力骤增,甚至宕机。
本质:缓存只缓存"存在的key",对"不存在的key"无防护,形成"缓存真空"。

2. 解决方案1:缓存空值(简单有效,推荐中小场景)

(1)原理

第一次查询不存在的数据时,不仅返回空结果,还会往缓存中存入一个"空值"(如""null),并设置短期过期时间(避免长期占用内存)。后续请求会直接命中缓存的空值,不再访问数据库。

(2)Java代码实现(Spring Boot)
java 复制代码
@Service
public class UserService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private UserMapper userMapper;

    // 空值过期时间:5分钟(根据业务调整,不宜过长)
    private static final long NULL_VALUE_EXPIRE = 5 * 60 * 1000L;

    public User getUserById(Long id) {
        String key = "user:id:" + id;
        // 1. 先查缓存
        User user = (User) redisTemplate.opsForValue().get(key);
        if (user != null) {
            // 2. 缓存命中:若为null(空值标记),返回null;否则返回用户
            return user instanceof NullValue ? null : user;
        }

        // 3. 缓存未命中:查数据库
        user = userMapper.selectById(id);
        if (user == null) {
            // 4. 数据库也不存在:缓存空值(用自定义NullValue标记,避免与真实null混淆)
            redisTemplate.opsForValue().set(key, new NullValue(), NULL_VALUE_EXPIRE, TimeUnit.MILLISECONDS);
            return null;
        }

        // 5. 数据库存在:缓存真实数据(设置合理过期时间,如1小时)
        redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
        return user;
    }

    // 自定义空值标记类(避免与真实null混淆,防止缓存穿透)
    static class NullValue implements Serializable {}
}
(3)适用场景与踩坑点
  • 适用场景:数据量不大(如十万级)、不存在的key请求频率不高的场景。
  • 踩坑点
    • 空值需设置短期过期时间(如5-10分钟),避免"真实数据新增后,缓存的空值导致查询不到";
    • 用自定义NullValue类标记空值,避免与真实null混淆(RedisTemplate默认不存储null)。

3. 解决方案2:布隆过滤器(大数据量场景,推荐)

(1)原理

布隆过滤器是一种"概率性数据结构",能快速判断"一个元素是否存在于集合中"。提前将所有"存在的key"(如数据库中所有用户ID)存入布隆过滤器,请求先经过过滤器:

  • 若过滤器判断"不存在",直接返回空,不访问缓存和数据库;
  • 若过滤器判断"可能存在"(允许一定误判率),再走"缓存→数据库"流程。
(2)Java代码实现(基于Guava布隆过滤器)
java 复制代码
@Configuration
public class BloomFilterConfig {
    // 预计数据量(如100万用户ID)
    private static final long EXPECTED_INSERTIONS = 1000000;
    // 误判率(推荐0.01-0.001,越小占用内存越大)
    private static final double FPP = 0.01;

    // 初始化用户ID布隆过滤器
    @Bean
    public BloomFilter<Long> userBloomFilter() {
        BloomFilter<Long> filter = BloomFilter.create(
            Funnels.longFunnel(), 
            EXPECTED_INSERTIONS, 
            FPP
        );
        // 从数据库加载所有存在的用户ID,放入过滤器(实际应异步加载)
        List<Long> allUserIds = userMapper.selectAllIds();
        allUserIds.forEach(filter::put);
        return filter;
    }
}

@Service
public class UserService {
    @Autowired
    private BloomFilter<Long> userBloomFilter;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private UserMapper userMapper;

    public User getUserById(Long id) {
        // 1. 先过布隆过滤器:若不存在,直接返回null
        if (!userBloomFilter.mightContain(id)) {
            return null;
        }

        // 2. 过滤器判断可能存在,再查缓存和数据库(同缓存空值方案的后续流程)
        String key = "user:id:" + id;
        User user = (User) redisTemplate.opsForValue().get(key);
        if (user != null) {
            return user instanceof NullValue ? null : user;
        }

        user = userMapper.selectById(id);
        if (user == null) {
            redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
            return null;
        }

        redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
        return user;
    }
}
(3)适用场景与踩坑点
  • 适用场景:数据量大(百万级以上)、不存在的key请求频率极高的场景(如爬虫恶意攻击)。
  • 踩坑点
    • 存在"误判率"(无法完全避免穿透):需配合"缓存空值"兜底;
    • 不支持删除数据:若数据库数据删除,布隆过滤器无法同步删除,需定期重建过滤器(如每天凌晨);
    • 内存占用:100万数据、0.01误判率约占1.5MB,可接受。

二、缓存击穿:热点key失效瞬间,请求全冲库

1. 问题定义与危害

定义 :某个"热点key"(如秒杀商品ID、热门文章ID)缓存突然过期,瞬间大量并发请求未命中缓存,全部穿透到数据库,导致数据库过载。
本质:热点key的"缓存失效时间点"与"高并发请求"重叠,形成"流量尖峰"。

2. 解决方案1:互斥锁(控制并发,推荐通用场景)

(1)原理

缓存失效时,不是所有请求都去查数据库,而是让"第一个请求"获取锁(如Redis的SET NX),去数据库查询并更新缓存;其他请求获取锁失败后,等待一段时间再重试,直到缓存更新完成。

(2)Java代码实现(基于Redis分布式锁)
java 复制代码
@Service
public class GoodsService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private GoodsMapper goodsMapper;

    // 锁过期时间:3秒(需大于数据库查询+缓存更新的耗时)
    private static final long LOCK_EXPIRE = 3 * 1000L;
    // 重试间隔:100毫秒
    private static final long RETRY_INTERVAL = 100L;

    public Goods getGoodsById(Long id) {
        String key = "goods:id:" + id;
        // 1. 先查缓存
        Goods goods = (Goods) redisTemplate.opsForValue().get(key);
        if (goods != null) {
            return goods;
        }

        // 2. 缓存失效:尝试获取锁
        String lockKey = "lock:goods:" + id;
        boolean locked = false;
        try {
            // 2.1 用SET NX获取锁(仅当锁不存在时成功)
            locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_EXPIRE, TimeUnit.MILLISECONDS);
            if (locked) {
                // 2.2 获取锁成功:查数据库
                goods = goodsMapper.selectById(id);
                if (goods == null) {
                    // 数据库不存在:缓存空值(短期)
                    redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
                    return null;
                }
                // 数据库存在:更新缓存(设置合理过期时间,如30分钟)
                redisTemplate.opsForValue().set(key, goods, 30, TimeUnit.MINUTES);
                return goods;
            } else {
                // 2.3 获取锁失败:等待后重试(最多重试5次)
                int retryCount = 0;
                while (retryCount < 5) {
                    Thread.sleep(RETRY_INTERVAL);
                    goods = (Goods) redisTemplate.opsForValue().get(key);
                    if (goods != null) {
                        return goods;
                    }
                    retryCount++;
                }
                // 重试多次仍未获取缓存:返回默认兜底数据(如"系统繁忙")
                return new Goods().setName("系统繁忙,请稍后再试");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            // 3. 释放锁(仅释放自己的锁,避免误删)
            if (locked) {
                redisTemplate.delete(lockKey);
            }
        }
    }
}
(3)适用场景与踩坑点
  • 适用场景:热点key更新频率不高、数据库查询耗时较短的场景(如商品详情)。
  • 踩坑点
    • 锁过期时间需大于"数据库查询+缓存更新"的耗时,避免"锁提前释放,多个线程同时查库";
    • 重试次数和间隔需合理(如5次、100ms),避免线程长时间阻塞;
    • 释放锁需判断"是否是自己的锁"(复杂场景可用Lua脚本,本例简化处理)。

3. 解决方案2:热点key永不过期(彻底避免失效,推荐超高并发场景)

(1)原理

两种实现方式:

  • 物理上不设置过期时间:缓存中的热点key永远不过期;
  • 逻辑上永不过期:设置过期时间,但用异步线程定期(如每隔29分钟)更新过期时间,保证缓存"逻辑上不过期"。

核心是"不让热点key在高并发时失效"。

(2)Java代码实现(异步线程续期)
java 复制代码
@Service
public class SeckillService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private SeckillMapper seckillMapper;

    // 初始化定时线程池(用于更新过期时间)
    private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);

    public Seckill getSeckillById(Long id) {
        String key = "seckill:id:" + id;
        // 1. 先查缓存
        Seckill seckill = (Seckill) redisTemplate.opsForValue().get(key);
        if (seckill != null) {
            return seckill;
        }

        // 2. 缓存未命中:查数据库并初始化缓存(设置30分钟过期)
        seckill = seckillMapper.selectById(id);
        if (seckill == null) {
            return null;
        }
        redisTemplate.opsForValue().set(key, seckill, 30, TimeUnit.MINUTES);

        // 3. 启动异步线程:每隔29分钟更新一次过期时间(逻辑上永不过期)
        scheduler.scheduleAtFixedRate(() -> {
            redisTemplate.expire(key, 30, TimeUnit.MINUTES);
        }, 29, 29, TimeUnit.MINUTES);

        return seckill;
    }

    // 服务关闭时关闭线程池
    @PreDestroy
    public void destroy() {
        scheduler.shutdown();
    }
}
(3)适用场景与踩坑点
  • 适用场景:超高并发的热点key(如秒杀、热门活动),且数据更新频率低(避免缓存与数据库不一致)。
  • 踩坑点
    • 需保证"异步线程池"的稳定性(避免线程泄露);
    • 若数据更新,需主动更新缓存(如发布消息通知缓存更新),否则会出现"缓存脏数据"。

三、缓存雪崩:大量key同时过期,数据库被冲垮

1. 问题定义与危害

定义 :缓存中大量key在同一时间过期,或缓存集群宕机,导致所有请求瞬间落到数据库,数据库直接被压垮。
本质:"缓存失效"或"缓存不可用"与"高并发请求"叠加,形成"流量洪峰"。

2. 解决方案1:过期时间随机化(避免同时过期,基础方案)

(1)原理

给每个key的过期时间加一个"随机值"(如30分钟 ± 5分钟),避免大量key在同一时间点过期,将过期时间分散到不同时间段。

(2)Java代码实现(封装Redis工具类)
java 复制代码
@Component
public class RedisCacheUtil {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // 基础过期时间(如30分钟)
    private static final long BASE_EXPIRE = 30 * 60 * 1000L;
    // 随机值范围(±5分钟)
    private static final long RANDOM_RANGE = 5 * 60 * 1000L;
    private static final Random random = new Random();

    // 存储缓存并添加随机过期时间
    public void setWithRandomExpire(String key, Object value) {
        // 计算随机过期时间:BASE_EXPIRE ± RANDOM_RANGE
        long expire = BASE_EXPIRE + (random.nextLong() % (2 * RANDOM_RANGE + 1) - RANDOM_RANGE);
        redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MILLISECONDS);
    }
}

// 使用示例
@Service
public class ProductService {
    @Autowired
    private RedisCacheUtil redisCacheUtil;

    public void saveProduct(Product product) {
        // 存储缓存时自动添加随机过期时间
        redisCacheUtil.setWithRandomExpire("product:id:" + product.getId(), product);
    }
}

3. 解决方案2:缓存集群高可用(避免缓存不可用,核心方案)

(1)原理

通过"主从复制+哨兵"或"Redis Cluster"部署缓存集群,避免单节点宕机导致整个缓存不可用:

  • 主从复制:主节点处理写请求,从节点同步数据并处理读请求,主节点宕机后从节点可切换为主;
  • 哨兵:监控主从节点健康状态,自动完成故障转移(主节点宕机后选新主);
  • Redis Cluster:分片存储数据,支持多主多从,单个节点宕机不影响整体可用。
(2)核心配置(Redis Cluster示例)
yaml 复制代码
# Spring Boot配置Redis Cluster
spring:
  redis:
    cluster:
      nodes:
        - 192.168.1.101:6379
        - 192.168.1.102:6379
        - 192.168.1.103:6379
        - 192.168.1.104:6379
        - 192.168.1.105:6379
        - 192.168.1.106:6379
      max-redirects: 3  # 最大重定向次数

4. 解决方案3:服务熔断降级(保护数据库,兜底方案)

(1)原理

当数据库压力过大(如QPS超过阈值),通过熔断工具(如Sentinel、Resilience4j)暂时"熔断"缓存到数据库的请求,返回降级数据(如"系统繁忙,请稍后再试"),避免数据库被压垮。

(2)Java代码实现(基于Sentinel)
java 复制代码
@Service
public class OrderService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private OrderMapper orderMapper;

    // 用Sentinel注解设置熔断规则:QPS>1000时降级
    @SentinelResource(
        value = "getOrderById",
        blockHandler = "getOrderByIdBlockHandler"  // 熔断时执行的方法
    )
    public Order getOrderById(Long id) {
        String key = "order:id:" + id;
        Order order = (Order) redisTemplate.opsForValue().get(key);
        if (order != null) {
            return order;
        }

        // 缓存未命中:查数据库(熔断时不会执行到这里)
        order = orderMapper.selectById(id);
        if (order != null) {
            redisTemplate.opsForValue().set(key, order, 1, TimeUnit.HOURS);
        }
        return order;
    }

    // 熔断降级处理方法(参数和返回值需与原方法一致)
    public Order getOrderByIdBlockHandler(Long id, BlockException e) {
        return new Order().setId(id).setMessage("系统繁忙,请稍后再试");
    }
}
(3)Sentinel控制台配置

在Sentinel控制台为getOrderById资源设置规则:

  • 阈值类型:QPS;
  • 单机阈值:1000;
  • 流控模式:直接;
  • 流控效果:快速失败(直接返回降级数据)。

四、场景化选择:不同场景怎么选方案?

问题类型 场景特点 推荐方案
缓存穿透 数据量小(<10万)、无效请求少 缓存空值
缓存穿透 数据量大(>100万)、无效请求多 布隆过滤器+缓存空值(兜底)
缓存击穿 热点key更新频率低、并发中等 互斥锁
缓存击穿 热点key超高并发(如秒杀) 热点key永不过期+异步更新
缓存雪崩 预防key同时过期 过期时间随机化
缓存雪崩 预防缓存集群不可用 Redis Cluster(主从+哨兵)
缓存雪崩 数据库压力过大时兜底 服务熔断降级(Sentinel)

五、面试高频踩坑题&标准答案

  1. 问:缓存空值会导致什么问题?如何避免?
    答:问题:若真实数据新增,缓存的空值会导致"查不到新数据"。避免:设置短期过期时间(如5分钟),或新增数据时主动删除缓存的空值。
  2. 问:布隆过滤器为什么不能删除数据?
    答:布隆过滤器通过"多个哈希函数映射到位数组"实现,删除一个元素会影响其他元素的映射结果(可能导致误判"不存在"),因此不支持删除。解决方案:定期重建过滤器。
  3. 问:互斥锁的过期时间设置过短会怎样?
    答:若锁过期时间小于"数据库查询+缓存更新"的耗时,会导致"锁提前释放,多个线程同时查库",重新引发缓存击穿。需根据实际耗时设置(如3-5秒),并预留冗余。
  4. 问:过期时间随机化的随机范围怎么定?
    答:随机范围=基础过期时间的10%-20%(如基础1小时,随机±6-12分钟),范围太小仍可能集中过期,太大可能导致缓存数据过期时间过长(脏数据)。

六、总结与下一篇预告

缓存三大问题的核心是"流量控制"和"风险隔离":

  • 穿透:用"缓存空值"或"布隆过滤器"拦截无效请求;
  • 击穿:用"互斥锁"或"永不过期"控制热点key的并发流量;
  • 雪崩:用"随机过期""集群高可用""熔断降级"分散风险。

掌握这些方案的代码实现和场景选择,就能应对中大厂的实战面试题。

下一篇将聚焦"Redis分布式锁"------从基础实现到Redisson高级版的演进,包括锁续期、可重入性、集群场景优化等核心考点,帮你彻底搞懂分布式锁的实战落地,敬请关注。

如果觉得本文有用,欢迎收藏+转发,后续会持续更新Redis面试核心系列,帮你系统攻克Redis考点~

相关推荐
爱吃烤鸡翅的酸菜鱼3 小时前
【Java】基于策略模式 + 工厂模式多设计模式下:重构租房系统核心之城市房源列表缓存与高性能筛选
java·redis·后端·缓存·设计模式·重构·策略模式
milanyangbo3 小时前
从局部性原理到一致性模型:深入剖析缓存设计的核心权衡
开发语言·后端·缓存·架构
pipip.5 小时前
Redis vs MongoDB:内存字典与文档库对决
数据库·redis·缓存
信仰_27399324317 小时前
RedisCluster客户端路由智能缓存
java·spring·缓存
兰雪簪轩17 小时前
仓颉语言内存布局优化技巧:从字节对齐到缓存友好的深度实践
java·spring·缓存
2401_8370885017 小时前
解释 StringRedisTemplate 类和对象的作用与关系
redis
陈果然DeepVersion18 小时前
Java大厂面试真题:从Spring Boot到AI微服务的三轮技术拷问(一)
java·spring boot·redis·微服务·kafka·面试题·oauth2
漠然&&19 小时前
实战案例:用 Guava ImmutableList 优化缓存查询系统,解决多线程数据篡改与内存浪费问题
java·开发语言·缓存·guava
IT小哥哥呀20 小时前
MyBatis 性能优化指南:Mapper 映射、缓存与批量操作实战
缓存·性能优化·mybatis·数据库优化·批量插入·分布式系统·sql性能