缓存穿透、缓存击穿、缓存雪崩这三个词只要是搞后端开发的面试必问线上必踩。我刚工作那会儿以为把数据往Redis里一塞就完事了结果第一次压测就把数据库搞挂了原因就是没搞懂这三个问题。这期内容我把它们揉碎了讲每个问题的成因、排查方法、解决方案、实战代码都过一遍尤其是几个容易忽略的坑。不管你是刚接触缓存的初学者还是被线上问题逼疯的救火队员这篇内容都能让你少走不少弯路。全文约6000字干到不能再干。1. 三个问题先搞清楚穿透、击穿、雪崩到底分别是什么1.1 核心概念拆解数据库压力猛增的三种路径先搞清楚定义后面才好对症下药。这三个问题的共同点是“大量请求绕过缓存直接打到数据库/下游服务”区别在于绕过的原因和规模。缓存穿透指的是查询一个缓存和数据库中都不存在的数据。举个具体例子商品详情接口用户传了一个根本不存在的商品ID比如负数、超大数、被删掉的IDRedis里查不到数据库里也查不到那这个请求就会穿透缓存直接打到数据库。正常的流量里混入几个这种请求不明显但如果有人恶意构造大量不存在的ID并发请求数据库瞬间就可能被打爆。穿透的本质是缓存命中率失效——因为数据不存在没法缓存所以每次请求都得去数据库走一圈。缓存击穿指的是某一个热点key在缓存过期的瞬间恰好有大量请求涌进来。比如某个明星突然上了热搜关于他的详情数据被缓存在Redis里过期时间到了正好失效这时候成千上万的请求同时查这个keyRedis没缓存所有人一起把请求打到数据库。击穿和穿透的区别在于击穿针对的是单个热点key数据在数据库里是存在的只是缓存的“遮羞布”在那一瞬间被掀掉了穿透针对的是压根不存在的key数据库里也没有。缓存雪崩指的是大量key在同一时间段集中过期或者Redis节点发生故障导致大面积请求直接落到数据库。比如你初始化缓存时给所有商品设置相同的过期时间比如统一设置12小时后的零点那零点一到Redis里大批key同时失效数据库压力瞬间拉满可能直接宕机。雪崩是这三个问题里破坏力最大的因为它是成片成片地失效扛不住就是全站不可用。可以用一个简单的比喻记忆穿透是“空手掏”数据库里没有的东西硬要查击穿是“单点破”一个热点key的偶尔懈怠雪崩是“群体倒”一片key一起下班。1.2 业务影响面分析不处理会出什么事这三类问题如果不做预防后果是层层递进的。缓存穿透如果被恶意利用攻击者只要遍历ID范围构造大量不存在的请求就能让你的数据库持续处于高负载状态。正常业务请求会跟着变慢因为数据库连接池被无效查询占满了。轻则接口RT飙升重则数据库连接打满、服务直接雪崩。缓存击穿的影响是“短暂但剧烈”。如果热点key每小时失效一次每次失效都伴随秒级的高并发请求数据库可能没有生命危险但接口的可用性会变得不稳定。特别是当这个热点key的数据是核心业务数据时比如秒杀商品状态、配置中心的核心配置一次击穿就可能引发超卖等严重事故——订单错误比接口慢更加致命。缓存雪崩的危害是系统级的。大量key同时过期或Redis节点宕机数据库承接的流量可能是平时的几十甚至上百倍。这时候数据库的连接池、CPU、磁盘IO等资源会迅速耗尽紧接着触发的是服务超时重试重试又加剧了数据库压力最终形成级联故障整个服务集群都可能崩溃。严重的雪崩事故恢复时间不是分钟级而是小时级对业务的影响是毁灭性的。1.3 前置认知缓存为什么要加怎么加才合理要理解这三个问题还得先明白缓存的基本姿势。绝大多数业务系统使用Redis是“Cache-Aside”模式旁路缓存读请求先查Redis命中就直接返回未命中则查数据库把结果写回Redis设置过期时间再返回给调用方。这个模式本身很简单但它有两个隐藏前提一是数据必须可重建且允许短暂不一致二是过期时间必须合理设置。很多人加缓存时根本没想过过期时间该怎么定随手写个set(key, value, 3600)用一小时统一过期时间这就是雪崩的隐患。合理的缓存设计必须考虑业务对数据时效性的容忍度。比如商品价格秒级变动会影响交易缓存时间应该短商品标题分钟级容忍即可可以缓存时间长一些购物车数量用户每次操作都要实时刷新这类数据压根不适合用Redis缓存更应该直接查库或用本地缓存。先判断数据是否适合缓存再谈防穿透、防击穿、防雪崩这个顺序不能反。2. 缓存穿透的完整解法从入口拦截到兜底缓存2.1 参数校验是第一条防线能拦下90%的恶意请求最简单的穿透防护就是接口层做参数校验。比如查询商品详情的ID必须是正整数且小于某个最大值查询订单号的格式必须符合业务规则。把明显不合理的请求直接拒绝返回参数错误不要让它们进入缓存和数据库链路。这块很多团队会忽略。我见过不少代码接口里除了NotNull啥都不加负数、超长字符串、特殊字符全放过去了。实际上一次压测就能看出来非法参数的请求占比可能高达30%。参数校验的开销可以忽略不计收益却是实打实的。// 参数校验示例拦截非法商品ID GetMapping(/product/{id}) public ResultProduct getProduct(PathVariable String id) { // 1. 格式校验必须是纯数字 if (!id.matches(\\d)) { return Result.error(商品ID格式不正确); } long productId Long.parseLong(id); // 2. 范围校验不能为负数或超出业务范围 if (productId 0 || productId 999999999L) { return Result.error(商品ID超出范围); } return productService.getProductById(productId); }注意参数校验只是拦截“明显非法”的请求。真实的穿透攻击往往用“合法但不存在”的ID比如一个正常商品被下架删除后它的ID在数据库里已经不存在了但格式完全合法。这种请求参数校验拦不住需要后面几种手段。2.2 缓存空值解决“不存在数据”的穿透最直接的手段既然数据库里没有的数据没法缓存那我们就把“空”也当做一个值缓存起来。也就是说当数据库查不到数据时在Redis里写一个NULL或者特定标记过期时间设得短一点比如60秒下一次同样的查询就能命中缓存不会穿透到数据库。public Product getProductById(Long id) { // 1. 查缓存 String cacheKey product: id; String cacheValue redisUtil.get(cacheKey); if (cacheValue ! null) { // 缓存中是空标记 if (EMPTY.equals(cacheValue)) { return null; } return JSON.parseObject(cacheValue, Product.class); } // 2. 缓存未命中查数据库 Product product productMapper.selectById(id); if (product null) { // 3. 把空结果写入缓存设置较短过期时间防止穿透 redisUtil.set(cacheKey, EMPTY, 60); return null; } // 4. 正常数据写缓存 redisUtil.set(cacheKey, JSON.toJSONString(product), 3600); return product; }这里有几个细节要注意空值标记要能区分“缓存了空”和“没有缓存”。Redis的get操作在key不存在时返回null如果缓存空值的时候直接存null下次get返回null代码会误以为“缓存未命中”继续查库。所以空值要用一个特殊字符串标记比如EMPTY或NULL代码里显式判断。空值缓存的过期时间要短。正常数据的缓存时间可能是1小时空值缓存建议30秒到60秒即可。因为数据可能随时被创建如果空值缓存时间太长会出现“数据已经入库了但查询还是返回null”的延迟问题影响一致性。空值缓存只适合“可能存在但现在不存在”的数据。如果恶意请求用的ID是随机数每次都不一样缓存空值就没有意义——key每次都不同缓存等于没用反而会往Redis里写入大量垃圾key撑大内存。2.3 布隆过滤器从源头拦截“一定不存在”的数据布隆过滤器的价值在于判断“一定不存在”时它是绝对准确的判断“可能存在”时有一定的误判率。在拦截穿透请求的场景把数据库里存在的ID全都提前放入布隆过滤器查询时先过过滤器如果过滤器说“这个ID肯定不存在”就直接返回根本不用走缓存和数据库。// 初始化布隆过滤器把所有商品ID放入 Component public class BloomFilterInit { Autowired private RedisTemplateString, String redisTemplate; private static final String BLOOM_KEY product_id_bloom; PostConstruct public void init() { // 商品ID总量约1000万期望误判率0.01% int expectedInsertions 10_000_000; double fpp 0.0001; BloomFilterLong filter BloomFilter.create( Funnels.longFunnel(), expectedInsertions, fpp); // 从数据库分批加载所有商品ID ListLong allIds productMapper.getAllIds(); for (Long id : allIds) { filter.put(id); } // filter不是Redis中的这里示意的是JVM内存中的布隆过滤器 // 生产环境可以用Redisson的RBloomFilter this.filter filter; } } // 查询时先过布隆过滤器 public Product getProductById(Long id) { if (!bloomFilter.mightContain(id)) { // 布隆过滤器判定不存在直接返回 return null; } // 接下来走缓存 - 数据库的流程 }生产环境我推荐用Redisson自带的RBloomFilter它可以直接存在Redis里集群部署时每个实例共享同一个过滤器状态不用每个实例各自加载一份。不过要注意Redisson的布隆过滤器内存占用相对较高10亿条数据大约占用1.2GB内存评估好Redis的内存水位再上线。布隆过滤器有个衍生问题数据的动态增删。新增ID时要同步往过滤器里加删除ID时理论上应该从过滤器里删但布隆过滤器不支持删除。对于被删除的ID可以让它继续留在过滤器里代价是这些ID永远“可能存在”查询时会穿透到数据库但结合空值缓存就能兜住。2.4 请求限流与降级最后的兜底手段就算前面几道防线全漏了限流兜底也不能少。对于读接口可以按用户维度、IP维度做限流单用户每秒最多查N次单IP每秒最多查M次超出的直接返回错误或降级信息。这样就算攻击者绕过了布隆过滤器也绕不过频率限制因为攻击的请求数会被砍到安全范围内。// 基于Guava的RateLimiter单机限流 private final RateLimiter rateLimiter RateLimiter.create(500); // 每秒500个请求 public ResultProduct getProduct(Long id) { if (!rateLimiter.tryAcquire()) { // 触发限流返回兜底提示 return Result.error(系统繁忙请稍后再试); } // 业务逻辑... } // 如果觉得单机限流不够可以用RedisLua实现分布式限流 // 滑动窗口/令牌桶都可以Redisson自带RRateLimiter缓存穿透的整体防线是“参数校验 → 布隆过滤器 → 缓存空值 → 接口限流”从入口到出口层层拦截单靠任何一层都不够。特别要记住布隆过滤器解决“不存在”的穿透缓存空值解决“已知不存在”的穿透限流解决“恶意高频”的穿透三者缺一不可。3. 缓存击穿的针对性优化热点key过期瞬间怎么扛住3.1 互斥锁方案只让一个请求去查库其余请求等结果击穿问题的核心是“热点key过期瞬间多个请求同时回源”。最朴素的解决办法就是加锁多个请求只有一个能拿到锁去查数据库其他请求等待锁释放后直接从缓存里拿数据。这样数据库只承受一次查询压力完全可控。public Product getProductById(Long id) { String cacheKey hot_product: id; String cacheValue redisUtil.get(cacheKey); if (cacheValue ! null) { return JSON.parseObject(cacheValue, Product.class); } // 缓存未命中尝试获取互斥锁 String lockKey lock:hot_product: id; String requestId UUID.randomUUID().toString(); boolean locked redisUtil.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); if (locked) { try { // 拿到锁再次查缓存双重检查防止其他线程已经重建缓存 cacheValue redisUtil.get(cacheKey); if (cacheValue ! null) { return JSON.parseObject(cacheValue, Product.class); } // 查数据库 Product product productMapper.selectById(id); if (product null) { redisUtil.set(cacheKey, EMPTY, 60); return null; } redisUtil.set(cacheKey, JSON.toJSONString(product), 3600); return product; } finally { // 释放锁使用Lua脚本保证原子性 releaseLock(lockKey, requestId); } } // 没拿到锁说明其他线程正在查库等待重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductById(id); // 重试走缓存 }锁这个方案的几个关键点我多说几句锁必须设置过期时间并保证加锁的原子性。用set(key, value, NX, EX, 30)这种Redis原生命令一条命令完成加锁和过期设置避免“加锁后进程宕机导致死锁”的问题。锁的过期时间要比业务耗时足够长否则锁提前释放其他线程还是会重复查库。释放锁时必须判断value是否是自己设置的。如果是自己加的锁才允许删除如果锁已经被别人抢到删除会造成误删。这个判断和删除要用Lua脚本保证原子性不能用两步Java代码完成。未抢到锁的线程不要疯狂自旋。sleep(50)是一次短等待然后重试。如果并发量极大可以考虑用一个CompletableFuture或信号量机制挂起等待而不是每个线程都去自旋重试否则锁竞争本身也会成为压力。一个经验值是自旋重试上限设3~5次超过就直接返回缓存降级数据或错误防止线程池被占满。3.2 逻辑过期方案物理key永不过期过期时间写在value里互斥锁有一个天然缺点锁等待期间的请求都在阻塞如果热点key重建需要200ms那这200ms内所有请求都在打转接口RT会看到明显的尖峰。逻辑过期方案可以完全避免阻塞。思路是Redis里的key不设置物理过期时间或者设置很长的时间把过期时间放在value里。查询时判断逻辑时间是否过期过期了就直接返回旧数据同时异步去刷新缓存。public Product getProductById(Long id) { long currentTime System.currentTimeMillis(); // 1. 查缓存这个key物理上永不过期 String cacheKey hot_product: id; String cacheValue redisUtil.get(cacheKey); if (cacheValue null) { // 缓存穿透兜底逻辑... return getProductFromDbAndSetCache(id); } CacheDataProduct cacheData JSON.parseObject(cacheValue, CacheData.class); // 2. 逻辑时间未过期直接返回 if (cacheData.getExpireTime() currentTime) { return cacheData.getData(); } // 3. 逻辑时间已过期先返回旧数据再异步重建缓存 asyncRebuildCache(id, cacheKey); return cacheData.getData(); }这个方案的核心优势是用户永远读得到数据过期时拿的是“旧数据”但不会阻塞。代价是数据一致性变差了在缓存重建完成前用户看到的是旧值。对于商品详情这类允许秒级延迟的业务完全可接受对于库存、余额这类强一致数据不适用。还有个隐藏问题异步重建任务必须做并发控制。如果100个线程同时发现逻辑过期都去触发异步重建数据库还是会被打穿。解决办法是加一个“重建锁”同一时刻只允许一个重建任务执行其他线程检测到锁存在就直接放弃重建反正旧数据还能用。private void asyncRebuildCache(Long id, String cacheKey) { String lockKey rebuild_lock: id; String requestId UUID.randomUUID().toString(); boolean locked redisUtil.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (!locked) { // 已经有线程在重建了直接放弃 return; } // 使用线程池异步执行 executorService.submit(() - { try { Product product productMapper.selectById(id); CacheDataProduct newCache new CacheData( product, System.currentTimeMillis() 3600 * 1000); redisUtil.set(cacheKey, JSON.toJSONString(newCache)); } finally { // 重建完成后释放锁 String lock redisUtil.get(lockKey); if (requestId.equals(lock)) { redisUtil.delete(lockKey); } } }); }3.3 热点key不失效主动更新兜底还有一种简单粗暴的玩法把热点的key设置成永不过期数据更新时通过MQ消息或定时任务主动更新缓存。这个方案特别适合读多写少、数据来源可控的场景比如商品基础信息、配置中心配置。// 商品信息更新时主动刷新缓存 public void updateProduct(Product product) { // 1. 更新数据库 productMapper.updateById(product); // 2. 发送消息通知缓存刷新 mqProducer.send(product.update, product.getId()); } // MQ消费者 RabbitListener(queues product.update) public void handleProductUpdate(Long productId) { // 查询最新数据并写回缓存 Product product productMapper.selectById(productId); String cacheKey hot_product: productId; redisUtil.set(cacheKey, JSON.toJSONString(product), 3600); }注意用“主动更新”策略时如果Redis里的key还没创建或者Redis服务不可用查询链路还是需要兜底的查库逻辑不能假设缓存永远有值。另外建议给这个“永不过期”的key设置一个超长物理过期时间比如7天作为极端情况下的兜底清理防止脏数据永远堆积。三个击穿方案怎么选我的经验是对一致性要求高、并发尖峰在秒级的用互斥锁对RT敏感、能接受短暂旧值的用逻辑过期热点数据本身更新频率低、能走MQ的直接主动更新。实际项目中击穿防护通常是组合的——逻辑过期做主防互斥锁做重建时的兜底。4. 缓存雪崩的系统级防护别让一排key同时倒下4.1 过期时间随机化最便宜最有效的一招雪崩最典型的原因是“同一批key的过期时间完全相同”。解决思路也很简单让过期时间在合理区间内分散。同样是1小时的有效期可以给每个key的过期时间加上一个随机偏移量比如10~300秒这样同一个业务下的key不会集中在同一秒失效。// 分散过期时间基础过期时间 随机偏移量 public void setCacheWithRandomExpire(String key, String value, int baseTtl) { // 基础过期时间 随机0~5分钟偏移 int randomTtl baseTtl ThreadLocalRandom.current().nextInt(0, 300); redisUtil.set(key, value, randomTtl); }这个方案执行成本极低几乎是零代价但能解决由于过期时间集中导致的大部分雪崩场景。千万不要觉得“偏移量太小没用”——即使只随机偏移几秒也能把数据库的瞬时压力摊平不少。压测数据表明集中过期时数据库峰值QPS能到5000随机偏移后峰值能降到1000左右差别非常明显。4.2 多级缓存在数据库前面再焊一道防线很多团队过度依赖RedisRedis一挂或大量失效数据库就直接裸奔。一个更稳健的设计是引入多级缓存本地缓存JVM内存→ Redis → 数据库每一级缓存都为下一级挡住一部分流量。// 本地缓存使用CaffeineTTL设置为Redis缓存时间的1/4左右 Configuration public class CacheConfig { Bean public CacheString, Product localCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(900, TimeUnit.SECONDS) // 15分钟 .build(); } } public Product getProductById(Long id) { // 1. 查本地缓存 String localKey product: id; Product localData localCache.getIfPresent(localKey); if (localData ! null) { return localData; } // 2. 查Redis缓存 String redisKey product: id; String redisValue redisUtil.get(redisKey); if (redisValue ! null) { Product product JSON.parseObject(redisValue, Product.class); localCache.put(localKey, product); return product; } // 3. 查数据库 Product dbProduct productMapper.selectById(id); if (dbProduct ! null) { String json JSON.toJSONString(dbProduct); redisUtil.set(redisKey, json, 3600 randomOffset()); localCache.put(localKey, dbProduct); } return dbProduct; }多级缓存的关键在于本地缓存出现后请求大概率被挡在JVM层Redis即使挂了业务依然可以降级运行。但要注意本地缓存的过期时间应该比Redis短否则更新数据后本地旧数据会持续被读取。我习惯的做法是本地缓存TTL设为Redis的1/4并且每次从Redis取到新值时主动更新本地缓存。4.3 熔断、降级与限流雪崩面前的最后一道防线即使做了过期时间分散和多级缓存仍然要面对一个极端场景Redis集群整体故障或者数据库本身本来就慢。这种时候熔断让请求快速失败比让请求堆积更安全。用Sentinel或Hystrix给每个依赖数据库的读接口设置熔断规则当错误率/慢调用率超过阈值熔断器打开后续请求直接走降级逻辑不再调用数据库。// Sentinel降级规则配置示例 Configuration public class SentinelConfig { Bean public DegradeRule degradeRule() { DegradeRule rule new DegradeRule(getProduct); // 阈值类型慢调用比例500毫秒算慢调用 rule.setGrade(RuleConstant.DEGRADE_GRADE_RT); rule.setCount(500L); rule.setTimeWindow(10); // 熔断10秒 rule.setSlowRatioThreshold(0.7); // 慢调用占比超过70%触发 return rule; } } // 降级兜底缓存也没有、数据库也超时的时候返回默认数据或空数据 public ResultProduct getProductFallback(Long id) { // 从本地备份缓存或其他静态数据源取兜底数据 Product fallback fallbackDataService.getFallbackProduct(id); return Result.success(fallback); }熔断的降级策略得提前想好有的业务可以返回旧数据有的可以返回基础信息有的必须报错。不能所有接口一律返回null前端直接渲染空白用户会以为系统坏了。我的经验是降级必须分级——核心交易链路宁可报错也不返回错误数据非核心的展示链路可以返回缓存旧值或简化数据。这里还要提一个容易被忽略的降级手段Redis持久化与高可用。把Redis做成主从哨兵模式Redis数据开启RDB和AOF双持久化主机宕机后哨兵自动完成主从切换能手动干预的雪崩场景就少了一大半。成本不算高收益却很实在我建议所有没做高可用的Redis环境都尽快补齐。4.4 预防比解决更重要缓存预热与监控告警雪崩最好的治理是“不让自己走到雪崩这一步”。这里给两个非常实用的操作一是上线前做缓存预热。提前把热点数据写入缓存而不是等用户请求来触发。用定时任务在业务低谷期凌晨3点到6点扫描数据库里的热点数据主动填充缓存并给不同key打上分散的过期时间。// 缓存预热Job示例每半小时执行一次 Component public class CacheWarmUpJob { Scheduled(cron 0 0/30 * * * ?) public void warmUp() { // 1. 从数据库或日志中找出最近30分钟访问量Top N的热点key ListLong hotIds statisticService.getHotProductIds(30, 5000); for (Long id : hotIds) { String cacheKey hot_product: id; if (!redisUtil.hasKey(cacheKey)) { Product product productMapper.selectById(id); int ttl 3600 ThreadLocalRandom.current().nextInt(0, 300); redisUtil.set(cacheKey, JSON.toJSONString(product), ttl); } } } }二是建立告警监测体系监控不能只看到“Redis没宕机”就放心更要看“缓存命中率”和“数据库QPS平滑度”。发现命中率异常下降立刻排查是哪个key、哪批key失效了。推荐用Prometheus Grafana搭建Redis实例缓存命中率、数据库读QPS、接口RT的P99分位数、Sentinel熔断触发次数这些指标全都要有面板和告警规则。缓存命中率低于90%触发警告数据库读QPS超过历史基线150%触发警告Redis慢查询数超过阈值触发警告接口RT的P99超过500ms持续5分钟触发严重告警我踩过最大的坑是缓存命中率掉了没人发现等数据库扛不住才从告警里看到“已持续恶化30分钟”。这30分钟里每一秒都在往数据库上多压流量。告警阈值宁紧勿松宁可误报不可漏报。5. 三大问题速查对比表读完就能用的干货这里我整理了一张速查表方便大家贴在工位上随时对照。很多同学面试时把三个问题背得滚瓜烂熟一到线上就分不清当前到底是穿透还是击穿这张表就是用来解决“分不清对不上”的问题。维度缓存穿透缓存击穿缓存雪崩数据是否存在数据库中也查不到数据库中有且是热点数据数据库中有但大量key同时失效影响范围单个key或恶意攻击的多个key单个热点key大面积key或Redis集群故障核心原因缓存数据库都无数据热点key过期瞬间并发回源过期时间集中或Redis宕机请求特性每次请求都打到数据库同一瞬间多个请求打库一段时间内大量请求打库SQL特征结果为空但数据库被无谓查询同一SQL重复查库大量不同SQL同时查库主要防御参数校验布隆过滤器空值缓存互斥锁逻辑过期主动更新过期随机化多级缓存熔断降级即时排查看数据库是否存在该key看Redis中该热点key是否存在看是否大量key同一时间过期这个表在故障排查时非常有用。看一眼数据库里这个ID有没有数据、Redis里key的TTL是什么时间点、失效的是一批还是单个基本就能定位是三类问题中的哪一种。先判断问题类型再选择对应的对策这比急着改代码可靠得多。6. 实战踩坑记录这些细节书本上没有6.1 互斥锁方案中锁过期时间怎么定才不会被反噬互斥锁方案最容易出事的细节就是锁的过期时间。设短了业务还没查完库锁就自动释放了别的线程进来重复查库锁白加了。设长了万一拿锁的线程异常卡住锁要等很久才能释放期间所有请求都在等待接口RT会急剧恶化。我的建议是锁过期时间 预估最长业务耗时 × 2 缓冲余量。比如查库序列化写缓存预计最多花费300ms锁的过期时间就设1秒。同时在finally块里必须释放锁防止业务代码抛异常导致锁不释放。try { boolean locked redisUtil.setIfAbsent(lockKey, requestId, 1, TimeUnit.SECONDS); if (!locked) { // 等待重试... } // 业务逻辑 } finally { releaseLock(lockKey, requestId); }还有更稳妥的做法是给锁加一个“看门狗”续期机制如果业务还没执行完后台线程自动续期锁的过期时间。Java的Redisson就实现了这个机制tryLock时它会自动续期默认30秒的锁。不过看门狗也不是银弹——如果整个进程卡死续期逻辑也执行不了了。所以锁的过期时间不要依赖看门狗无限续期结合业务实际情况设置一个最大执行时限才是正道。6.2 空值缓存导致的数据不一致问题缓存空值虽然能防穿透但也有个隐性的数据一致性问题当一条数据从“不存在”变成“存在”比如用户下了一个新订单商品重新上架空值缓存还在得等它过期后新数据才能被读到。要解决这个问题需要在数据新建的时候主动删除空值缓存。public void createProduct(Product product) { // 1. 写数据库 productMapper.insert(product); // 2. 删除Redis中的空值缓存 String cacheKey product: product.getId(); redisUtil.delete(cacheKey); }类似地数据更新的时候也要注意缓存更新策略。缓存空值的TTL设置得越短不一致窗口越小但防穿透效果也跟着变弱。推荐给空值缓存一个短TTL30~60秒并通过主动删除来保持最终一致。6.3 Redis集群故障时的降级链路要提前演练雪崩中有一类特殊情况Redis集群整体不可用。很多同学以为自己做了“过期时间随机化”就能高枕无忧但Redis宕机时所有key都等于不存在缓存层直接被打穿数据库照样会被压垮。提前演练降级链路非常关键。在压测环境模拟Redis宕机验证以下问题数据库能承受多少QPS没有缓存保护的裸QPS上限是多少熔断降级策略是否能及时触发触发后接口返回什么本地缓存Caffeine是否能扛住大部分流量它的容量和过期策略是否合理Redis恢复后缓存是否会自动回填回填期间数据库压力如何我建议每个月做一次Redis故障演练。不要等到线上真的出事那天才发现降级逻辑有bug——比如本地缓存只在Redis查不到时才写入如果Redis宕机本地缓存也会miss整个调用链还是会打到数据库。正确的做法是Redis不可用时自动切换到“仅本地缓存”模式本地也没有才允许查库。public Product getProductById(Long id) { // 1. 查本地缓存 Product localData localCache.getIfPresent(id); if (localData ! null) { return localData; } try { // 2. 查Redis快速失败不让请求卡在连接超时上 String redisValue redisUtil.get(redisKey); if (redisValue ! null) { Product product JSON.parseObject(redisValue, Product.class); localCache.put(id, product); return product; } } catch (Exception e) { // Redis不可用记录日志并继续走降级链路 log.warn(Redis unavailable, degrade to local cache: {}, e.getMessage()); } // 3. Redis不可用且本地无缓存才允许查数据库并限流保护 if (rateLimiter.tryAcquire()) { return getProductFromDb(id); } return fallbackDataService.getFallbackProduct(id); }Redis客户端的连接超时和读取超时也要专门调优不能让一个Redis请求阻塞线程太久。我习惯把Redis连接超时设为200ms读取超时设500ms一旦出现异常直接跳过Redis走降级不要让线程池被Redis等待占满。6.4 压测时如何验证你的防护策略真的有效最后说一个实操技巧怎么压测验证防护效果。很多同学写完代码就以为搞定了结果压测时发现数据库还是被打穿原因就是测试数据里非法的id太多布隆过滤器加载不全或者空值缓存的命中率太低。我的压测方法是准备混合流量。压测数据里包含三种请求正常的热点数据请求占比80%、存在但缓存已过期的数据请求占比10%、不存在的数据请求占比10%才能真实模拟线上场景。分别压单接口。先只压“不存在数据”请求验证穿透防护是否有效观察数据库QPS是否趋近于0再压热点key过期瞬间的并发验证击穿防护是否有效。看三个指标数据库QPS是否平稳、接口RT的P99是否出现尖峰、缓存命中率是否在健康范围。任何一个指标不达标都说明防线的某个环节有问题。模拟Redis宕机。可以用debug sleep命令或kill -9模拟Redis故障观察降级链路是否按照预期工作。我在一次压测中曾经发现缓存击穿防护在“单机版”正常但切到“集群版”之后失败概率大幅增加排查发现是Redis集群模式下SETNX的分布式锁并没有覆盖到所有槽位后来改用Redisson的RLock才解决。这种问题在压测阶段发现是幸运的要是上线后才暴露就麻烦了。7. 写在最后的一点个人体会缓存穿透、击穿、雪崩这三个问题说到底都是“缓存作为一道屏障屏障失效时必须有备用方案”的思想。我在实际工作中的体会是先保证系统不被打垮再谈缓存命中率和数据一致性。不要一上来就追求所有请求都走缓存而是假设缓存必然会有失效、会有漏洞针对每一类漏洞都设计好兜底方案。我见过很多团队刚开始做缓存时只想着“加上Redis就快了”把过期时间随意一设没有任何防护措施直到线上出了几次事故才回头补功课。其实这三个问题的防护代码量并不大最难的是理解透每个方案背后的取舍——互斥锁保证了强一致但牺牲了RT逻辑过期保证了RT但牺牲了一致性多级缓存扛住了雪崩但增加了更新复杂度。如果你正在设计新的缓存服务我建议直接按照“参数校验布隆过滤器空值缓存互斥锁/逻辑过期过期时间随机化多级缓存熔断降级”这条链路去搭每一步的代码成本都不高但组合起来能让缓存层具备相当强的韧性。如果你是在排查线上问题先别急着改代码去监控上确认是三问题中的哪一种——定位错误比不定位更致命。希望这篇内容对你有帮助。如果你在实际项目中还踩过其他和缓存相关的坑欢迎分享出来大家一起把这个话题聊透。