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的序列化器设置成StringRedisSerializer,value的序列化器设置成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,缓存里被写入了一个空字符串""。那第二次再来请求同一ID,StrUtil.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里的SETNX:key不存在时设置成功返回true,key已经存在说明别人持有锁,返回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透传给Service,Service内部先查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策略、序列化方式都清清楚楚。这也是黑马点评虽然是个学习项目,但它的工程组织思路值得借鉴的原因。