缓存穿透与缓存击穿的工程化治理:从空值兜底到互斥锁的代码拆解
1. 先分清穿透和击穿同样是缓存失效破坏路径完全不一样做后端几年的人应该都有过这种经历线上一个接口平时稳得很突然某个时间点MySQL的CPU飙到100%慢查询日志刷屏紧接着报警群就炸了。查来查去发现Redis明明还有大半内存没用可数据库就是扛不住了。这种场景十有八九不是Redis本身的问题而是缓存这一层没把流量拦住让请求直接穿到了数据库。黑马点评这个项目里针对这两类经典问题专门设计了处理逻辑配合CacheClient工具类把缓存读写、序列化、空值兜底、逻辑过期这些事统一封装起来。我先把话放在前面缓存穿透和缓存击穿看着名字像实际上是完全两种病治疗方案不能混着来。这篇文章就围绕这两套方案的代码逐行拆顺便把工具类里那些看似不起眼、实际全是细节的写法讲透。适合看这篇的人有两类一类是刚学完Redis基础、准备做缓存治理实战的Java开发者另一类是项目里已经出现了缓存穿透或击穿苗头、想赶紧补方案的运维或后端同学。1.1 穿透查了一个根本不存在的东西缓存穿透的官方定义是请求查询一个缓存和数据库中都不存在的数据缓存永远无法命中每次请求都会直接落到数据库。这句话翻译成人话就是——有人按了一个根本不存在的门牌号去敲门每次都会敲门询问物业物业手里没有这个人的信息又问了一圈邻居还是没人认识白白跑断了腿。这个有人通常有两种来源。第一种是真的用户误操作比如前端传了一个已经被删除的商品ID。第二种就麻烦一点是恶意攻击者故意构造大量不存在的ID去扫接口这些ID往往还是自增的、有规律的扫描起来成本极低但对数据库来说是致命的。你没有任何缓存能兜住这种请求因为缓存里本来就永远不会有这些key。黑马点评里处理穿透的核心思路就是查不到数据库就在缓存里放一个空值给这个空值一个很短的过期时间。这样下一次再来查同一个不存在的ID缓存直接命中空值返回null数据库彻底歇着了。1.2 击穿热点key在过期瞬间被流量集中打穿缓存击穿的官方定义是某个热点key在过期的那一瞬间大量请求同时涌入缓存没命中全部穿透到数据库。注意三个关键词热点key、过期瞬间、大量并发。它不是一直打而是卡在缓存刚失效的那个时间窗口里。还是拿敲门来类比。一栋楼只有一部电梯平时大家排队坐没什么问题。但某天电梯刚好在早高峰开始时故障停运了整个楼的人全去挤楼梯楼梯口瞬间堵死。Redis缓存就是那部电梯热点key失效的那个瞬间等于电梯停了而楼下还排着几百号人。这类问题的可怕之处在于它不像穿透那样是持续的、平均的流量而是短时间内的超高并发集中在同一个key上。像秒杀商品的库存、热点新闻的详情、直播间的在线人数都属于这种类型。如果数据库每秒只能扛500个QPS结果同一时刻来了5000个请求那数据库基本是秒挂的。黑马点评的互斥锁方案本质就是在这个电梯故障的瞬间让第一个到达的人先去修电梯其他人老老实实排队等着修好了再一起坐电梯。1.3 为什么这两个问题要在同一个工具类体系里解决穿透和击穿都是缓存没能挡住请求的问题解决的形态也都是围绕缓存怎么写、写什么来展开。所以黑马点评项目里把缓存写入、序列化、空值设置、逻辑过期这些公共能力统一收拢到CacheClient里业务层只需要关心自己的查询逻辑缓存怎么存、存多久、过期时间怎么设全部交给工具类。我之前见过不少项目缓存操作散落在各个Service里今天这个类用RedisTemplate明天那个类用Spring Cache后天又冒出个直接用Jedis的。一旦出现穿透或者击穿排查起来就是一场灾难——因为你会发现没有一个统一入口去控制缓存key的格式、TTL策略和空值处理逻辑。黑马点评这套设计也正是我在实际项目中推荐的做法先把缓存基础设施抽象清楚再去谈具体的治理方案。2. CacheClient怎么组织一套set方法打通所有缓存写入口先看CacheClient工具类的核心代码。这个类在项目里承担的是缓存写入统一出口的角色整个类并不复杂但几个关键选择直接影响后面所有缓存方案能不能跑得通。Component public class CacheClient { private final StringRedisTemplate stringRedisTemplate; public CacheClient(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } public void set(String key, Object value, Long time, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(value), time, unit); } public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) { RedisData redisData new RedisData(); redisData.setData(value); redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(redisData)); } }这段代码看起来短但它决定了整个项目的缓存风格。我一个个说。2.1 为什么选StringRedisTemplate而不是RedisTemplate很多新手写Redis缓存时习惯直接用RedisTemplate然后登录Redis客户端一看key变成了一串类似\xac\xed\x00\x05t\x00\x0c的乱码value也带一堆二进制前缀。这就是JDK序列化留下的杰作。黑马点评里用的是StringRedisTemplate理由很简单所有key和value都以字符串形式存可读性强谁去线上排查都看得懂。key本来就是字符串value统一用JSON.toJSONString序列化后续反序列化用JSON.toBean回来。省掉了RedisTemplate里那套默认的JdkSerializationRedisSerializer从根源上避免了乱码问题。提示如果你现在维护的老项目里已经用了RedisTemplate并且出现了乱码不是你写错了是序列化器没配对。解决方案是自定义RedisTemplate把key的序列化器设置成StringRedisSerializervalue的序列化器设置成Jackson或FastJson而不是把代码推倒重写。2.2 JSON序列化与统一缓存写入再看这个set方法它做了三件事把对象转成JSON字符串、设置key、带上过期时间。三个参数一次搞定业务方调的时候根本不用关心Redis底层怎么操作。这里有个很重要的设计惯性缓存里存的值是序列化后的字符串而不是对象。为什么因为Redis本身就只能存字符串你往里放对象它也得序列化。主动用JSON序列化存进去的内容至少还能被人读、被其他语言解析如果让默认序列化器处理存进去的就是一堆完全不可读的二进制出了问题连调试都无从下手。2.3 setWithLogicalExpire方法的额外价值setWithLogicalExpire是击穿场景的另一种解法——逻辑过期。它把过期时间存进一个RedisData对象里随数据一起写入缓存而不是依赖Redis原生的TTL秒杀机制。这里我先不展开逻辑过期的代码只讲一个点这个方法与互斥锁方法并存在CacheClient里并不冲突。互斥锁解决击穿逻辑过期也解决击穿只是适用场景不一样。工具类把两种写缓存的姿势都准备好业务层按需选择这才是封装的意义。等到了第六节我再把这两个方案的取舍掰开来讲。3. 缓存穿透解决实操空值兜底短TTL代码逐行拆工具类准备好了接下来看业务层怎么用它解决穿透。黑马点评的商铺查询场景对应的就是ShopServiceImpl里的queryWithPassThrough方法。这个方法在我眼里是教科书级别的穿透示范。public Shop queryWithPassThrough(Long id) { // 1. 查询缓存 String key CACHE_SHOP_KEY id; String shopJson stringRedisTemplate.opsForValue().get(key); // 2. 缓存命中直接返回 if (StrUtil.isNotBlank(shopJson)) { Shop shop JSONUtil.toBean(shopJson, Shop.class); return shop; } // 3. 存在空值说明数据库也没有直接返回null if (.equals(shopJson)) { return null; } // 4. 查询数据库 Shop shop getById(id); if (shop null) { // 5. 数据库中不存在缓存空值TTL设为2分钟 stringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } // 6. 数据库存在写入缓存TTL设为30分钟 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return shop; }3.1 第一步到第三步三级判断每一级都在挡流量第一个判断StrUtil.isNotBlank(shopJson)过滤掉所有正常命中的请求。这里用isNotBlank而不是isNotEmpty意味着空字符串、null、纯空格字符串全都会被拦下来。第二个判断.equals(shopJson)出现的时机很巧妙。你可以想象一下第一次请求某个不存在的ID走完了整个方法数据库返回null缓存里被写入了一个空字符串。那第二次再来请求同一IDStrUtil.isNotBlank()是false直接落进第二个判断发现这是个空值立刻return null连数据库的毛都摸不到。这就是缓存穿透解决方案的神奇之处第一次请求虽然打到了数据库但后续所有相同请求全被空值缓存挡住。攻击者就算拿一万个不存在的ID来扫每一个ID最多也只让数据库崩溃一次之后就全成了缓存命中的空值拦截。3.2 空值为什么要带2分钟的TTL必须带TTL否则空值会永远堆积在Redis里变成垃圾数据。你想一下一个不存在的ID如果永远有缓存空值业务上如果哪天真把这个ID对应的数据创建出来了用户看到的还是空值数据永远不更新这就是缓存与数据库的一致性事故。2分钟这个数字是有讲究的太短比如几秒钟攻击者用不同的时间窗口反复打同一批ID空值还没活多久就过期了形同虚设太长比如几小时又会积累大量无效key。黑马点评给的2分钟在大多数业务里是既能拦得住瞬时攻击又不至于堆积垃圾数据的折中值。实际项目里我也会建议在1到5分钟之间根据业务调整。3.3 数据库返回null时直接return null有什么意义第六步里shop null时的操作有两行写空值、return null。有些同学会问既然都准备写空值了还return null干什么直接走完方法不行吗这里恰恰是代码的干净之处。如果只写空值不return后面的第6步还会执行stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), ...)这个set传入的是null——虽然JSONUtil.toJsonStr(null)会返回null字符串不会报错但缓存里就会出现一个值为null的key而不是空字符串。更关键的是这会让逻辑变得混乱后续判断空值的分支就不好写了。先return把分支彻底走干净再也不用担心逗号后面跟什么。4. 缓存击穿解决实操互斥锁二次检查重建过程只能有一个线程进穿透解决完了接下来是硬仗——缓存击穿。黑马点评里处理击穿的核心方法是queryWithMutex也就是互斥锁方案。它的核心思想很朴素让第一个发现缓存失效的线程去查数据库重建缓存其他线程在锁外面等着等缓存重建完了再放行。public Shop queryWithMutex(Long id) { // 1. 查询缓存 String key CACHE_SHOP_KEY id; String shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } if (.equals(shopJson)) { return null; } // 2. 尝试获取互斥锁 String lockKey LOCK_SHOP_KEY id; Shop shop null; try { boolean isLock tryLock(lockKey); if (!isLock) { // 3. 获取锁失败休眠后重试 Thread.sleep(50); return queryWithMutex(id); } // 4. 获取锁成功二次检查缓存 shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 5. 查询数据库模拟重建耗时 shop getById(id); if (shop null) { stringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } Thread.sleep(200); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { // 6. 释放锁 unlock(lockKey); } return shop; }锁本身的实现很简单利用的就是Redis的SETNX命令private boolean tryLock(String key) { Boolean flag stringRedisTemplate.opsForValue().setIfAbsent(key, 1, 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } private void unlock(String key) { stringRedisTemplate.delete(key); }4.1 tryLock、wait、double check三段节奏为什么缺一不可先说tryLock。它调用了setIfAbsent对应Redis里的SETNXkey不存在时设置成功返回truekey已经存在说明别人持有锁返回false。注意这里带了10秒的过期时间这一步非常关键——如果没带过期时间万一拿到锁的线程在执行过程中宕机了锁永远不会释放后面所有线程都会卡死在休眠重试里这就是经典的死锁事故。有了10秒过期最坏情况也就是锁自动失效代价是可能有少量线程重复查库但系统不会整体挂掉。再说sleep(50) 递归重试。拿不到锁说明有另一个线程正在重建缓存当前线程的选择不是干等而是睡50毫秒再重新走一遍queryWithMutex。等它醒过来大概率缓存已经重建完第一次查缓存就直接命中了。50毫秒这个数字也合理既能避免空转消耗CPU也不会让请求等待太久。如果把睡眠时间调到500毫秒接口的响应时间就会被明显拖长。最后说二次检查也叫double check。初次查缓存时还没拿到锁等拿到锁之后缓存可能已经被刚才那个持锁线程重建好了。如果拿着锁再去查一次数据库等于白白浪费了一次DB查询更麻烦的是还会把已经写好的缓存再覆盖一遍。所以拿到锁后必须重新读一次缓存命中就直接返回没命中才轮到当前线程查库重建。4.2 用线程池压一下看看数据库到底被打了多少次光看代码说服力不够我模拟过这个场景。用一个CountDownLatch让50个线程同时等同一个信号然后在缓存过期的一瞬间放行让它们全部去请求同一个商铺ID。实测结果不加锁的版本数据库被打了50次有一半以上的线程都跑到getById(id)这行。加锁的版本数据库被打的次数是1次其余49个线程要么在休眠重试要么在唤醒后直接命中了已经被重建的缓存。这就是互斥锁的威力。注意我这里用的是随机休眠而不是固定休眠主要是防止大量等待线程在同一个时间点一起醒来出现缓存刚写好、所有线程同时命中的和谐假象后面跟着的下一次击穿。现实中用固定50ms也能跑通但加上一点随机性更接近真实流量特征。4.3 锁粒度只锁当前商铺ID千万别一把锁锁全表看到LOCK_SHOP_KEY id这个写法没有锁的key是跟着业务ID走的。如果项目里有100个商铺同时过期理论上最多有100个线程分别去查不同的商铺数据互不干扰。要是抽象出一个全局锁LOCK_SHOP_KEY那所有过期商铺的重建工作都会串行执行本来可以并行的DB查询全被堵在一个锁上性能直接回到解放前。这一点在黑马点评的代码里体现得很自然但我在很多真实项目里见过把锁粒度做大的案例。原则就一个能锁key就别锁表能锁单条就别锁整片。5. 一条请求从Controller到DB整个调用链路的串联逻辑前面拆的都是方法内部的逻辑但很多读者会好奇这些方法是怎么被串起来的谁在调用它们整个链路从HTTP请求进来到数据库查询再到结果返回经历了哪些环节5.1 从ShopController到ShopServiceImpl的调用链标准的调用链路是这样的RestController RequestMapping(/shop) public class ShopController { Resource private IShopService shopService; GetMapping(/{id}) public Result queryShopById(PathVariable(id) Long id) { return shopService.queryById(id); } }Controller里的queryById统一收口所有商铺查询请求然后交给Service。Service里再根据业务场景选择不同的缓存方案Override public Result queryById(Long id) { // 演示代码根据实际需要选择穿透方案或击穿方案 Shop shop queryWithMutex(id); return Result.ok(shop); }这就是整个链路的入口。一个普通的GET请求进来Controller把ID透传给ServiceService内部先查Redis、再决定走DB还是直接返回最终把结果组装成统一返回结构给前端。5.2 击穿方案在真实接口里怎么切换实际项目里不会让所有接口都用击穿方案。热点数据、秒杀商品、库存数据这类短时间高并发的key才值得用互斥锁而普通的文章详情、用户信息、登录状态用穿透方案的空值兜底就够了。黑马点评把选择权留在了Service层——你想用哪个方法在queryById里改一行调用就行。这也是工具类业务方法的组合优势方案是你业务决定的但缓存基础操作永远是那套工具类。6. 这套方案在生产落地前必须想清楚的几个坑代码讲完了我再泼几盆冷水。黑马点评是学习项目里面的参数定得都比较理想放到真实生产环境有几个坑不提前想清楚很容易线上翻车。6.1 空值TTL设太短会被反复打透设太长会积累海量垃圾key黑马点评里CACHE_NULL_TTL是2分钟这个值在教程场景里没问题但真实项目要结合攻击频率来定。我见过一个真实案例某个接口被脚本用几万个随机订单号疯狂扫描空值缓存2分钟就过期脚本大概每过2分钟就能重新打透一批数据库一直处于半死状态。后来把空值TTL调到了5分钟虽然还是不完美但DB的压力肉眼可见地降下来了。另一个反面案例是有人图省事把空值TTL设成了24小时结果数据库中真的新增了对应ID的数据用户端却一直查不到。所以空值TTL要么短要么在后端做数据变更时主动删除对应空值缓存的补偿逻辑。写空值的那一刻就要想好它什么时候被清除、被谁清除。6.2 用StringRedisTemplate时容易出现的序列化问题前面提到过黑马点评用StringRedisTemplate就是为了规避RedisTemplate的JDK序列化乱码。但即便用StringRedisTemplate也有一个常见坑手动把对象转JSON时如果对象里有Date、LocalDateTime这类时间字段序列化格式处理不好缓存的值会变得很长很乱反序列化时还可能报错。黑马点评里用的是JSONUtil/FastJson遇到时间字段最好统一配置一下日期格式别依赖默认输出。另一个就实践上是缓存里存VO视图对象不要直接存DO数据对象。DO里可能带了数据库的隐藏字段比如逻辑删除标记、内部状态码这些直接暴露在缓存里没意义还有被外部反序列化读取的风险。6.3 锁的过期时间与线程标识是两个反直觉的坑先说锁过期时间。10秒是我习惯用的值但这不是拍脑袋拍的而是根据查一次数据库重建缓存的最长耗时来估算的。比如你的DB查询耗时一般在50ms到100ms加上网络开销和序列化200ms以内肯定能完成那锁的过期时间给10秒就是妥妥的保险。反过来如果业务里一次DB查询要跑3秒你把锁过期时间也设成3秒就会出现锁提前释放、多个线程同时进临界区的情况——击穿没防住反而多了个锁失效事故。再说线程标识。上面演示用的unlock是无条件删除锁key这在常规场景够用但有个隐患如果一个线程持锁时间超过了锁的过期时间锁被系统自动释放了此时另一个线程拿到了锁、还没干完活第一个线程跑完了finally里的delete就把第二个线程的锁删掉了。这就是经典的误删他人锁。解决方式也很简单Redis官方都给你想好了释放锁前先判断锁的value是不是自己线程设置的标识是则删否则不删。用UUID或者线程ID拼一个唯一标识写进锁的value删除前读取比对一下即可。6.4 互斥锁方案和逻辑过期方案到底怎么选黑马点评的CacheClient里同时提供了set和setWithLogicalExpire对应互斥锁和逻辑过期两种击穿治理思路。这两个方案各有各的适用场景我总结成一张表维度互斥锁方案逻辑过期方案缓存一致性强一致缓存和DB数据同步更新弱一致缓存过期时间由业务逻辑控制DB已更新但缓存可能还是旧值实现复杂度低基于SETNX实现中需要定义RedisData包装对象还要额外维护一个重建线程池高并发行为请求大概率阻塞等待热点key重建期间接口有等待感请求直接返回旧数据后台线程异步重建缓存接口感知不到等待适合场景对数据一致性要求高、重建耗时短的场景流量极大、允许短时间读到旧数据的场景黑马点评的setWithLogicalExpire走的正是逻辑过期思路它对Redis命中但数据已过期的请求不会等待而是直接返回旧数据同时让线程池去异步重建缓存。这在秒杀、大促这样的场景里非常舒服——用户永远能拿到数据哪怕旧了那么一点点也远比让用户干等要好得多。我个人的经验是如果不能确定业务属于哪种类型先用互斥锁方案因为它逻辑简单、不容易出岔子。等确认了业务的流量模型和数据一致性诉求再考虑升级到逻辑过期。先把正确的跑通再去做性能优化顺序不能反。回到CacheClient本身这个工具类最大的价值不是某一套方案写得有多惊艳而是它把所有缓存写入动作统一到了几个方法里让业务层在穿透和击穿之间自由切换。我当时在真实项目里把散落各处的缓存代码收拢成这样一个工具类之后线上排查缓存问题的效率提升了一大截——因为你知道凡是缓存操作入口只有一个所有的key格式、TTL策略、序列化方式都清清楚楚。这也是黑马点评虽然是个学习项目但它的工程组织思路值得借鉴的原因。