
缓存三大问题穿透、击穿、雪崩完整解决方案作者黒漂技术佬 | 系列Redis 缓存与高并发实战缓存用得好系统飞起用不好数据库先被干趴。缓存穿透、击穿、雪崩是 Redis 缓存体系里最经典的三个问题也是面试必考、生产必遇的三大坑。本文把三个问题掰碎了讲清楚每个都附图解和代码。一、缓存穿透什么是缓存穿透简单说查询一个根本不存在的数据缓存里没有数据库里也没有每次请求都打到数据库。正常流程是这样的客户端 → 查 Redis → 有就返回 → 没有就查 MySQL → 写入 Redis → 返回但如果有人请求一个 ID 为-1的商品或者一个不存在的用户 IDRedis 里查不到MySQL 里也查不到。结果就是每次请求都穿透缓存直接打到 MySQL。客户端 → 查 Redis没有→ 查 MySQL也没有→ 返回 null ↑ 每次都是这条路径缓存形同虚设危害如果有人恶意构造大量不存在的 Key 发起请求比如用脚本刷你的接口MySQL 就会被打满甚至宕机。这本质上是一种 DoS 攻击。图解正常请求 Request → [Redis Cache] → 命中 → 返回 ✅ ↓ 未命中 [MySQL] → 命中 → 回写缓存 → 返回 ✅ 穿透请求 Request → [Redis Cache] → 未命中 ↓ [MySQL] → 也没命中 → 返回空 ↑ 缓存和数据库都拦不住每次都穿透解决方案一缓存空值查不到的数据也往 Redis 里存一份值为null或空字符串但设一个较短的过期时间比如 60 秒。publicProductgetProduct(LongproductId){Stringkeyproduct:productId;Stringcachedredis.get(key);if(cached!null){if(NULL.equals(cached)){returnnull;// 缓存的空值直接返回}returnJSON.parseObject(cached,Product.class);}// 缓存未命中查数据库Productproductmysql.query(SELECT * FROM product WHERE id ?,productId);if(product!null){redis.set(key,JSON.toJSONString(product),3600);// 正常缓存 1 小时}else{redis.set(key,NULL,60);// 空值缓存 60 秒防止穿透}returnproduct;}优点实现简单直接有效。缺点占用额外内存虽然存的是 null但 Key 本身占内存如果攻击者每次用不同的不存在的 Key这个方案就力不从心了——你不可能把所有不存在的 Key 都缓存下来解决方案二布隆过滤器Bloom Filter布隆过滤器是一种空间效率极高的概率型数据结构专门用来判断某个元素是否在一个集合中。它的特点说不存在就一定不存在说存在可能存在有误判率但可以控制得很低原理简单理解用一个很长的二进制位数组 多个哈希函数。存入元素时用多个哈希函数算出多个位置把这些位置设为 1。查询时同样算多个位置如果都是 1 就可能存在只要有任何一个位置是 0 就一定不存在。写入 可乐 hash1(可乐) 3 → bit[3] 1 hash2(可乐) 7 → bit[7] 1 hash3(可乐) 11 → bit[11] 1 查询 雪碧 hash1(雪碧) 3 → bit[3] 1 ✓ hash2(雪碧) 5 → bit[5] 0 ✗ → 一定不存在在缓存前置一层布隆过滤器Request → [Bloom Filter] → 不存在 → 直接返回不查 Redis 也不查 MySQL ✅ → 可能存在 → 查 Redis → 查 MySQL// 初始化把数据库中所有商品 ID 放入布隆过滤器BloomFilterLongfilterBloomFilter.create(Funnels.longFunnel(),1000000,0.001// 100万数据误判率 0.1%);// 启动时加载ListLongallProductIdsmysql.query(SELECT id FROM product);allProductIds.forEach(filter::put);publicProductgetProduct(LongproductId){// 第一层布隆过滤器拦截if(!filter.mightContain(productId)){returnnull;// 一定不存在直接返回}// 第二层查缓存Stringkeyproduct:productId;Stringcachedredis.get(key);if(cached!null){returnNULL.equals(cached)?null:JSON.parseObject(cached,Product.class);}// 第三层查数据库Productproductmysql.query(SELECT * FROM product WHERE id ?,productId);if(product!null){redis.set(key,JSON.toJSONString(product),3600);}else{redis.set(key,NULL,60);}returnproduct;}实战建议缓存空值 布隆过滤器组合使用双保险。布隆过滤器拦截大部分不存在的 Key缓存空值兜底处理布隆过滤器的误判。二、缓存击穿什么是缓存击穿某个热点 Key 在过期的瞬间大量并发请求同时打过来全部绕过缓存直接查数据库。和穿透的区别穿透是查不存在的数据击穿是查存在但刚好过期的热点数据。双11秒杀商品 缓存过期的那一瞬间 1000 个请求同时到达 → 查 Redis → 全部未命中刚过期 → 1000 个请求同时查 MySQL → MySQL 扛不住了 图解正常情况 Request → [Redis Cache: 热点Key] → 命中 → 返回 ✅ Key过期瞬间 Request1 → [Redis Cache: Key已过期] → 未命中 → 查 MySQL ↓ Request2 → [Redis Cache: Key已过期] → 未命中 → 查 MySQL ↓ Request3 → [Redis Cache: Key已过期] → 未命中 → 查 MySQL ↓ ... → MySQL 被打爆 解决方案一互斥锁SETNX思路缓存未命中时先抢锁谁抢到谁查数据库并回写缓存其他人等着。publicProductgetHotProduct(LongproductId){Stringkeyproduct:productId;Stringcachedredis.get(key);if(cached!null){returnJSON.parseObject(cached,Product.class);}// 缓存未命中尝试获取互斥锁StringlockKeylock:product:productId;try{// SETNX 抢锁过期时间 10 秒防止死锁booleanlockedredis.setIfAbsent(lockKey,1,10,TimeUnit.SECONDS);if(locked){try{// 二次检查拿到锁后先再查一次缓存可能别人已经写入了cachedredis.get(key);if(cached!null){returnJSON.parseObject(cached,Product.class);}// 查数据库Productproductmysql.query(SELECT * FROM product WHERE id ?,productId);// 写入缓存过期时间适当延长redis.set(key,JSON.toJSONString(product),3600);returnproduct;}finally{redis.delete(lockKey);// 释放锁}}else{// 没抢到锁短暂等待后重试Thread.sleep(50);returngetHotProduct(productId);// 递归重试}}catch(Exceptione){thrownewRuntimeException(e);}}关键点SETNXSET if Not eXists保证只有一个请求能拿到锁锁必须设过期时间防止持锁线程挂了导致死锁拿到锁后要二次检查缓存避免重复查库没拿到锁的线程等待重试而不是直接查库解决方案二热点 Key 永不过期 后台异步更新思路热点 Key 不设过期时间由后台线程定期更新。// 后台定时更新线程Scheduled(fixedRate30*60*1000)// 每 30 分钟更新一次publicvoidrefreshHotProductCache(){ListLonghotProductIdsgetHotProductIds();// 从排行榜或配置获取热点商品for(Longid:hotProductIds){Productproductmysql.query(SELECT * FROM product WHERE id ?,id);redis.set(product:id,JSON.toJSONString(product);// 不设过期时间}}优点不会出现击穿问题请求永远命中缓存。缺点数据有短暂延迟不是实时的需要维护更新逻辑。适合对数据实时性要求不极端的场景。两种方案对比互斥锁适合绝大多数场景实现简单永不过期适合超高并发的极端热点比如首页 Banner、秒杀商品。三、缓存雪崩什么是缓存雪崩大量 Key 在同一时间集中过期或者 Redis 服务整体宕机导致所有请求全部打到数据库。和击穿的区别击穿是单个热点 Key过期雪崩是大量 Key同时过期。场景一大量Key同时过期 你给 10000 个商品缓存都设了相同的过期时间 3600 秒 → 1 小时后10000 个 Key 同时过期 → 此时如果正好来一波流量全部打到 MySQL → 雪崩 场景二Redis宕机 Redis 挂了 → 所有缓存都不可用 → 全部流量直接打到 MySQL → 雪崩 图解大量Key同时过期 [Key1: 过期] → 未命中 → MySQL ↓ [Key2: 过期] → 未命中 → MySQL ↓ [Key3: 过期] → 未命中 → MySQL ↓ [Key4: 过期] → 未命中 → MySQL ↓ ... → MySQL 被压垮 解决方案一过期时间加随机值最简单有效的方案——给每个 Key 的过期时间加上一个随机值打散过期时间。publicvoidcacheProduct(Productproduct){Stringkeyproduct:product.getId();intbaseExpire3600;// 基础过期 1 小时intrandomExpirenewRandom().nextInt(300);// 随机 0~300 秒redis.set(key,JSON.toJSONString(product),baseExpirerandomExpire);}这样 10000 个 Key 的过期时间分散在 3600~3900 秒之间不会集中过期。解决方案二Redis 集群高可用防 Redis 宕机导致的雪崩核心是不让 Redis 成为单点主从 哨兵主节点挂了自动切换从节点下篇详讲Redis Cluster多主多从一个节点挂了不影响整体多级缓存本地缓存Caffeine/Guava作为一级缓存Redis 作为二级缓存// 多级缓存示例publicProductgetProduct(Longid){// 一级缓存本地缓存CaffeineProductproductlocalCache.getIfPresent(product:id);if(product!null){returnproduct;}// 二级缓存RedisStringcachedredis.get(product:id);if(cached!null){productJSON.parseObject(cached,Product.class);localCache.put(product:id,product);// 回填本地缓存returnproduct;}// 三级数据库productmysql.query(SELECT * FROM product WHERE id ?,id);if(product!null){redis.set(product:id,JSON.toJSONString(product),3600random());localCache.put(product:id,product);}returnproduct;}解决方案三服务降级与限流雪崩发生时的最后一道防线限流用 Sentinel 或 Hystrix 对数据库查询接口限流超过阈值直接拒绝降级返回兜底数据默认值、上次缓存值保证系统不整体崩溃SentinelResource(valuegetProduct,fallbackgetProductFallback)publicProductgetProduct(Longid){// 正常逻辑}// 降级方法返回默认商品信息publicProductgetProductFallback(Longid){returnProduct.defaultProduct();}四、三大问题对比缓存穿透缓存击穿缓存雪崩触发条件查不存在的数据热点Key过期瞬间大量Key同时过期/Redis宕机请求量可大可小大量并发海量影响范围单个Key单个热点Key大量Key解决方案缓存空值 布隆过滤器互斥锁 永不过期随机过期 集群高可用 限流降级五、无人售货柜商品查询缓存方案设计场景描述一个无人售货柜平台1000 台设备每台 50 个商品。用户扫码打开柜门后前端会轮询商品库存。高峰期午休时段单接口 QPS 可达 5000。缓存方案┌─────────────┐ │ 客户端请求 │ └──────┬──────┘ ↓ ┌─────────────┐ │ 布隆过滤器 │ ← 拦截不存在的商品ID防穿透 └──────┬──────┘ ↓ ┌─────────────┐ │ Redis缓存 │ ← 商品详情缓存过期时间 3600 random(300) └──────┬──────┘ ↓ (未命中) ┌─────────────┐ │ 互斥锁查库 │ ← SETNX 加锁只允许一个请求查 MySQL防击穿 └──────┬──────┘ ↓ ┌─────────────┐ │ MySQL │ └─────────────┘核心代码ServicepublicclassProductCacheService{AutowiredprivateStringRedisTemplateredis;AutowiredprivateProductMapperproductMapper;AutowiredprivateBloomFilterLongproductBloomFilter;privatestaticfinallongCACHE_TTL3600L;// 基础过期 1 小时privatestaticfinallongCACHE_NULL_TTL60L;// 空值过期 60 秒privatestaticfinallongLOCK_TTL10L;// 锁过期 10 秒publicProductgetProduct(LongproductId){// 1. 布隆过滤器前置拦截if(!productBloomFilter.mightContain(productId)){returnnull;}Stringkeycabinet:product:productId;Stringcachedredis.opsForValue().get(key);// 2. 缓存命中if(cached!null){if(NULL.equals(cached))returnnull;returnJSON.parseObject(cached,Product.class);}// 3. 缓存未命中互斥锁防止击穿StringlockKeylock:key;try{Booleanlockedredis.opsForValue().setIfAbsent(lockKey,1,LOCK_TTL,TimeUnit.SECONDS);if(Boolean.TRUE.equals(locked)){try{// 二次检查cachedredis.opsForValue().get(key);if(cached!null){returnNULL.equals(cached)?null:JSON.parseObject(cached,Product.class);}// 查库ProductproductproductMapper.selectById(productId);longrandomTtlCACHE_TTLThreadLocalRandom.current().nextLong(300);if(product!null){redis.opsForValue().set(key,JSON.toJSONString(product),randomTtl,TimeUnit.SECONDS);}else{redis.opsForValue().set(key,NULL,CACHE_NULL_TTL,TimeUnit.SECONDS);}returnproduct;}finally{redis.delete(lockKey);}}else{// 等待重试Thread.sleep(50);returngetProduct(productId);}}catch(InterruptedExceptione){Thread.currentThread().interrupt();thrownewRuntimeException(获取商品信息失败,e);}}}设计要点总结问题对应措施穿透布隆过滤器 空值缓存击穿SETNX 互斥锁 二次检查雪崩过期时间加随机值 Redis 主从集群缓存三大问题的本质都是缓存失效后请求绕过缓存直接打数据库。区别在于失效的原因不同——穿透是数据不存在、击穿是热点过期、雪崩是集体失效。理解了原因对应的解决方案就顺理成章了。