
做黑马点评项目时最让我头疼的不是优惠券秒杀的分布式锁而是缓存穿透和缓存击穿这两个看似不起眼、却能把数据库拖垮的问题。项目里那个 CacheClient 工具类把空值缓存、互斥锁、逻辑过期这几套经典解法浓缩成了几十行通用代码既解决了业务问题又不需要在每个 Service 里复制粘贴。这篇就结合 CacheClient 的源码逐行拆给大家看说清楚每段代码为什么这么写、参数怎么定、实际会遇到哪些坑。看完你不仅能给面试官讲明白缓存穿透和缓存击穿的区别还能直接照着这套思路写自己的通用缓存工具类。1. 为什么黑马点评要把缓存治理单独封装成一个工具类1.1 先看清楚穿透和击穿不是一回事很多初学者把缓存穿透、缓存击穿、缓存雪崩混为一谈面试时一紧张就讲成一团。其实这三个问题的触发点和解决思路完全不同理解清楚是做 CacheClient 的前提。缓存穿透是指请求的数据在缓存和数据库中都不存在。比如商铺查询接口攻击者拿着一个不存在的商铺 ID比如负数、超长随机数疯狂刷请求Redis 里查不到于是一层层打到 MySQL。MySQL 也查不到但又不能把没有这个结果告诉上层下次同样的请求还会再来。这种请求的特点是量大、key 不重复、永远查不到数据库压力就这样被凭空打出来了。缓存击穿是指某个热点 key 在缓存过期的瞬间大量并发请求同时发现缓存失效于是全部冲向数据库。黑马点评里的典型场景就是爆款商铺平时所有请求都命中缓存一旦缓存过期同一时刻可能有几百上千个查询同时去数据库捞数据。缓存击穿只发生在热点 key 上普通 key 压根不会触发这个问题。至于缓存雪崩那是大量 key 同时过期导致数据库被打爆解决思路是给过期时间加随机值、做多级缓存。缓存穿透和击穿是单点问题处理后端数据库压力雪崩是整体问题处理的是缓存层的集体失效。CacheClient 里对穿透和击穿分别做了处理我先说它为什么要被封装成工具类。1.2 CacheClient 在项目里承担的角色黑马点评的商铺、店铺、商品等模块都需要走同样的缓存逻辑先查缓存缓存没有就查数据库查完写缓存。如果没有 CacheClient每个 Service 里都要写一遍这段逻辑而且缓存穿透和击穿的处理代码很容易写得不一样——有人忘了缓存空值有人忘了加锁代码质量全靠个人自觉。CacheClient 做的事情很纯粹把查缓存、判断、查库、回填这套骨架抽成通用方法不同业务只需要传入 key 前缀、ID、实体类型、以及一个查数据库的函数式接口Function。这样一来商铺模块和商品模块共用同一套缓存治理逻辑修一个 bug 全项目受益。这是典型的模板方法模式思路只不过用 Java 的函数式接口实现得更加轻量。还有一点值得提为什么不直接引入 Caffeine 或者 JetCache 这样的现成缓存框架黑马点评本身是教学项目手写一遍缓存穿透和击穿的解决方案是为了让人真正理解底层原理。面试时你能把互斥锁、逻辑过期这套东西讲清楚比我用了 XX 框架有价值得多。但要知道生产环境如果追求稳定和效率是可以在此基础上替换为更成熟的组件的CacheClient 的价值在于它把思路讲透了。2. 缓存穿透空值缓存方案拆解queryWithPassThrough2.1 逐行解析 queryWithPassThrough 核心代码CacheClient 中处理缓存穿透的方法叫 queryWithPassThrough思路非常直接数据库查不到数据时把一个空值也写进缓存并设置一个较短的过期时间。下次再有相同 key 的请求过来Redis 直接返回空值请求不会穿透到数据库。public R, ID R queryWithPassThrough(String keyPrefix, ID id, ClassR type, FunctionID, R dbFallback, Long time, TimeUnit unit) { // 1. 拼接完整的缓存 key String key keyPrefix id; // 2. 查询缓存 String json stringRedisTemplate.opsForValue().get(key); // 3. 判断缓存是否存在且不为空 if (StrUtil.isNotBlank(json)) { // 4. 缓存命中直接反序列化返回 return JSONUtil.toBean(json, type); } // 5. 判断是否是空值缓存缓存穿透时写入的 if (json ! null) { // 6. 说明缓存中存的是空串直接返回 null return null; } // 7. 缓存未命中查询数据库 R r dbFallback.apply(id); // 8. 数据库不存在缓存空值并设置较短过期时间 if (r null) { stringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } // 9. 数据库存在写入缓存并返回 this.set(key, r, time, unit); return r; }这段代码里有几个细节特别值得注意。第一步到第三步先用 StrUtil.isNotBlank(json) 判断缓存是否有值。这里是拿 JSON 字符串判断不是拿反序列化后的对象判断目的是区分缓存里存的是空串和缓存里根本没有 key两种情况。如果改成直接判断对象是否为空空值缓存和缓存未命中就分不开了缓存穿透的兜底逻辑形同虚设。第五步的 json ! null 是关键。当 json 不为 null 但 isNotBlank 为 false说明缓存里存的是一段空白字符串这就是之前缓存穿透时写入的空值。此时直接返回 null不要再查数据库。第八步数据库也查不到时往 Redis 里写入空字符串 而不是 null。原因很朴素Redis 的 value 如果直接存 null下次 get 的时候返回的也是空无法区分缓存里存了 null和缓存里没有这个 key所以要用一个具体的值来表示这里确实缓存过空结果。2.2 两个关键参数空值过期时间为什么不能太长queryWithPassThrough 里要传入业务缓存的过期时间 time 和 unit而空值缓存用的则是 CACHE_NULL_TTL黑马点评中这个值通常设置为 5 分钟左右。为什么空值缓存的时间要比正常数据缓存短这么多最直接的原因是数据恢复问题。如果某个商铺 ID 只是因为暂时没上架而查不到过几分钟上架了结果 Redis 里那个空值缓存要 30 分钟才过期这 30 分钟内所有对该商铺的查询都会拿到空结果用户看着就是这家店不存在。设置短一点的过期时间能尽快让真实数据有机会重新回填。第二个原因是内存成本。穿透请求往往是恶意或异常的key 可能非常多如果空值缓存也用 30 分钟甚至更长的过期时间Redis 里会被塞满大量毫无意义的热点空 key。5 分钟已经足够阻挡多数短时攻击流量同时不会对内存造成太持久的压力。我在实际项目里还会在空值过期时间后面加一点随机抖动比如 3 到 7 分钟随机取值。这样做的目的是避免大量空值 key 在同一秒过期防止它们集中过期时又形成一次小规模的穿透波峰。CacheClient 里如果没做随机自己接业务时可以考虑补上这一点。2.3 穿透方案之外的防线校验和布隆过滤器空值缓存是缓存穿透的第一道防线但不是唯一防线。如果所有非法请求都靠先让数据库查一次再缓存空值来处理数据库还是会先承受一轮攻击流量。所以实战中通常会在进 CacheClient 之前再加一层前置校验。黑马点评的业务是商铺查询ID 一般是正整数。在 Service 层就可以先做参数校验ID 小于等于 0 直接返回失败根本不给缓存层和数据库层添乱。这是一个几乎零成本、却能挡掉大量低级扫描请求的手段。遇到更复杂的场景比如用户 ID 是 UUID、无法简单判断合法性那就要考虑布隆过滤器了。布隆过滤器的思路是先把所有可能存在的数据 ID 预加载到一个很省内存的位图结构里请求进来先问布隆过滤器这个 ID 存在吗如果它说不存在那一定不存在直接拦下如果它说存在才继续走缓存和数据库。CacheClient 里没有集成布隆过滤器因为黑马点评的商铺 ID 是有限的、可预知的整数空值缓存已经足够。但如果你做的是一个用户量巨大、ID 不可预知的系统空值缓存就只能兜底一部分流量前面必须再架一层布隆过滤器。这两者不是二选一而是配合使用。3. 缓存击穿互斥锁方案拆解queryWithMutex3.1 用 setnx 模拟互斥锁把并发请求串行化缓存击穿的根源是热点 key 过期后的瞬间并发。最简单的解决思路就是互斥锁让第一个发现缓存过期的请求去查数据库并回填缓存其他请求在锁外等待等缓存回填完成后直接读新缓存而不是每个人都冲到数据库。CacheClient 里的 queryWithMutex 就是干这个的。public R, ID R queryWithMutex(String keyPrefix, ID id, ClassR type, FunctionID, R dbFallback, Long time, TimeUnit unit) { String key keyPrefix id; // 1. 查询缓存 String json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toBean(json, type); } // 2. 缓存未命中可能是击穿产生的瞬时状态 // 3. 尝试获取互斥锁 String lockKey lock: keyPrefix id; String lockValue UUID.randomUUID().toString(); Boolean isLock stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); // 4. 获取锁失败说明有其他线程正在重建缓存短暂休眠后重试 if (!BooleanUtil.isTrue(isLock)) { ThreadUtil.sleep(50); return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit); } // 5. 获取锁成功再次检测缓存double check try { json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toBean(json, type); } // 6. 查数据库并回填缓存 R r dbFallback.apply(id); if (r null) { stringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } this.set(key, r, time, unit); return r; } finally { // 7. 释放锁 stringRedisTemplate.delete(lockKey); } }这段代码比 queryWithPassThrough 多了几层逻辑每一层都有明确目的。第二步到第三步缓存未命中后并没有立刻查数据库而是先尝试获取锁。setIfAbsent 是 Redis 的 setnx 命令含义是只有当 key 不存在时才设置成功。利用这个特性把 lockKey 当作锁谁能成功写入这个 key谁就拿到了锁。锁的 value 存一个 UUID是为了后面释放锁的时候确认身份防止误删别人刚设置的锁。第四步获取锁失败说明已经有别的线程在重建缓存。这里选择休眠 50 毫秒后递归调用自身重新走一遍完整逻辑。递归重试的好处是代码简洁坏处是如果数据库一直很慢递归层数会增多理论上存在栈溢出风险。我在生产环境更喜欢改成 for 循环重试逻辑等价但更安全。第五步非常关键叫 double check。线程拿到锁之后不能立刻认为缓存肯定还是空的因为可能出现这种情况线程 A 拿到锁去查库线程 B 一直自旋等待A 回填完缓存、释放锁后B 才拿到锁。如果 B 不做 double check 直接去查库就白抢一次锁数据库压力也白受了。所以拿到锁之后必须先看一遍缓存如果已经有值了说明别的线程已经重建完毕直接返回即可。3.2 锁的粒度、超时时间与误删问题互斥锁方案里有几个设计细节很容易被忽视但它们直接决定方案能不能在生产环境站稳。首先是锁的粒度。CacheClient 里 lockKey 是 lock: keyPrefix id也就是说每个商铺 ID 一把锁。这样做的好处是不同商铺的缓存同时过期时各自的查询互不干扰不会出现全局只有一个锁导致所有缓存重建全部串行的问题。锁的粒度越细系统的并发能力越好。坏处是锁会占用一点 Redis 内存但锁过期时间很短10 秒成本可以忽略。其次是锁超时时间的选择。锁设了 10 秒过期这是为了防止持锁线程在查数据库时挂掉锁永远不释放导致其他线程死等。这个值一定要大于查数据库 反序列化 写缓存的预估耗时。黑马点评的单表查询加 JSON 序列化一般几十毫秒就完成了10 秒绰绰有余。但如果你在 dbFallback 里做了复杂的聚合查询或者远程调用10 秒可能不够需要结合实际压测调整。这里有个平衡锁超时太短慢查询没结束锁就自动释放其他线程又会冲进来重复重建锁超时太长持锁线程一旦真挂了其他线程等待的时间就会很长。还有一个经典坑是锁误删。释放锁的时候不能直接 stringRedisTemplate.delete(lockKey)必须判断当前线程持锁的 value 是否等于 Redis 里存的 value相等才删除。考虑一个时间线线程 A 拿到锁后执行很慢锁 10 秒超时自动释放了线程 B 又拿到锁value 是 B 的 UUID此时 A 终于执行完在 finally 里执行 delete直接把 B 的锁删掉了。然后线程 C 也能拿到锁锁完全失效。CacheClient 里简化了这一步生产环境建议用 Lua 脚本保证判断 value 并删除是一个原子操作或者用 Redisson 这类自带看门狗机制的重入锁。3.3 互斥锁方案的优缺点和适用场景互斥锁方案的核心思路是牺牲一点可用性换取强一致性。在缓存未命中的瞬间只有持锁线程能查库其他线程都在自旋等待这会稍微增加接口响应时间。好处是数据一致性强——最终写入缓存的都是数据库里的真实数据不会出现逻辑过期方案那种用户读到旧数据的情况。它对代码的要求也比较低不需要预热缓存也不需要设计额外的过期字段任何 key 都可以在运行时自然经历过期→加锁→重建的流程。所以它特别适合数据一致性要求高、热点 key 数量不大、并发峰值可控的场景。不过要注意互斥锁方案并没有消除数据库的压力只是把压力从并发同时打变成了串行挨个打。如果热点 key 背后是一个复杂且耗时的查询比如需要聚合多张表那么即使只有一个线程在查库数据库响应也可能很慢下游服务还是要被拖住。这种情况下就要考虑后面要说的逻辑过期方案了。4. 缓存击穿进阶逻辑过期方案拆解queryWithLogicalExpire4.1 核心思想缓存不过期过期时间写在 value 里互斥锁方案虽然能挡住击穿但它有一个天然缺陷缓存过期的那一瞬间依然有一批请求在等待锁用户体验会有可感知的耗时。逻辑过期方案换了个思路——缓存本身不设置物理过期时间而是在缓存的数据结构里额外存一个逻辑过期时间。请求进来时判断逻辑时间是否过期如果没过期就直接返回数据如果过期了则返回旧数据的同时在后台异步把新数据刷新进缓存。因为缓存的物理 key 永不过期所以正常情况下所有请求都直接命中缓存响应速度极快也不存在过期瞬间所有请求同时查库的问题。换句话说逻辑过期方案牺牲了数据的强一致性换取了极高的可用性。CacheClient 中定义了一个 RedisData 类来承载这个结构Data public class RedisData { private LocalDateTime expireTime; private Object data; }写入缓存时把业务数据和过期时间一起序列化成 JSON。读取缓存时先反序列化成 RedisData再判断 expireTime 是否晚于当前时间。这里要特别注意 RedisData 里的 Object 字段在 JSON 序列化和反序列化时必须指定业务数据的实际类型否则反序列化出来的是 JSONObject强转成 Shop 对象时会报类型转换异常。CacheClient 里的写法是先用 JSONUtil.toBean(json, RedisData.class) 把外层结构解析出来再取出 data 字段用 JSONUtil.toBean((JSONObject) redisData.getData(), type) 转成真正的业务对象。为了让逻辑过期方案生效缓存必须提前预热。因为逻辑过期只会在缓存已存在但逻辑时间过期的路径上生效如果缓存从来就没有数据那就是另一条空缓存处理逻辑。所以黑马点评里会在项目启动时或商品上架时手动调用 setWithLogicalExpire 把热门数据提前放入缓存确保用户请求到来时缓存是热的。4.2 重建缓存流程独立线程 互斥锁 double check下面这段代码是逻辑过期方案的核心。它和 queryWithMutex 最大的不同在于缓存过期后请求线程不等待锁直接返回旧数据真正负责查库和回填的是后台线程。private static final ExecutorService CACHE_REBUILD_EXECUTOR Executors.newFixedThreadPool(10); public R, ID R queryWithLogicalExpire(String keyPrefix, ID id, ClassR type, FunctionID, R dbFallback, Long time, TimeUnit unit) { String key keyPrefix id; // 1. 查询缓存 String json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { // 缓存不存在说明还没预热先按未命中的方式处理 return null; } // 2. 反序列化 RedisData RedisData redisData JSONUtil.toBean(json, RedisData.class); R r JSONUtil.toBean((JSONObject) redisData.getData(), type); LocalDateTime expireTime redisData.getExpireTime(); // 3. 判断逻辑过期 if (expireTime.isAfter(LocalDateTime.now())) { // 4. 未过期直接返回旧数据 return r; } // 5. 已过期尝试获取互斥锁 String lockKey lock: keyPrefix id; boolean isLock tryLock(lockKey); if (isLock) { // 6. double check拿到锁后再看一次缓存防止重复重建 try { json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { RedisData freshData JSONUtil.toBean(json, RedisData.class); if (freshData.getExpireTime().isAfter(LocalDateTime.now())) { return JSONUtil.toBean((JSONObject) freshData.getData(), type); } } // 7. 交给独立线程异步重建缓存 CACHE_REBUILD_EXECUTOR.submit(() - { try { R newR dbFallback.apply(id); this.setWithLogicalExpire(key, newR, time, unit); } catch (Exception e) { throw new RuntimeException(e); } finally { unLock(lockKey); } }); } catch (Exception e) { unLock(lockKey); throw new RuntimeException(e); } } // 8. 无论是否拿到锁都先返回旧数据 return r; }这段代码最巧妙的地方在第五步到第八步。当缓存逻辑过期时每个请求都会尝试抢锁但只有抢到锁的线程才会触发异步重建任务没抢到锁的线程直接拿着旧数据返回。这样既避免了大量线程重复查库又不会让用户干等锁释放接口响应时间几乎不变。后台线程池 CACHE_REBUILD_EXECUTOR 用了固定 10 线程的池子。这个大小需要根据热点 key 的数量和数据库查询耗时来调如果热点 key 非常多、数据库比较慢10 个线程可能不够用队列会积压重建任务。生产环境一般建议使用带线程名前缀的 ThreadPoolExecutor 自定义线程池方便日志排查同时要设置合理的拒绝策略比如 CallerRunsPolicy让提交任务的线程自己执行或者丢弃最旧任务。还值得说的是 double check。在异步任务真正执行前再次读缓存并判断逻辑过期时间是为了防止这样的场景线程 A 抢到锁在提交异步任务前卡了一下此时 L1 的过期时间还没到线程 B 也抢到锁不会因为锁还在。更常见的场景是A 提交任务后快速释放了锁但异步任务还没真正执行到 setWithLogicalExpire此时线程 C 拿到锁double check 时发现逻辑过期时间仍是过去的就又会提交一个重建任务。要完全堵住这种情况把锁释放挪到异步任务执行完之后更严谨。CacheClient 里锁是在异步任务 finally 中释放的所以上面分析的窗口其实是被堵住的写自己的版本时这一点千万别省略。4.3 互斥锁和逻辑过期到底选哪个这两个方案都能解决缓存击穿但适用场景完全不同。我用下面这张表做个对比你面试时可以直接拿来用对比维度互斥锁方案逻辑过期方案数据一致性强一致缓存永远是最新数据弱一致过期后短期内返回旧数据接口响应耗时缓存重建瞬间有等待可能变慢几乎无额外耗时用户体验稳定实现复杂度简单只要 setnx 加锁中等需要 RedisData 结构和异步线程池数据库压力瞬时串行重建压力较集中异步后台重建压力分散对缓存预热要求不需要预热必须预热否则缓存缺失处理逻辑复杂适用场景一致性要求高、并发峰值可控热点数据、读多写少、允许短暂不一致我自己的经验是如果项目里热点 key 就十几个数据库是单库且性能一般直接用互斥锁代码量少、心智负担低如果热点数据非常多、接口 QPS 很高或者下游数据库真的顶不住瞬时流量那就上逻辑过期。两者不冲突甚至可以并存比如普通商铺用互斥锁、爆款商品用逻辑过期。5. 黑马点评实战中的常见问题与排查记录5.1 缓存穿透方向空值缓存没生效怎么办我见过最多的问题是代码明明写了查不到就缓存空值但压测时数据库 QPS 依然暴涨。排查思路一般集中在这几个点。第一个检查点空值是否真的写进 Redis 了。用 redis-cli 直接 get 一下对应的 key如果返回 (nil)说明代码根本没有执行 set 操作。常见原因是判断数据库结果为 null 的时候提前 return 了没走到缓存空值的分支。看一下出问题的代码分支即可定位。第二个检查点空值缓存是否被后续代码覆盖了。如果服务里还有别的线程或定时任务在同 key 上写数据可能某个空值刚写进去就被新数据覆盖或者反过来空值把正常数据覆盖了需要检查所有写缓存的地方保证同一个 key 的写入语义一致。第三个检查点是空值缓存的时间。如果空值缓存时间设置得比业务缓存还长或者干脆没设过期时间Redis 内存会悄悄涨上去。我在排查内存增长问题时发现过线上有大量空值 key 占用了几 GB 内存就是因为空值没有 TTL。黑马点评是教学项目没暴露出来生产环境一定要给空值也设过期时间并及时监控。还有一个隐藏问题有些 SQL 框架的查询结果即使记录不存在也会返回一个空对象而不是 null只是对象里的字段为空。如果用这个空对象去判断就会误判为数据存在然后把空对象缓存起来并返回给用户表现成接口一直拿到空数据。排查时要打断点或者打印日志确认 dbFallback 返回的到底是 null 还是空对象。5.2 互斥锁方向锁失效、死锁、自旋卡顿互斥锁方案出问题大多集中在锁生命周期的管理上。最常见的是死锁和锁误删我在 3.2 小节已经详细聊过。这里再补充一个我在实际联调中遇到的场景锁获取失败后直接递归重试但如果数据库此时响应很慢一次完整流程可能要 200 毫秒递归深度会很大。虽然 JVM 默认栈深度足够但递归里每层都带着当前方法的局部变量长期高并发下会有额外的栈内存开销。建议改成 while 循环重试并在重试计数超过一定次数时返回友好错误避免线程无限自旋。另一个容易忽略的是锁 key 的清理。正常情况下锁会在 finally 中 delete但如果在获取锁之后、进入 try 之前抛了异常锁可能就漏删了。例如 StringRedisTemplate 的 setIfAbsent 返回的 Boolean 是 null底层网络异常时会出现BooleanUtil.isTrue 会把它当 false 处理然后线程直接走重试分支锁倒是没误用但也没进入 try不会产生泄漏。可如果 setIfAbsent 成功、后面拼接查询逻辑时抛了异常而异常没有被 try 包住那就漏删锁了。所以标准写法一定是获取锁成功后立即进入 tryfinally 中释放锁任何中间代码都不能放在 try 外面。再深入一点如果项目里同时有多个 JVM 实例基于 Redis 的互斥锁是跨实例生效的setnx 天然支持分布式。但要注意所有实例必须使用同一个 Redis且 setnx 的 key 结构完全一致否则锁就失效了。黑马点评是单体应用这个问题不明显上集群后一定要注意 key 的拼接规则统一。5.3 逻辑过期方向序列化、预热、线程池问题逻辑过期方案对序列化要求更苛刻。RedisData 里含 LocalDateTime 字段如果 JSON 序列化工具没有注册 Java Time 模块LocalDateTime 会变成一串数字或者直接报错。黑马点评里使用了 Hutool 的 JSONUtil需要确保底层 Jackson 配置支持 JSR310 模块。解决办法有两个把 expireTime 换成 Long 类型的时间戳比如 System.currentTimeMillis() 过期毫秒数彻底绕开 LocalDateTime 的序列化问题或者统一配置序列化器注册 JavaTimeModule。我个人在生产环境更偏好时间戳简单直观也不会因时区问题产生偏差。预热问题是逻辑过期方案最容易被人吐槽的。如果你只在启动时预热了部分热点数据而用户突然访问了一个没预热过的数据缓存里压根没有 RedisDataqueryWithLogicalExpire 里的 StrUtil.isBlank(json) 就会走返回 null 的分支用户直接拿到空结果。解决思路是在预热时把热门数据写进去再在缓存缺失的分支里兜底查一次数据库并回填带逻辑过期的数据虽然这本质上又走了一次缓存穿透逻辑但不至于让业务完全断掉。线程池的问题也很典型。固定线程池如果被重建任务打满后面的任务会进入队列等待而队列如果无界内存可能被积压任务撑爆如果有界积压满了会触发拒绝策略。黑马点评里用的是简单 newFixedThreadPool(10)生产环境建议自定义 ThreadPoolExecutor设置有界队列、合理的拒绝策略以及给线程命名方便排查。我当时就是因为没给线程池命名日志里出现异常时完全分不清是哪个业务触发的重建任务排查了很久才定位到问题。5.4 给初学者的调试建议从日志到监控以上问题都建立在你能看到系统状态的基础上。黑马点评本地开发时建议在 CacheClient 的几个关键分支都加上日志缓存命中、缓存穿透写入空值、获取锁成功、获取锁失败重试、逻辑过期触发重建。日志里带上 key 和当前线程名这样你能肉眼看到并发请求是怎么被锁拦住的也能看到逻辑过期方案中后台线程是否真的在重建缓存。生产环境则要配上 Redis 的监控指标命令 QPS、内存使用率、key 过期数量。配合数据库的慢查询日志和连接数监控一旦出现缓存穿透或击穿你能快速从数据库连接数飙升 Redis QPS 降低这对信号里判断是缓存层失守了。我见过很多工程师本地测试一切正常上线后出问题才发现连 Redis 命令统计都没开排查起来跟盲人摸象一样。6. 关于这套方案我在项目里的实际体会黑马点评的 CacheClient 是我见过把缓存穿透和缓存击穿讲得最清楚的教学代码。它的价值不在于代码本身有多炫技而在于用最简单的 Redis 命令setnx、get、set把两个高并发经典问题的解法落地了。我自己后来在真实的电商项目中做缓存治理底层思路几乎就是从这套代码里长出来的只是把锁替换成了 Redisson、把 JSON 工具换成了定制序列化方案、把线程池改成了可观测的 ThreadPoolExecutor。如果你正准备面试我建议你不仅要把这两个方案背下来还要自己能手写出 queryWithMutex 和 queryWithLogicalExpire 的骨架以及能解释清楚为什么逻辑过期方案要配 double check 和独立线程池。能把为什么讲明白比把代码贴出来值钱得多。最后分享一个小技巧给 CacheClient 做单元测试时别只测正常命中链路一定要用 mock 的方式模拟数据库查不到缓存过期瞬间这两个极端场景并且把 Redis 的超时时间设置得很小来验证锁的自动释放。这些边界情况才是这套代码真正值钱的地方。