【Redis】Redis缓存应用实战Day12(2026年)
写在前面
Redis作为缓存中间件,是系统架构中不可或缺的一环。但缓存使用不当,反而会带来一系列问题。今天我们来深入探讨缓存三大经典问题:缓存穿透、缓存击穿、缓存雪崩,以及它们的解决方案。
文章目录
- 写在前面
- 一、缓存穿透
- 1.1 什么是缓存穿透
- 1.2 缓存穿透的危害
- 1.3 解决方案一:布隆过滤器
- 1.4 解决方案二:空值缓存
- 1.5 两种方案对比
- 二、缓存击穿
- 2.1 什么是缓存击穿
- 2.2 缓存击穿与穿透的区别
- 2.3 解决方案一:互斥锁
- 2.4 解决方案二:热点数据预热
- 2.5 解决方案三:逻辑过期
- 三、缓存雪崩
- 3.1 什么是缓存雪崩
- 3.2 缓存雪崩的原因
- 3.3 解决方案一:随机过期时间
- 3.4 解决方案二:多级缓存
- 3.5 解决方案三:熔断降级
- 3.6 缓存雪崩解决方案对比
- 四、缓存更新策略
- 4.1 常见更新策略
- 4.2 Cache Aside模式详解
- 4.3 缓存和数据库一致性问题
- 五、踩坑提醒
- 5.1 缓存和数据库一致性陷阱
- 5.2 热点key问题
- 5.3 大key问题
- 六、面试高频考点
- 6.1 如何解决缓存三兄弟(穿透、击穿、雪崩)?
- 6.2 缓存和数据库如何保证一致性?
- 6.3 为什么删除缓存而不是更新缓存?
- 七、参考资料
- 八、互动话题
一、缓存穿透
1.1 什么是缓存穿透
实际场景:黑客恶意查询不存在的数据,如查询id=-1的商品,导致请求直接穿透缓存打到数据库。
缓存穿透示意图:
┌─────────┐ ┌─────────┐ ┌─────────┐ │ 请求 │ → │ 缓存 │ → │ 数据库 │ │(不存在key)│ │ (无数据) │ │ (无数据)│ └─────────┘ └─────────┘ └─────────┘ ↑ │ └──────────────────────────────┘ 每次都穿透到数据库1.2 缓存穿透的危害
| 危害 | 说明 |
|---|---|
| 数据库压力 | 大量请求直接打到数据库 |
| 系统崩溃 | 数据库负载过高导致宕机 |
| 资源浪费 | 无效请求消耗系统资源 |
1.3 解决方案一:布隆过滤器
经验之谈:布隆过滤器是一种空间效率很高的数据结构,可以快速判断元素是否存在于集合中。
布隆过滤器原理:
元素 → 多个哈希函数 → 位数组中多个位置设为1 查询时:所有位置都是1 → 可能存在 有位置是0 → 一定不存在Redis实现布隆过滤器:
# 使用RedisBloom模块# 添加元素BF.ADDusersuser1 BF.ADDusersuser2# 判断元素是否存在BF.EXISTSusersuser1# 返回1表示可能存在BF.EXISTSusersuser999# 返回0表示一定不存在Java代码示例:
// 使用Guava布隆过滤器BloomFilter<String>bloomFilter=BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()),1000000,// 预期元素数量0.01// 误判率);// 添加所有有效keyfor(Stringkey:allValidKeys){bloomFilter.put(key);}// 查询前先判断if(!bloomFilter.mightContain(key)){returnnull;// 一定不存在,直接返回}1.4 解决方案二:空值缓存
踩坑提醒:空值缓存会占用内存,需要设置较短的过期时间,避免内存浪费。
publicObjectgetValue(Stringkey){// 1. 查询缓存Objectvalue=redisTemplate.opsForValue().get(key);// 2. 缓存命中if(value!=null){// 空值标记if("NULL".equals(value)){returnnull;}returnvalue;}// 3. 查询数据库value=database.query(key);// 4. 写入缓存if(value==null){// 空值缓存,过期时间较短redisTemplate.opsForValue().set(key,"NULL",5,TimeUnit.MINUTES);}else{redisTemplate.opsForValue().set(key,value,1,TimeUnit.HOURS);}returnvalue;}1.5 两种方案对比
| 对比项 | 布隆过滤器 | 空值缓存 |
|---|---|---|
| 空间占用 | 小 | 较大 |
| 精确度 | 有误判率 | 精确 |
| 实现复杂度 | 较高 | 简单 |
| 适用场景 | 数据量大、固定集合 | 数据量小、动态变化 |
| 维护成本 | 需要重建过滤器 | 自动过期 |
二、缓存击穿
2.1 什么是缓存击穿
实际场景:某热点商品缓存过期瞬间,大量并发请求同时查询该商品,全部穿透到数据库。
缓存击穿示意图:
┌─────────┐ │ 请求1 │ │ 请求2 │ ┌─────────┐ ┌─────────┐ │ 请求3 │ → │ 缓存 │ → │ 数据库 │ │ ... │ │ (过期) │ │ (压力) │ │ 请求N │ └─────────┘ └─────────┘ └─────────┘ 热点key过期瞬间大量请求2.2 缓存击穿与穿透的区别
| 对比项 | 缓存穿透 | 缓存击穿 |
|---|---|---|
| 数据是否存在 | 不存在 | 存在但过期了 |
| 请求特点 | 恶意请求不存在的key | 热点key过期瞬间大量请求 |
| 影响范围 | 持续影响 | 瞬间影响 |
| 解决方案 | 布隆过滤器、空值缓存 | 互斥锁、热点预热 |
2.3 解决方案一:互斥锁
经验之谈:使用分布式锁保证只有一个线程去查询数据库并更新缓存,其他线程等待或返回旧数据。
publicObjectgetValueWithLock(Stringkey){// 1. 查询缓存Objectvalue=redisTemplate.opsForValue().get(key);if(value!=null){returnvalue;}// 2. 获取分布式锁StringlockKey="lock:"+key;try{// 尝试获取锁,等待时间3秒,锁过期时间10秒Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);if(Boolean.TRUE.equals(locked)){// 获取锁成功,查询数据库value=database.query(key);// 写入缓存if(value!=null){redisTemplate.opsForValue().set(key,value,1,TimeUnit.HOURS);}}else{// 获取锁失败,等待后重试Thread.sleep(100);returngetValueWithLock(key);// 递归重试}}finally{// 释放锁redisTemplate.delete(lockKey);}returnvalue;}2.4 解决方案二:热点数据预热
实际场景:双十一大促前,提前将热点商品数据加载到缓存,并设置较长的过期时间。
@ComponentpublicclassCacheWarmUp{@AutowiredprivateRedisTemplateredisTemplate;@AutowiredprivateProductServiceproductService;// 系统启动时预热@PostConstructpublicvoidwarmUp(){// 获取热点商品列表List<Long>hotProductIds=productService.getHotProductIds();for(Longid:hotProductIds){Productproduct=productService.getById(id);if(product!=null){// 预热缓存,设置较长过期时间Stringkey="product:"+id;redisTemplate.opsForValue().set(key,product,24,TimeUnit.HOURS);}}}}2.5 解决方案三:逻辑过期
经验之谈:不设置TTL,而是在value中存储过期时间,后台异步更新缓存。
@DatapublicclassCacheData<T>{privateTdata;privateLongexpireTime;// 逻辑过期时间}publicObjectgetValueWithLogicalExpire(Stringkey){// 1. 查询缓存Stringjson=redisTemplate.opsForValue().get(key);if(json==null){returnnull;// 直接返回,不查数据库}// 2. 解析数据CacheDatacacheData=JSON.parseObject(json,CacheData.class);// 3. 判断是否过期if(cacheData.getExpireTime()>System.currentTimeMillis()){returncacheData.getData();// 未过期}// 4. 过期了,异步更新CompletableFuture.runAsync(()->{// 获取锁StringlockKey="lock:"+key;Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);if(Boolean.TRUE.equals(locked)){try{// 查询数据库ObjectnewData=database.query(key);// 更新缓存CacheDatanewCacheData=newCacheData();newCacheData.setData(newData);newCacheData.setExpireTime(System.currentTimeMillis()+3600000);redisTemplate.opsForValue().set(key,JSON.toJSONString(newCacheData));}finally{redisTemplate.delete(lockKey);}}});// 5. 返回旧数据returncacheData.getData();}三、缓存雪崩
3.1 什么是缓存雪崩
实际场景:凌晨2点,大量缓存同时过期,瞬间大量请求打到数据库,导致数据库崩溃。
缓存雪崩示意图:
时间轴: ├──────┼──────┼──────┼──────┤ 0:00 1:00 2:00 3:00 4:00 ↑ ↑ ↑ key1 key2 key3 过期 过期 过期 ↓ ↓ ↓ └──────┴──────┘ 同时大量请求打到数据库3.2 缓存雪崩的原因
| 原因 | 说明 |
|---|---|
| 同时过期 | 大量key设置了相同的过期时间 |
| Redis宕机 | 缓存服务不可用 |
| 网络问题 | 缓存服务网络故障 |
3.3 解决方案一:随机过期时间
经验之谈:在基础过期时间上增加随机值,避免大量key同时过期。
publicvoidsetCacheWithRandomExpire(Stringkey,Objectvalue){// 基础过期时间:1小时longbaseExpire=3600;// 随机过期时间:0-600秒longrandomExpire=newRandom().nextInt(600);// 总过期时间longtotalExpire=baseExpire+randomExpire;redisTemplate.opsForValue().set(key,value,totalExpire,TimeUnit.SECONDS);}3.4 解决方案二:多级缓存
实际场景:使用本地缓存+Redis缓存的多级缓存架构,即使Redis不可用,本地缓存还能扛一阵。
请求 → 本地缓存(Caffeine) → Redis缓存 → 数据库 (一级缓存) (二级缓存) (数据源)多级缓存实现:
@ComponentpublicclassMultiLevelCache{@AutowiredprivateRedisTemplateredisTemplate;// 本地缓存privateCache<String,Object>localCache=Caffeine.newBuilder().maximumSize(10000).expireAfterWrite(5,TimeUnit.MINUTES).build();publicObjectget(Stringkey){// 1. 先查本地缓存Objectvalue=localCache.getIfPresent(key);if(value!=null){returnvalue;}// 2. 再查Redis缓存value=redisTemplate.opsForValue().get(key);if(value!=null){// 写入本地缓存localCache.put(key,value);returnvalue;}// 3. 查询数据库value=database.query(key);if(value!=null){// 写入两级缓存redisTemplate.opsForValue().set(key,value,1,TimeUnit.HOURS);localCache.put(key,value);}returnvalue;}}3.5 解决方案三:熔断降级
踩坑提醒:熔断降级是最后的防线,当缓存和数据库都扛不住时,通过限流保护系统。
@ComponentpublicclassCacheService{// 熔断器privateCircuitBreakercircuitBreaker=CircuitBreaker.create("cacheBreaker",CircuitBreakerConfig.custom().failureRateThreshold(50)// 失败率50%触发熔断.waitDurationInOpenState(Duration.ofSeconds(30))// 熔断30秒.build());publicObjectgetWithCircuitBreaker(Stringkey){returncircuitBreaker.executeSupplier(()->{Objectvalue=redisTemplate.opsForValue().get(key);if(value==null){value=database.query(key);if(value!=null){redisTemplate.opsForValue().set(key,value,1,TimeUnit.HOURS);}}returnvalue;},()->{// 降级逻辑:返回默认值returngetDefaultvalue(key);});}}3.6 缓存雪崩解决方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 随机过期时间 | 简单易实现 | 不能完全避免 | 常规场景 |
| 多级缓存 | 性能高、容错强 | 数据一致性复杂 | 高并发场景 |
| 熔断降级 | 保护系统 | 影响用户体验 | 极端情况 |
四、缓存更新策略
4.1 常见更新策略
实际场景:缓存和数据库数据一致性是分布式系统的经典难题,需要根据业务场景选择合适的策略。
| 策略 | 描述 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 先更新DB,再删除缓存 | 较好 | 较好 | 读多写少 |
| Read/Write Through | 由缓存代理更新DB | 好 | 好 | 读写均衡 |
| Write Behind | 只更新缓存,异步更新DB | 差 | 最好 | 写多读少 |
4.2 Cache Aside模式详解
面试高频考点:为什么是删除缓存而不是更新缓存?
删除 vs 更新:
| 对比项 | 删除缓存 | 更新缓存 |
|---|---|---|
| 复杂度 | 低 | 高 |
| 数据一致性 | 较好 | 可能不一致 |
| 性能 | 高(懒加载) | 低(每次写都更新) |
| 并发问题 | 较少 | 较多 |
Cache Aside实现:
publicvoidupdateData(Stringkey,Objectvalue){// 1. 先更新数据库database.update(key,value);// 2. 再删除缓存redisTemplate.delete(key);}4.3 缓存和数据库一致性问题
踩坑提醒:在高并发场景下,即使先更新DB再删除缓存,也可能出现不一致。
问题场景:
线程A: 更新DB → 删除缓存 线程B: 读缓存miss → 查DB(旧数据) → 写缓存 如果线程B在线程A删除缓存前写入,缓存就是旧数据解决方案:延迟双删
publicvoidupdateData(Stringkey,Objectvalue){// 1. 先删除缓存redisTemplate.delete(key);// 2. 更新数据库database.update(key,value);// 3. 延迟后再次删除缓存CompletableFuture.runAsync(()->{try{Thread.sleep(500);// 延迟500msredisTemplate.delete(key);}catch(InterruptedExceptione){log.error("延迟双删失败",e);}});}五、踩坑提醒
5.1 缓存和数据库一致性陷阱
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 先删缓存再更新DB | 并发时可能读到旧数据写入缓存 | 使用延迟双删 |
| 缓存删除失败 | 数据库更新成功但缓存删除失败 | 使用消息队列重试 |
| 并发写问题 | 多线程同时写导致数据错乱 | 使用分布式锁 |
5.2 热点key问题
踩坑提醒:热点key会导致单个Redis节点压力过大,需要特殊处理。
解决方案:
// 方案1:热点key分散String[]keys={"hot:1","hot:2","hot:3"};intindex=newRandom().nextInt(keys.length);Objectvalue=redisTemplate.opsForValue().get(keys[index]);// 方案2:本地缓存// 使用Caffeine等本地缓存框架5.3 大key问题
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 内存占用大 | 单个key占用过多内存 | 拆分大key |
| 网络阻塞 | 传输大key阻塞网络 | 压缩或分片 |
| 过期阻塞 | 删除大key阻塞主线程 | 异步删除 |
六、面试高频考点
6.1 如何解决缓存三兄弟(穿透、击穿、雪崩)?
答案:
缓存穿透:
- 布隆过滤器:过滤不存在的key
- 空值缓存:缓存空值,设置短过期时间
- 参数校验:在入口处过滤非法请求
缓存击穿:
- 互斥锁:只允许一个线程查询数据库
- 热点预热:提前加载热点数据
- 逻辑过期:不设置TTL,后台异步更新
缓存雪崩:
- 随机过期时间:避免同时过期
- 多级缓存:本地缓存+Redis缓存
- 熔断降级:保护系统不被压垮
6.2 缓存和数据库如何保证一致性?
答案:
Cache Aside模式:先更新数据库,再删除缓存
延迟双删:删除缓存 → 更新DB → 延迟后再删除缓存
消息队列重试:删除缓存失败时,通过MQ重试
Binlog订阅:通过Canal订阅MySQL binlog,异步更新缓存
强一致性场景:使用分布式锁或直接查数据库
6.3 为什么删除缓存而不是更新缓存?
答案:
并发安全:删除操作是幂等的,更新可能被覆盖
性能考虑:很多场景下缓存可能根本不会被读取,更新是浪费
数据一致性:更新缓存可能失败,导致数据不一致
懒加载:删除后下次读取时再加载,数据更新鲜
七、参考资料
- Redis官方文档 - 缓存模式
- Cache Aside Pattern详解
八、互动话题
- 你的项目中遇到过缓存穿透、击穿、雪崩吗?是如何解决的?
- 对于强一致性要求的业务,你会如何设计缓存策略?
- 多级缓存的方案在实际应用中有什么坑?
欢迎在评论区分享你的实战经验!
下期预告:Day13我们将学习Redis分布式锁,深入理解SETNX、Redisson和Redlock算法。
