【Redis】Redis缓存应用实战Day12(2026年)

写在前面

Redis作为缓存中间件,是系统架构中不可或缺的一环。但缓存使用不当,反而会带来一系列问题。今天我们来深入探讨缓存三大经典问题:缓存穿透、缓存击穿、缓存雪崩,以及它们的解决方案。

文章目录

    • 写在前面
    • 一、缓存穿透
      • [1.1 什么是缓存穿透](#1.1 什么是缓存穿透)
      • [1.2 缓存穿透的危害](#1.2 缓存穿透的危害)
      • [1.3 解决方案一:布隆过滤器](#1.3 解决方案一:布隆过滤器)
      • [1.4 解决方案二:空值缓存](#1.4 解决方案二:空值缓存)
      • [1.5 两种方案对比](#1.5 两种方案对比)
    • 二、缓存击穿
      • [2.1 什么是缓存击穿](#2.1 什么是缓存击穿)
      • [2.2 缓存击穿与穿透的区别](#2.2 缓存击穿与穿透的区别)
      • [2.3 解决方案一:互斥锁](#2.3 解决方案一:互斥锁)
      • [2.4 解决方案二:热点数据预热](#2.4 解决方案二:热点数据预热)
      • [2.5 解决方案三:逻辑过期](#2.5 解决方案三:逻辑过期)
    • 三、缓存雪崩
      • [3.1 什么是缓存雪崩](#3.1 什么是缓存雪崩)
      • [3.2 缓存雪崩的原因](#3.2 缓存雪崩的原因)
      • [3.3 解决方案一:随机过期时间](#3.3 解决方案一:随机过期时间)
      • [3.4 解决方案二:多级缓存](#3.4 解决方案二:多级缓存)
      • [3.5 解决方案三:熔断降级](#3.5 解决方案三:熔断降级)
      • [3.6 缓存雪崩解决方案对比](#3.6 缓存雪崩解决方案对比)
    • 四、缓存更新策略
      • [4.1 常见更新策略](#4.1 常见更新策略)
      • [4.2 Cache Aside模式详解](#4.2 Cache Aside模式详解)
      • [4.3 缓存和数据库一致性问题](#4.3 缓存和数据库一致性问题)
    • 五、踩坑提醒
      • [5.1 缓存和数据库一致性陷阱](#5.1 缓存和数据库一致性陷阱)
      • [5.2 热点key问题](#5.2 热点key问题)
      • [5.3 大key问题](#5.3 大key问题)
    • 六、面试高频考点
      • [6.1 如何解决缓存三兄弟(穿透、击穿、雪崩)?](#6.1 如何解决缓存三兄弟(穿透、击穿、雪崩)?)
      • [6.2 缓存和数据库如何保证一致性?](#6.2 缓存和数据库如何保证一致性?)
      • [6.3 为什么删除缓存而不是更新缓存?](#6.3 为什么删除缓存而不是更新缓存?)
    • 七、参考资料
    • 八、互动话题

一、缓存穿透

1.1 什么是缓存穿透

实际场景:黑客恶意查询不存在的数据,如查询id=-1的商品,导致请求直接穿透缓存打到数据库。

缓存穿透示意图

复制代码
┌─────────┐    ┌─────────┐    ┌─────────┐
│  请求   │ →  │  缓存   │ →  │ 数据库  │
│(不存在key)│   │ (无数据) │    │ (无数据)│
└─────────┘    └─────────┘    └─────────┘
     ↑                              │
     └──────────────────────────────┘
           每次都穿透到数据库

1.2 缓存穿透的危害

危害 说明
数据库压力 大量请求直接打到数据库
系统崩溃 数据库负载过高导致宕机
资源浪费 无效请求消耗系统资源

1.3 解决方案一:布隆过滤器

经验之谈:布隆过滤器是一种空间效率很高的数据结构,可以快速判断元素是否存在于集合中。

布隆过滤器原理

复制代码
元素 → 多个哈希函数 → 位数组中多个位置设为1
查询时:所有位置都是1 → 可能存在
        有位置是0 → 一定不存在

Redis实现布隆过滤器

shell 复制代码
# 使用RedisBloom模块
# 添加元素
BF.ADD users user1
BF.ADD users user2

# 判断元素是否存在
BF.EXISTS users user1
# 返回1表示可能存在
BF.EXISTS users user999
# 返回0表示一定不存在

Java代码示例

java 复制代码
// 使用Guava布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    1000000,  // 预期元素数量
    0.01      // 误判率
);

// 添加所有有效key
for (String key : allValidKeys) {
    bloomFilter.put(key);
}

// 查询前先判断
if (!bloomFilter.mightContain(key)) {
    return null;  // 一定不存在,直接返回
}

1.4 解决方案二:空值缓存

踩坑提醒:空值缓存会占用内存,需要设置较短的过期时间,避免内存浪费。

java 复制代码
public Object getValue(String key) {
    // 1. 查询缓存
    Object value = redisTemplate.opsForValue().get(key);
    
    // 2. 缓存命中
    if (value != null) {
        // 空值标记
        if ("NULL".equals(value)) {
            return null;
        }
        return value;
    }
    
    // 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);
    }
    
    return value;
}

1.5 两种方案对比

对比项 布隆过滤器 空值缓存
空间占用 较大
精确度 有误判率 精确
实现复杂度 较高 简单
适用场景 数据量大、固定集合 数据量小、动态变化
维护成本 需要重建过滤器 自动过期

二、缓存击穿

2.1 什么是缓存击穿

实际场景:某热点商品缓存过期瞬间,大量并发请求同时查询该商品,全部穿透到数据库。

缓存击穿示意图

复制代码
                    ┌─────────┐
                    │  请求1  │
                    │  请求2  │    ┌─────────┐    ┌─────────┐
                    │  请求3  │ →  │  缓存   │ →  │ 数据库  │
                    │  ...   │    │ (过期)  │    │ (压力)  │
                    │  请求N  │    └─────────┘    └─────────┘
                    └─────────┘
                    热点key过期瞬间大量请求

2.2 缓存击穿与穿透的区别

对比项 缓存穿透 缓存击穿
数据是否存在 不存在 存在但过期了
请求特点 恶意请求不存在的key 热点key过期瞬间大量请求
影响范围 持续影响 瞬间影响
解决方案 布隆过滤器、空值缓存 互斥锁、热点预热

2.3 解决方案一:互斥锁

经验之谈:使用分布式锁保证只有一个线程去查询数据库并更新缓存,其他线程等待或返回旧数据。

java 复制代码
public Object getValueWithLock(String key) {
    // 1. 查询缓存
    Object value = redisTemplate.opsForValue().get(key);
    if (value != null) {
        return value;
    }
    
    // 2. 获取分布式锁
    String lockKey = "lock:" + key;
    try {
        // 尝试获取锁,等待时间3秒,锁过期时间10秒
        Boolean locked = 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);
            return getValueWithLock(key);  // 递归重试
        }
    } finally {
        // 释放锁
        redisTemplate.delete(lockKey);
    }
    
    return value;
}

2.4 解决方案二:热点数据预热

实际场景:双十一大促前,提前将热点商品数据加载到缓存,并设置较长的过期时间。

java 复制代码
@Component
public class CacheWarmUp {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    @Autowired
    private ProductService productService;
    
    // 系统启动时预热
    @PostConstruct
    public void warmUp() {
        // 获取热点商品列表
        List<Long> hotProductIds = productService.getHotProductIds();
        
        for (Long id : hotProductIds) {
            Product product = productService.getById(id);
            if (product != null) {
                // 预热缓存,设置较长过期时间
                String key = "product:" + id;
                redisTemplate.opsForValue().set(key, product, 24, TimeUnit.HOURS);
            }
        }
    }
}

2.5 解决方案三:逻辑过期

经验之谈:不设置TTL,而是在value中存储过期时间,后台异步更新缓存。

java 复制代码
@Data
public class CacheData<T> {
    private T data;
    private Long expireTime;  // 逻辑过期时间
}

public Object getValueWithLogicalExpire(String key) {
    // 1. 查询缓存
    String json = redisTemplate.opsForValue().get(key);
    if (json == null) {
        return null;  // 直接返回,不查数据库
    }
    
    // 2. 解析数据
    CacheData cacheData = JSON.parseObject(json, CacheData.class);
    
    // 3. 判断是否过期
    if (cacheData.getExpireTime() > System.currentTimeMillis()) {
        return cacheData.getData();  // 未过期
    }
    
    // 4. 过期了,异步更新
    CompletableFuture.runAsync(() -> {
        // 获取锁
        String lockKey = "lock:" + key;
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(locked)) {
            try {
                // 查询数据库
                Object newData = database.query(key);
                
                // 更新缓存
                CacheData newCacheData = new CacheData();
                newCacheData.setData(newData);
                newCacheData.setExpireTime(System.currentTimeMillis() + 3600000);
                redisTemplate.opsForValue().set(key, JSON.toJSONString(newCacheData));
            } finally {
                redisTemplate.delete(lockKey);
            }
        }
    });
    
    // 5. 返回旧数据
    return cacheData.getData();
}

三、缓存雪崩

3.1 什么是缓存雪崩

实际场景:凌晨2点,大量缓存同时过期,瞬间大量请求打到数据库,导致数据库崩溃。

缓存雪崩示意图

复制代码
时间轴:
├──────┼──────┼──────┼──────┤
0:00   1:00   2:00   3:00   4:00
       ↑      ↑      ↑
    key1   key2   key3
    过期   过期    过期
       ↓      ↓      ↓
       └──────┴──────┘
         同时大量请求打到数据库

3.2 缓存雪崩的原因

原因 说明
同时过期 大量key设置了相同的过期时间
Redis宕机 缓存服务不可用
网络问题 缓存服务网络故障

3.3 解决方案一:随机过期时间

经验之谈:在基础过期时间上增加随机值,避免大量key同时过期。

java 复制代码
public void setCacheWithRandomExpire(String key, Object value) {
    // 基础过期时间:1小时
    long baseExpire = 3600;
    // 随机过期时间:0-600秒
    long randomExpire = new Random().nextInt(600);
    // 总过期时间
    long totalExpire = baseExpire + randomExpire;
    
    redisTemplate.opsForValue().set(key, value, totalExpire, TimeUnit.SECONDS);
}

3.4 解决方案二:多级缓存

实际场景:使用本地缓存+Redis缓存的多级缓存架构,即使Redis不可用,本地缓存还能扛一阵。

复制代码
请求 → 本地缓存(Caffeine) → Redis缓存 → 数据库
         (一级缓存)          (二级缓存)   (数据源)

多级缓存实现

java 复制代码
@Component
public class MultiLevelCache {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    // 本地缓存
    private Cache<String, Object> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();
    
    public Object get(String key) {
        // 1. 先查本地缓存
        Object value = localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
        
        // 2. 再查Redis缓存
        value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            // 写入本地缓存
            localCache.put(key, value);
            return value;
        }
        
        // 3. 查询数据库
        value = database.query(key);
        if (value != null) {
            // 写入两级缓存
            redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
            localCache.put(key, value);
        }
        
        return value;
    }
}

3.5 解决方案三:熔断降级

踩坑提醒:熔断降级是最后的防线,当缓存和数据库都扛不住时,通过限流保护系统。

java 复制代码
@Component
public class CacheService {
    
    // 熔断器
    private CircuitBreaker circuitBreaker = CircuitBreaker.create(
        "cacheBreaker",
        CircuitBreakerConfig.custom()
            .failureRateThreshold(50)  // 失败率50%触发熔断
            .waitDurationInOpenState(Duration.ofSeconds(30))  // 熔断30秒
            .build()
    );
    
    public Object getWithCircuitBreaker(String key) {
        return circuitBreaker.executeSupplier(() -> {
            Object value = redisTemplate.opsForValue().get(key);
            if (value == null) {
                value = database.query(key);
                if (value != null) {
                    redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
                }
            }
            return value;
        }, () -> {
            // 降级逻辑:返回默认值
            return getDefaultvalue(key);
        });
    }
}

3.6 缓存雪崩解决方案对比

方案 优点 缺点 适用场景
随机过期时间 简单易实现 不能完全避免 常规场景
多级缓存 性能高、容错强 数据一致性复杂 高并发场景
熔断降级 保护系统 影响用户体验 极端情况

四、缓存更新策略

4.1 常见更新策略

实际场景:缓存和数据库数据一致性是分布式系统的经典难题,需要根据业务场景选择合适的策略。

策略 描述 一致性 性能 适用场景
Cache Aside 先更新DB,再删除缓存 较好 较好 读多写少
Read/Write Through 由缓存代理更新DB 读写均衡
Write Behind 只更新缓存,异步更新DB 最好 写多读少

4.2 Cache Aside模式详解

面试高频考点:为什么是删除缓存而不是更新缓存?

删除 vs 更新

对比项 删除缓存 更新缓存
复杂度
数据一致性 较好 可能不一致
性能 高(懒加载) 低(每次写都更新)
并发问题 较少 较多

Cache Aside实现

java 复制代码
public void updateData(String key, Object value) {
    // 1. 先更新数据库
    database.update(key, value);
    
    // 2. 再删除缓存
    redisTemplate.delete(key);
}

4.3 缓存和数据库一致性问题

踩坑提醒:在高并发场景下,即使先更新DB再删除缓存,也可能出现不一致。

问题场景

复制代码
线程A: 更新DB → 删除缓存
线程B: 读缓存miss → 查DB(旧数据) → 写缓存
如果线程B在线程A删除缓存前写入,缓存就是旧数据

解决方案:延迟双删

java 复制代码
public void updateData(String key, Object value) {
    // 1. 先删除缓存
    redisTemplate.delete(key);
    
    // 2. 更新数据库
    database.update(key, value);
    
    // 3. 延迟后再次删除缓存
    CompletableFuture.runAsync(() -> {
        try {
            Thread.sleep(500);  // 延迟500ms
            redisTemplate.delete(key);
        } catch (InterruptedException e) {
            log.error("延迟双删失败", e);
        }
    });
}

五、踩坑提醒

5.1 缓存和数据库一致性陷阱

陷阱 说明 解决方案
先删缓存再更新DB 并发时可能读到旧数据写入缓存 使用延迟双删
缓存删除失败 数据库更新成功但缓存删除失败 使用消息队列重试
并发写问题 多线程同时写导致数据错乱 使用分布式锁

5.2 热点key问题

踩坑提醒:热点key会导致单个Redis节点压力过大,需要特殊处理。

解决方案

java 复制代码
// 方案1:热点key分散
String[] keys = {"hot:1", "hot:2", "hot:3"};
int index = new Random().nextInt(keys.length);
Object value = redisTemplate.opsForValue().get(keys[index]);

// 方案2:本地缓存
// 使用Caffeine等本地缓存框架

5.3 大key问题

问题 说明 解决方案
内存占用大 单个key占用过多内存 拆分大key
网络阻塞 传输大key阻塞网络 压缩或分片
过期阻塞 删除大key阻塞主线程 异步删除

六、面试高频考点

6.1 如何解决缓存三兄弟(穿透、击穿、雪崩)?

答案

缓存穿透

  • 布隆过滤器:过滤不存在的key
  • 空值缓存:缓存空值,设置短过期时间
  • 参数校验:在入口处过滤非法请求

缓存击穿

  • 互斥锁:只允许一个线程查询数据库
  • 热点预热:提前加载热点数据
  • 逻辑过期:不设置TTL,后台异步更新

缓存雪崩

  • 随机过期时间:避免同时过期
  • 多级缓存:本地缓存+Redis缓存
  • 熔断降级:保护系统不被压垮

6.2 缓存和数据库如何保证一致性?

答案

  1. Cache Aside模式:先更新数据库,再删除缓存

  2. 延迟双删:删除缓存 → 更新DB → 延迟后再删除缓存

  3. 消息队列重试:删除缓存失败时,通过MQ重试

  4. Binlog订阅:通过Canal订阅MySQL binlog,异步更新缓存

  5. 强一致性场景:使用分布式锁或直接查数据库

6.3 为什么删除缓存而不是更新缓存?

答案

  1. 并发安全:删除操作是幂等的,更新可能被覆盖

  2. 性能考虑:很多场景下缓存可能根本不会被读取,更新是浪费

  3. 数据一致性:更新缓存可能失败,导致数据不一致

  4. 懒加载:删除后下次读取时再加载,数据更新鲜


七、参考资料

  1. Redis官方文档 - 缓存模式
  2. Cache Aside Pattern详解

八、互动话题

  1. 你的项目中遇到过缓存穿透、击穿、雪崩吗?是如何解决的?
  2. 对于强一致性要求的业务,你会如何设计缓存策略?
  3. 多级缓存的方案在实际应用中有什么坑?

欢迎在评论区分享你的实战经验!


下期预告:Day13我们将学习Redis分布式锁,深入理解SETNX、Redisson和Redlock算法。

相关推荐
zzz_23681 小时前
【Redis】Redis 面试深度系列
数据库·redis·面试
代码中介商1 小时前
HTTP 完全指南(二):缓存机制深度详解
网络协议·http·缓存
Solis程序员1 小时前
解决双写不一致!Canal+Outbox+Kafka 高可靠事件驱动架构
redis·分布式·架构·kafka·canal
Java_2017_csdn1 小时前
在 Java 中,MessageFormat.format() 和 String.format() 函数对比?
java·开发语言·前端·数据库
basketball6161 小时前
Redis基础:2. Redis 常用命令
数据库·redis·缓存
码农阿豪2 小时前
Node.js 连金仓数据库(下篇):连接池、事务和那些坑
数据库·node.js
东方巴黎~Sunsiny2 小时前
实战:RocketMQ 幂等 + Redis 分布式锁 + 异常重试 保姆级教程
redis·分布式·rocketmq
峰子20122 小时前
PG 管控系统技术方案
数据库·后端·pg