Redis 缓存实战:雪崩、穿透、击穿一次讲透 缓存用好了是性能翻倍用不好凌晨三点把你叫醒。Redis 在大多数系统里扮演的角色是挡在数据库前面的一堵墙。正常情况下请求打到 Redis命中就走数据库压力不大。但这堵墙有三个洞它可能整堵墙同时塌可能有人绕过墙直接锤数据库也可能墙上最忙的那块砖碎了。对应过来就是雪崩、穿透、击穿。一个一个说。一、缓存雪崩整堵墙同时塌了什么是雪崩大量缓存在同一个时间点集中过期所有请求瞬间穿透到数据库数据库扛不住直接挂。典型的触发场景你批量导入了 10 万条商品数据所有缓存都设了 24 小时过期。24 小时后10 万个 key 在同一秒集体死亡。下一秒打进来的请求全部查库数据库连接池瞬间打满。怎么解决三道防线// 防线一过期时间加随机值错开集体死亡 // 不要这样 redisTemplate.opsForValue().set(productKey, value, 24, TimeUnit.HOURS); // 改成这样随机加 1-6 小时 int randomHours ThreadLocalRandom.current().nextInt(1, 7); redisTemplate.opsForValue().set(productKey, value, 24 randomHours, TimeUnit.HOURS); // 防线二永不过期 异步更新 // 缓存本身不设过期由定时任务定期刷新 Scheduled(fixedDelay 300000) // 每5分钟 public void refreshHotProducts() { ListProduct hotProducts productService.getHotProducts(); for (Product p : hotProducts) { String key product: p.getId(); redisTemplate.opsForValue().set(key, p, Duration.ofDays(7)); } } // 注意这里设了7天兜底防止定时任务挂了缓存永远不更新 // 防线三多级缓存 限流兜底 // 如果 Redis 也挂了本地缓存拦截一波 Cacheable(value product, key #id, unless #result null) public Product getProduct(Long id) { return productMapper.selectById(id); } // 数据库层加限流即使被打穿也不至于雪崩到宕机 // Sentinel / RateLimiter 随便选一个核心是保住数据库不挂雪崩的核心矛盾不是数据过期是所有东西同时过期。错开时间就消解了 80% 的风险。二、缓存穿透有人绕过墙直接锤数据库什么是穿透查询一个不存在的数据——缓存里没有数据库里也没有。正常逻辑会直接查库然后发现是空的不写缓存。下次同样的非法请求再来继续查库。恶意攻击者可以构造一大堆不存在的 ID把你的数据库活活打死。// 漏洞代码 public Product getProduct(Long id) { String key product: id; Product p (Product) redisTemplate.opsForValue().get(key); if (p ! null) { return p; } p productMapper.selectById(id); if (p ! null) { redisTemplate.opsForValue().set(key, p, 24, TimeUnit.HOURS); } // BUGp 是 null 的时候什么都不做下次还查库 return p; }怎么修// 方案一缓存空值把「没有」也存起来 public Product getProduct(Long id) { String key product: id; Product p (Product) redisTemplate.opsForValue().get(key); if (p ! null) { // 判断是不是空值标记 if (p.getId() -1L) return null; return p; } p productMapper.selectById(id); if (p null) { // 缓存一个空对象短过期防止被恶意刷 Product empty new Product(); empty.setId(-1L); redisTemplate.opsForValue().set(key, empty, 5, TimeUnit.MINUTES); return null; } redisTemplate.opsForValue().set(key, p, 24, TimeUnit.HOURS); return p; }空值缓存的问题是如果有人刷几百万个不同的非法 IDRedis 内存会被空对象占满。所以// 方案二布隆过滤器事前拦截不存在的 key // 用 Guava 或 Redisson 的布隆过滤器 RBloomFilterString bloomFilter redissonClient.getBloomFilter(product_filter); bloomFilter.tryInit(1000000L, 0.01); // 100万容量1%误判率 // 数据写入时同步加入过滤器 bloomFilter.add(product: id); // 查询前先过过滤器 public Product getProduct(Long id) { String key product: id; if (!bloomFilter.contains(key)) { return null; // 一定不存在直接返回不查库 } // 剩下走正常缓存逻辑 // ... }布隆过滤器的代价有 1% 误判率说存在但实际不存在。多查一次数据库是能接受的。它的价值是拦住 99% 的恶意穿透请求。推荐组合布隆过滤器前置拦截 空值缓存兜底。布隆过滤器拦不住的那 1% 由空值缓存接住。三、缓存击穿墙上最忙的那块砖碎了什么是击穿一个热点 key 过期了这个 key 平时扛几万 QPS。过期瞬间几万请求同时打向数据库。和雪崩的区别是雪崩是一大片 key 同时过期击穿是单个爆款 key 过期。典型场景秒杀商品的库存缓存、首页推荐位的配置缓存、大 V 的粉丝数缓存。// 漏洞代码 public Product getHotProduct(Long id) { String key product: id; Product p (Product) redisTemplate.opsForValue().get(key); if (p null) { // 缓存过期了直接查库 // 几千个线程同时走到这里全部查库 p productMapper.selectById(id); redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES); } return p; }怎么修// 方案一互斥锁只让一个人去查库 public Product getHotProduct(Long id) { String key product: id; String lockKey lock:product: id; Product p (Product) redisTemplate.opsForValue().get(key); if (p ! null) return p; // SET NX EX抢锁超时 10 秒防止死锁 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 抢到锁二次检查 p (Product) redisTemplate.opsForValue().get(key); if (p ! null) return p; p productMapper.selectById(id); redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES); return p; } finally { redisTemplate.delete(lockKey); } } else { // 没抢到锁等 50ms 重试 try { Thread.sleep(50); } catch (InterruptedException ignored) {} return getHotProduct(id); // 递归重试 } } // 方案二逻辑过期不删 key只更新 value // 适合容忍短期旧数据的场景 public Product getHotProductV2(Long id) { String key product: id; Product p (Product) redisTemplate.opsForValue().get(key); if (p null) { // 首次加载 p productMapper.selectById(id); redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES); return p; } // 判断是否逻辑过期在 value 里塞一个过期时间戳 if (p.getExpireTime() System.currentTimeMillis()) { // 异步更新当前请求直接返回旧数据 threadPool.submit(() - refreshCache(id)); } return p; // 返回可能过期的旧数据但不会击穿 }逻辑过期方案的关键取舍宁愿用户看到 30 秒前的旧数据也别让他等 5 秒白屏。区别速查表类型现象本质核心解法雪崩大量 key 同时过期过期时间一致随机 TTL 永不过期异步刷新穿透查不存在的 key无缓存屏障布隆过滤器 缓存空值击穿单个热点 key 过期高并发查同一个互斥锁 逻辑过期最后不管你用不用 Redis这三类问题本质上都是同一件事当缓存和数据库之间的假设破裂时你的系统有没有兜底。一个缓存 key 过期了你没有互斥锁说明你假设了缓存永远能命中。缓存不存在你没有布隆过滤器说明你假设了所有请求都是合法的。大量 key 同时过期你没有随机 TTL说明你假设了过期时间是均匀分布的。假设被打破的那一刻就是你的系统开始雪崩的那一刻。