一、一次缓存雪崩让我记忆犹新
2019年某个凌晨2点,Redis集群由于一次网络抖动,大量Key同时过期。
那一瞬间,所有请求涌入数据库,数据库连接池瞬间耗尽,服务全部超时。
更糟糕的是,数据库扛不住压力也挂了。我们花了2个小时才恢复。
后来算了一下,那2个小时的停机损失超过200万。从那以后,我对缓存的理解深入了很多。
二、缓存三大问题
2.1 缓存穿透
缓存穿透:查询一个不存在的数据 请求 → 缓存未命中 → 数据库未命中 → 返回空 每次都穿透到数据库 原因: 1. 业务逻辑问题(查不存在的ID) 2. 恶意攻击(大量请求不存在的数据) 3. 数据被删除了,但缓存没更新/** * 缓存穿透防护:布隆过滤器 + 空值缓存 */@Service@Slf4jpublicclassAntiPenetrationCacheService{@AutowiredprivateStringRedisTemplateredisTemplate;@AutowiredprivateBloomFilter<Long>productBloomFilter;privatestaticfinalStringNULL_CACHE_PREFIX="null:";privatestaticfinalDurationNULL_CACHE_TTL=Duration.ofMinutes(5);/** * 查询商品(防穿透) */publicProductgetProduct(LongproductId){StringcacheKey="product:"+productId;// 1. 布隆过滤器检查(第一道防线)if(!productBloomFilter.mightContain(productId)){log.warn("布隆过滤器拦截: productId={}",productId);returnnull;}// 2. 查询缓存Stringcached=redisTemplate.opsForValue().get(cacheKey);// 2.1 命中空值缓存if(NULL_CACHE_PREFIX.equals(cached)){returnnull;}// 2.2 命中正常缓存if(cached!=null){returnJSON.parseObject(cached,Product.class);}// 3. 查询数据库Productproduct=productMapper.selectById(productId);if(product!=null){// 正常数据,写入缓存redisTemplate.opsForValue().set(cacheKey,JSON.toJSONString(product),Duration.ofMinutes(30));}else{// 空值缓存(防止穿透)redisTemplate.opsForValue().set(cacheKey,NULL_CACHE_PREFIX,NULL_CACHE_TTL);}returnproduct;}/** * 初始化布隆过滤器 */@PostConstructpublicvoidinitBloomFilter(){productBloomFilter=BloomFilter.create(Funnels.longFunnel(),1000000,// 预期元素数量0.01// 误判率1%);// 加载所有商品IDList<Long>productIds=productMapper.selectAllIds();productIds.forEach(productBloomFilter::put);log.info("布隆过滤器初始化完成: count={}",productIds.size());}}2.2 缓存击穿
缓存击穿:热点Key过期,大量请求同时访问 某个热点Key过期的瞬间,大量并发请求同时穿透到数据库 场景: 1. 热门商品详情 2. 热门活动页面 3. 微博热搜/** * 缓存击穿防护:互斥锁 + 逻辑过期 */@Service@Slf4jpublicclassAntiBreakdownCacheService{@AutowiredprivateStringRedisTemplateredisTemplate;/** * 方案1:互斥锁(适合一般热点数据) */publicProductgetProductWithMutex(LongproductId){StringcacheKey="product:"+productId;StringlockKey="lock:product:"+productId;// 1. 查询缓存Stringcached=redisTemplate.opsForValue().get(cacheKey);if(cached!=null){returnJSON.parseObject(cached,Product.class);}// 2. 获取互斥锁Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);if(Boolean.TRUE.equals(locked)){try{// 3. 双重检查cached=redisTemplate.opsForValue().get(cacheKey);if(cached!=null){returnJSON.parseObject(cached,Product.class);}// 4. 查询数据库Productproduct=productMapper.selectById(productId);// 5. 写入缓存if(product!=null){redisTemplate.opsForValue().set(cacheKey,JSON.toJSONString(product),Duration.ofMinutes(30));}returnproduct;}finally{// 6. 释放锁redisTemplate.delete(lockKey);}}else{// 没拿到锁,等一会儿重试try{Thread.sleep(100);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}returngetProductWithMutex(productId);}}/** * 方案2:逻辑过期(适合超级热点数据) */publicProductgetProductWithLogicalExpire(LongproductId){StringcacheKey="product:logical:"+productId;// 1. 查询缓存Stringcached=redisTemplate.opsForValue().get(cacheKey);if(cached==null){// 首次加载returnloadAndCache(cacheKey,productId);}// 2. 解析逻辑过期时间CacheData<Product>cacheData=JSON.parseObject(cached,newTypeReference<CacheData<Product>>(){});if(cacheData.getExpireTime().isAfter(LocalDateTime.now())){// 未过期,直接返回returncacheData.getData();}// 3. 已过期,异步刷新StringlockKey="lock:logical:"+productId;Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);if(Boolean.TRUE.equals(locked)){// 拿到锁,异步刷新CompletableFuture.runAsync(()->{try{loadAndCache(cacheKey,productId);}finally{redisTemplate.delete(lockKey);}});}// 返回过期数据(保证可用性)returncacheData.getData();}}/** * 逻辑过期缓存数据 */@DataclassCacheData<T>{privateTdata;privateLocalDateTimeexpireTime;}2.3 缓存雪崩
缓存雪崩:大量Key同时过期,或缓存服务宕机 原因: 1. 大量Key设置了相同的过期时间 2. 缓存服务重启或宕机 3. 缓存服务网络故障/** * 缓存雪崩防护 */@Service@Slf4jpublicclassAntiAvalancheCacheService{@AutowiredprivateStringRedisTemplateredisTemplate;/** * 方案1:随机过期时间 */publicvoidsetWithRandomExpire(Stringkey,Objectvalue,longbaseMinutes){// 基础过期时间 + 随机时间(0~5分钟)longrandomMinutes=ThreadLocalRandom.current().nextLong(0,5);longtotalMinutes=baseMinutes+randomMinutes;redisTemplate.opsForValue().set(key,JSON.toJSONString(value),Duration.ofMinutes(totalMinutes));}/** * 方案2:多级缓存 */publicProductgetProductWithMultiLevel(LongproductId){// L1: 本地缓存(Caffeine)Productproduct=localCache.getIfPresent(productId);if(product!=null){returnproduct;}// L2: Redis缓存StringcacheKey="product:"+productId;Stringcached=redisTemplate.opsForValue().get(cacheKey);if(cached!=null){product=JSON.parseObject(cached,Product.class);localCache.put(productId,product);returnproduct;}// L3: 数据库product=productMapper.selectById(productId);if(product!=null){// 写入L2redisTemplate.opsForValue().set(cacheKey,JSON.toJSONString(product),Duration.ofMinutes(30+ThreadLocalRandom.current().nextLong(0,5)));// 写入L1localCache.put(productId,product);}returnproduct;}/** * 方案3:熔断降级 */publicProductgetProductWithCircuitBreaker(LongproductId){Entryentry=null;try{entry=SphU.entry("getProduct");returngetProductWithMultiLevel(productId);}catch(BlockExceptione){// 被限流或熔断,返回降级数据log.warn("获取商品被熔断: productId={}",productId);returngetDefaultProduct(productId);}finally{if(entry!=null){entry.exit();}}}/** * 降级:返回默认商品信息 */privateProductgetDefaultProduct(LongproductId){returnProduct.builder().id(productId).name("商品信息加载中").price(BigDecimal.ZERO).status("LOADING").build();}}三、缓存一致性
3.1 常见方案
缓存一致性方案: 1. Cache Aside(旁路缓存) - 读:先读缓存,miss读DB,写缓存 - 写:先更新DB,再删缓存 - 问题:极端情况下会不一致 2. Write Through(写穿透) - 写:先写缓存,缓存同步写DB - 读:只读缓存 - 问题:写入延迟高 3. Write Behind(写回) - 写:只写缓存,异步写DB - 问题:数据可能丢失 4. 双写 - 写:同时写DB和缓存 - 问题:两个写操作可能一个成功一个失败3.2 最佳实践
/** * 缓存一致性:延迟双删 */@Service@Slf4jpublicclassCacheConsistencyService{@AutowiredprivateStringRedisTemplateredisTemplate;/** * 延迟双删策略 */@TransactionalpublicvoidupdateProduct(Productproduct){StringcacheKey="product:"+product.getId();// 1. 先删缓存redisTemplate.delete(cacheKey);// 2. 更新数据库productMapper.updateById(product);// 3. 延迟再删一次(防止旧数据被其他线程写入缓存)CompletableFuture.delayedExecutor(500,TimeUnit.MILLISECONDS).execute(()->{redisTemplate.delete(cacheKey);log.info("延迟双删: key={}",cacheKey);});}/** * 基于Canal的缓存更新 */@EventListenerpublicvoidonDataChange(DataChangeEventevent){if("t_product".equals(event.getTable())){StringproductId=event.getAfter().get("id");StringcacheKey="product:"+productId;if(event.getEventType()==CanalEntry.EventType.DELETE){redisTemplate.delete(cacheKey);}else{// 删除缓存,下次读取时重新加载redisTemplate.delete(cacheKey);}log.info("Canal更新缓存: key={}",cacheKey);}}}四、踩坑实录
坑1:大量Key同时过期
所有商品缓存设置了30分钟过期,整点时全部过期,数据库被打爆。
解决:过期时间加随机值。
坑2:缓存和数据库不一致
先删缓存再更新数据库,但并发时旧数据被写回缓存。
解决:延迟双删,或使用Canal监听Binlog更新缓存。
坑3:热点Key打满Redis
某个热点Key的QPS超过Redis单节点极限。
解决:本地缓存 + 热点Key拆分。
坑4:BigKey
一个List存了100万个元素,操作时阻塞Redis。
解决:拆分成多个小Key,使用Hash结构。
坑5:缓存预热不充分
大促前缓存预热没做好,流量一来数据库直接被打挂。
解决:大促前手动预热热点数据,逐步放量。
五、总结
缓存全链路防护:
| 问题 | 方案 |
|---|---|
| 穿透 | 布隆过滤器 + 空值缓存 |
| 击穿 | 互斥锁 + 逻辑过期 |
| 雪崩 | 随机过期 + 多级缓存 + 熔断 |
| 一致性 | 延迟双删 + Canal |
最佳实践:
- 永远设置随机过期时间
- 热点数据用逻辑过期
- 多级缓存保底
- 缓存和数据库一致性用Canal
- 大促前做好缓存预热
血的教训:
缓存不是万能的,没有缓存是万万不能的。用好了是加速器,用不好是定时炸弹。
思考题:你的系统有没有遇到过缓存问题?怎么解决的?
个人观点,仅供参考