1. 面试官为什么要把“缓存穿透”当攻防题来打1.1 穿透、击穿、雪崩先分清楚这三个词才有得聊面试官问谢飞机的第一句话是“你在Spring Boot微服务里遇到过Redis缓存击穿吗”说实话这个问题看起来很简单但能一字不差答清楚的候选人并不多。原因在于很多人把缓存穿透、缓存击穿、缓存雪崩这三个词混着用面试官只要追一句“那你这个是穿透还是击穿”节奏就乱了。我一般会用一个例子把三者分开讲。假设有一个用户详情接口正常流程是请求先进Redis查Redis没有就去数据库查查到再写回Redis。缓存穿透是指查询了一个缓存里不存在、数据库里也不存在的key比如攻击者伪造了一堆随机用户ID每次都拿到一个“查不到任何数据”的结果这些请求就完全绕过了缓存直接怼到数据库上。缓存击穿指的是某一个热点key正好在某个时间点过期瞬时大量请求同时涌向这个key缓存没拦住数据库被同一份查询同时打穿。缓存雪崩则是更大范围的故障大量key同一时间失效或者Redis实例整个不可用导致数据库在短时间里扛下所有流量。这三个问题在真实环境里经常叠在一起出现。最典型的情况是某个热点商品在秒杀前被集中访问过滤层没做好攻击者再用一批不存在的SKU做穿透扫描结果Redis里那个热点商品key刚好过期瞬时带宽和数据库连接池同时被打满。面试官喜欢拿这个场景当引子就是因为它能一次性考察候选人对于缓存失效模型的理解、对Redis数据结构的熟练度以及在微服务多实例部署下设计兜底方案的能力。1.2 谢飞机的标准答案框架很熟边界条件却一戳就破谢飞机的第一轮回答其实很漂亮“我会用布隆过滤器过滤掉不存在的key再把查询不到的数据缓存成空值最后用互斥锁保护热点key的回源。”这套组合拳很多网上教程都会写背出来不难但面试官真正想听的是背后怎么做取舍。面试官接着问了一个很实际的场景“你的用户中心有增量数据每天都有新用户注册。如果布隆过滤器初始化时只加载了存量用户ID今天刚注册的新用户第一次查询就被判定为‘不存在’直接把请求返回空这个线上事故怎么处理”谢飞机愣了一下。这正是从“背方案”到“做设计”的分水岭。布隆过滤器确实能高效判断“不存在”但它在业务里最大的代价不是误判而是“误杀”。一个新注册的用户如果没被及时加到过滤器里查询就会被误判为不存在轻则接口返回空数据重则导致注册后无法登录。要想避免这个问题写入链路就必须同步维护布隆过滤器也就是说用户注册、导入、解绑这类写操作都得耦合一份过滤器更新的逻辑。如果服务拆成了多个微服务由不同的团队维护这个同步成本会变得非常痛苦。面试官想考察的其实是候选人有没有站在整个链路上思考问题。缓存方案不是独立的工具选型它要和业务写入路径、数据一致性、异常降级策略绑定在一起。谢飞机知道三个方案的名字却没有第一时间讲清楚它们各自的适用边界这正是很多开发者最容易摔跤的地方。2. 空值缓存、互斥锁、布隆过滤器三条防线的适用边界2.1 空值缓存成本最低但别把“空”随便往Redis里塞空值缓存是解决缓存穿透最直接的办法。逻辑很简单当数据库查不到数据时把“查不到”这个结果也写进Redis设置一个比较短的过期时间下次再遇到同一个key就直接命中缓存不再回源。听起来毫无难度但实际落地时有很多细节。第一个坑是空值怎么存。很多人在代码里直接写redisTemplate.opsForValue().set(key, null)想用null表示空缓存结果读取时发现get返回的也是null根本没办法区分“缓存里没有值”和“缓存里存了一个空值”等于这个缓存白做了。更严谨的做法是约定一个占位符比如字符串EMPTY_TAG或者一段固定结构的JSON比如{code:404,msg:not found}。读取缓存时先判断这个占位符再决定是返回空结果还是反序列化正常数据。第二个坑是TTL设置。空值缓存如果设置得太短比如10秒遇到恶意构造的大批量随机ID每个ID的第一次请求还是会穿透到数据库保护效果有限如果设置得太长比如2小时那些曾经不存在、后来却真实产生的ID就要等缓存过期后才能查到新数据功能性上会出现短暂的“数据延迟”。我个人的经验是空值TTL设置在3到5分钟比较平衡同时业务侧在写操作发生后主动删除对应的空缓存而不是干等它过期。第三个坑是内存放大。仔细想一下空值缓存保护的其实是“重复访问的无效key”。攻击者完全可以生成上百万个不同的无效ID每个ID只查一次Redis里就会积压上百万条空缓存内存被打爆。所以空值缓存只适合作为兜底不能作为第一道防线。它作文本层的意义是让那些通过了前面过滤、最终落到底层查询的“漏网之鱼”不再反复穿透。2.2 互斥锁回源串行化重点是想清楚拿不到锁怎么办互斥锁解决的是另一种问题一个热点key刚过期或者空值刚被写入还没生效同一瞬间有几十个请求发现缓存未命中于是同时回源。如果这几十个请求背后是几千个线程数据库可能当场被打满。互斥锁的核心思路是缓存未命中后先尝试获取一把分布式锁只有拿到锁的线程才允许回源查数据库其他线程要么等锁释放后重新读缓存要么直接返回降级结果。在Spring Boot微服务里最常见的实现是使用Redis的SETNX命令。命令的作用是“如果key不存在才写入”正好可以用来做分布式锁。用setIfAbsent方法设置一个带有超时时间的锁key拿到锁的线程继续往下走没拿到锁的线程进入等待逻辑。这里有三个细节特别容易踩坑。第一锁必须有超时时间。如果线程拿到锁以后因为数据库慢查询卡住了锁又一直没有过期其他线程就会一直等不到结果整个接口在业务高峰期直接“趴窝”。设置超时时间时要参考数据库查询的P99耗时不要拍脑袋写一个固定的10秒还要考虑极端慢查询的兜底。第二释放锁时必须校验持有者身份。A线程加锁成功但锁超时失效了B线程随后也拿到了同一把锁A线程这时才执行完数据库查询在finally块里把锁删除结果把B线程的锁删掉了。这个经典的“误删锁”问题解决办法是加锁时生成一个唯一的请求ID作为value释放锁时用Lua脚本对比value一致才删除。脚本写起来不长但它决定了分布式锁是否真正安全。第三拿不到锁的线程到底该怎么办很多初学版本会写一个while (lock null) { sleep(50); }这样的自旋等待看起来有耐心实际上很可怕。热点key过期的瞬间几百个线程都在自旋重试每个线程都得占用连接池和CPU如果数据库需要2秒才能恢复这些线程就会排队阻塞2秒最终拖垮整个接口。正确的做法是把等待次数控制在有限范围内超过次数直接返回一个明确的降级结果比如“数据繁忙请稍后重试”或者返回本地缓存中的旧数据。2.3 布隆过滤器存在性判断听起来完美但要接受它的三个先天限制布隆过滤器是对抗缓存穿透的另一把利器。它用一个位数组和多个哈希函数来判断某个key是否存在。当你把一个ID加入过滤器时会用若干哈希函数计算出多个位的位置并把它们标记为1查询时同样计算这些位如果发现任何一个位是0就能肯定这个key不在集合里可以直接返回不存在完全不用查数据库。这个数据结构有两个很反直觉的特性。第一布隆过滤器判断“不存在”是确定性的但判断“存在”不是。因为有哈希碰撞可能原本不在集合里的key恰好所有哈希位都被别的元素设置成了1它就会被误判为存在。第二布隆过滤器不能删除元素。一旦某个ID从业务上失效了你没法精确地把它对应的那些位改回0因为那些位可能也被别的元素共享着。这三个先天限制在实际项目里意味着什么呢第一如果要降低误判率就要调大位数组的长度、增加哈希函数的数量这会带来内存和计算成本的上升。第二如果业务数据本身频繁增删布隆过滤器的维护成本会很高因为它不支持删除一旦数据变化你可能需要定期重建过滤器这是个不小的工程。第三选中哪类元素加入过滤器是个业务决策。对用户ID、短订单号这类相对静态的数据布隆过滤器很合适对库存、价格这类高频变化的数据就不太适合。所以布隆过滤器最好的用法不是替代空值缓存和互斥锁而是放在它们前面挡住那些明显不存在的无效key减少底层的穿透流量。至于那些通过了过滤器判断“可能存在”的请求后面再由空值缓存和互斥锁继续兜底。3. Spring Boot微服务里组合防线的落地实现3.1 缓存层的编码准备RedisTemplate配置和Key规范动手写代码之前先把缓存层的配置弄干净。我见过很多项目直接在Service里new一个RedisTemplate或者让多套服务共用同一个Redis键空间结果Key设计混乱序列化方式也对不上排查问题时非常痛苦。一个比较稳妥的做法是配置专门的StringRedisTemplate来处理缓存键和缓存值所有写给Redis的数据都统一序列化成JSON字符串。这样可以避免对象序列化器带来的类型不安全问题。下面是一个常见的RedisConfig配置示例。Configuration public class RedisConfig { Bean public RedisTemplateString, String redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(connectionFactory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); // 统一存JSON字符串读取时自己负责反序列化 template.setValueSerializer(JsonRedisSerializer.INSTANCE); template.setHashValueSerializer(JsonRedisSerializer.INSTANCE); template.afterPropertiesSet(); return template; } }这里有个容易看走眼的地方如果用Jackson2JsonRedisSerializer直接序列化Java对象它默认不会存储类型信息读取时还要额外指定目标类型非常容易踩反序列化异常。与其让Redis帮我们做对象转换不如在代码里自己控制序列化和反序列化把对象转成JSON字符串再写入读取时再解析。这样键、值都是字符串跨语言、跨服务都不会有兼容问题排查时看到的数据也相对直观。Key的命名规范同样重要。微服务架构下多个服务共享Redis实例是很常见的。Key最好用“业务域:模块:业务ID”的方式组织比如uc:user:info:9527。还要定义好空占位的Key规则比如用户信息查询中空值会和正常数据放在同一个Key下只是value变成约定的占位符。这个规则要在服务内部形成书面约定不能靠程序员的临时默契。3.2 核心链路布隆过滤器、空值缓存、互斥锁的组合实现下面这段代码是整篇实战真正能“抄作业”的部分。我把它整理成一个完整的用户查询链路先把检查逻辑拆成四层读取或缓存占位、布隆过滤器拦截、加锁回源、锁等待降级。Service public class UserQueryService { private static final String USER_CACHE_PREFIX uc:user:info:; private static final String USER_LOCK_PREFIX uc:user:lock:; private static final String EMPTY_PLACEHOLDER {\code\:404,\msg\:\EMPTY\}; private static final long CACHE_TTL_SECONDS 30 * 60; private static final long EMPTY_TTL_SECONDS 5 * 60; private static final long LOCK_TTL_SECONDS 10; private static final int RETRY_TIMES 3; Autowired private StringRedisTemplate redisTemplate; Autowired private UserMapper userMapper; Autowired private RedisBloomFilterHelper bloomFilterHelper; public User getUserById(Long userId) { String cacheKey USER_CACHE_PREFIX userId; // 第一层读缓存命中直接返回 String cachedValue redisTemplate.opsForValue().get(cacheKey); if (cachedValue ! null) { if (EMPTY_PLACEHOLDER.equals(cachedValue)) { return null; } return JsonUtil.parseObject(cachedValue, User.class); } // 第二层布隆过滤器拦截明显不存在的ID if (!bloomFilterHelper.exists(uc:user:bloom, String.valueOf(userId))) { return null; } // 第三层尝试获取分布式锁 String lockKey USER_LOCK_PREFIX userId; String lockToken UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockToken, Duration.ofSeconds(LOCK_TTL_SECONDS)); if (Boolean.TRUE.equals(locked)) { try { User user userMapper.selectById(userId); if (user null) { redisTemplate.opsForValue().set(cacheKey, EMPTY_PLACEHOLDER, Duration.ofSeconds(EMPTY_TTL_SECONDS)); return null; } String jsonValue JsonUtil.toJsonString(user); redisTemplate.opsForValue().set(cacheKey, jsonValue, Duration.ofSeconds(CACHE_TTL_SECONDS)); return user; } finally { releaseLock(lockKey, lockToken); } } // 第四层有限次重试拿不到锁就降级 for (int i 0; i RETRY_TIMES; i) { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } cachedValue redisTemplate.opsForValue().get(cacheKey); if (cachedValue ! null) { if (EMPTY_PLACEHOLDER.equals(cachedValue)) { return null; } return JsonUtil.parseObject(cachedValue, User.class); } } throw new DataNotReadyException(数据正在回源请稍后重试); } private void releaseLock(String lockKey, String lockToken) { String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(luaScript, Long.class), List.of(lockKey), lockToken); } }这段代码最需要注意的地方是第二步。布隆过滤器判断“不存在”时直接返回空结果这个逻辑必须建立在一个前提上所有合法的新用户数据都必须在写入数据库时同步加入布隆过滤器。如果不满足这个前提编号3.3里的“误杀”问题就会跑到线上。另外注意第四层的降级策略。我用的是有限次数重试每次休眠50毫秒最多试三次。为什么不是自旋到成功因为在高并发下等待的线程本身也在占用资源。如果真的出现数据库慢查询直接抛出DataNotReadyException让上层返回一个友好提示比无限等下去要合理得多。3.3 写入链路不能脱节新数据入库时同步维护布隆过滤器刚才代码里布隆过滤器直接挡掉了“不存在”的ID这就要求写入链路必须跟上。如果用户服务有一个注册接口注册成功的用户ID必须先写入数据库再更新布隆过滤器这样才能保证后面查询时不会被误判。Service public class UserWriteService { Autowired private UserMapper userMapper; Autowired private RedisBloomFilterHelper bloomFilterHelper; Transactional public Long register(UserDTO userDTO) { User user new User(); user.setName(userDTO.getName()); user.setPhone(userDTO.getPhone()); userMapper.insert(user); // 数据入库后立刻把新ID加入布隆过滤器 bloomFilterHelper.add(uc:user:bloom, String.valueOf(user.getId())); return user.getId(); } }这里我故意把add放在Transactional方法里但不是在数据库事务提交前执行。更严谨的做法是把布隆过滤器的更新放到事务提交后或者通过事务同步器注册一个回调在commit之后执行。因为如果事务还没提交就更新了过滤器另一个线程查询这个新ID布隆过滤器说“存在”但数据库里还查不到又会造成一次无谓回源。说到底布隆过滤器维护的是一份“可能存在”的集合它的准确度依赖数据变更链路是否同步。项目里最好写清楚一个运维约定凡是新增、删除会影响ID有效性的操作都要去同步过滤器。删除场景比较麻烦因为布隆过滤器本身不支持删除如果业务上允许ID复用那就要谨慎使用布隆过滤器或者周期性重建过滤器。3.4 锁的粒度与超时不是所有回源都要加锁细心的读者会发现上面的代码是在布隆过滤器判断“可能存在”之后才去加锁但现实中并不是所有查询都值得加锁。如果一个接口的QPS很低只有偶尔几个人访问某个key那缓存未命中时直接回源就好了加锁反而是浪费一次Redis网络开销。我通常会在设计时区分两类接口一类是低频查询直接采用“缓存未命中就回源并回填”的简单写法另一类是热点查询比如商品详情、用户基本信息、活动配置这类QPS可能冲到几千的接口才需要完整的组合防线。区分标准就是看线上监控里的Redis miss数和数据库QPS。只有当回源量明显异常时才值得上互斥锁和布隆过滤器。互斥锁还有一个粒度问题。有人图省事把整个查询方法都罩上锁连读取缓存这一步都在锁里执行这样并发性能会非常差。正确的方式是先把缓存检查放在锁外面未命中再尝试加锁加锁成功后再二次检查缓存最外面再加一层布隆过滤器。这个“双重检查”的作用是极大概率避免重复回源让锁只保护第一波回源的串行化而不是锁住整个接口。4. 实战现场复盘最容易翻车的几个场景4.1 空缓存占位符和序列化不兼容线上接口直接报错有一个项目里A服务用Jackson序列化把空缓存写成了对象结构而B服务读的时候用的是StringRedisTemplate直接拿字符串去解析结果抛出了类型转换异常。听起来很低级但微服务里因为不同服务各自维护序列化逻辑这个问题非常常见。解决办法是统一缓存层的数据契约。我建议在项目里约定Redis里所有value默认存储JSON字符串空结果统一使用一个预定义好的JSON片段比如{code:404,msg:EMPTY}任何服务读取时先判断这个结构再决定是返回空还是继续反序列化。另外一个比较实用的技巧是给缓存值加上版本前缀比如{v:1,data:{}}这样以后如果数据结构变化读取端还能根据版本号做兼容处理不至于一上线就报错。4.2 错误锁操作直接DEL锁Key导致误删别人的锁锁释放是分布式锁环节里最容易出问题的地方。很多Spring Boot项目早期代码里写的是拿到锁直接redisTemplate.delete(lockKey)在单实例、低并发下看起来没问题一旦多个实例部署锁的寿命超过业务执行时间误删锁就成了必然。正确姿势是释放锁之前先校验锁的value。加锁时的value要用全局唯一的token释放时用Lua脚本做原子性的“get then del”。之所以要用Lua脚本是因为“查询锁值”和“删除锁Key”这两个动作之间可能发生并发变化必须保证它们是原子的。我上文中releaseLock方法用的就是标准写法。还有一种更隐蔽的情况锁超时了但拿到锁的线程还在执行慢查询。线程A拿到锁执行了12秒锁在第10秒过期线程B拿到同一把锁这时线程A终于查询完在finally里执行Lua脚本释放锁但锁的value已经变成B的token比对失败不会删掉B的锁。这算是“误删锁问题自动解除了”但代价是两个线程同时回源保护效果打了折扣。处理思路有两个一是结合业务情况设置合理的锁超时时间二是对长时间执行的回源做异步续期让锁在业务真正完成前不失效。4.3 极端热点下互斥锁不够用需要二级本地缓存有一类接口回源逻辑牵扯到多个数据库表和远程调用单次回源耗时可能到几百毫秒。这种情况下即使加了互斥锁一个拿到锁的线程在回源期间其他线程也在等待接口的平均响应时间依然会被拖得很高。极端热点的key比如热搜、排行榜、爆款商品锁并不是终点。我在实际项目中会引入一层本地缓存也就是Caffeine或者类似的内存缓存放在Redis前面。查询顺序变成本地缓存命中就返回本地缓存未命中再看RedisRedis还没有才走布隆过滤器和互斥锁回源。同时用一个后台任务定时刷新热点key的本地缓存比如每5秒自行回源一次把最新的数据推到本地缓存里。这样即使Redis里的key过期了本地缓存仍然能扛住大部分请求回源线程量被压到极低。Configuration public class LocalCacheConfig { Bean public CacheString, String userLocalCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(30)) .build(); } }本地缓存也不能滥用它需要每台机器都存一份数据多实例环境下存在数据不一致的问题。使用时只适合在“允许短暂过期、容忍秒级延迟、读取量极大”的场景比如商品快照、用户昵称这类不是强一致要求的数据。像余额、库存这种对实时性要求高的数据还是要老老实实回源。4.4 验证防线是否有效从压测结果反推设计方案代码写完了到底有没有效果要看压测数据不能靠感觉。我用一个模拟项目验证过这套组合防线条件很简单2000个并发线程反复查询一个不存在的用户ID同时随机生成一批不存在的ID模拟攻击请求。先测了不加任何防御的原始方案数据库QPS几乎和请求量持平连接池迅速被打满。然后逐层加方案得到的结果非常直观只加空值缓存第一批请求全部回源后相同的无效ID后续不再打库但攻击者不断换新ID数据库仍然有大量穿透流量。加空值缓存互斥锁热点key的并发回源被串行化数据库瞬时压力明显下降但无效ID带来的流量还是存在。全套组合防线布隆过滤器把绝大多数不存在的ID拦截在第0层空值缓存处理偶发的过滤器“可能存在”误判互斥锁保证第一波回源只有一条线程数据库QPS趋近于0接口响应时间非常稳定。这里面的经验是没有任何一个方案是银弹穿透问题的本质是流量根本没到Redis就被放过去了所以第一道防线必须设在“数据是否存在”的校验上。而击穿问题的本质是热点key过期的瞬间并发集中所以要用互斥锁和局部热点缓存来兜底。5. 面试结尾的追问如果Redis本身不可用了呢面试进行到这里谢飞机已经能把布隆过滤器、空值缓存、互斥锁讲得比较清楚了。面试官最后又问了一个开放性问题“假如服务还在但Redis集群整个不可用了几分钟你的防线还剩几层”这个问题其实是把这个话题引向更大的范围。缓存穿透、击穿、雪崩解决的都是Redis正常工作下的故障模型但Redis本身也可能宕机、故障转移、网络抖动。如果Redis整体不可用空值缓存和布隆过滤器都没办法工作唯一还能依靠的是每个服务节点上的本地缓存以及写代码时就准备好的降级逻辑。这也是为什么我会在真正的生产项目里把本地缓存作为第一层而不是把所有希望都寄托在Redis上。我的个人习惯是给关键查询接口准备一个“除了缓存之外的默认兜底”比如从只读库查一次、返回本地缓存里的旧数据、或者返回一个降级的默认内容。这样在Redis故障期间接口不至于完全不可用。平时这个兜底逻辑不显眼但出了大故障时它才是保证系统不挂的最后一道防线。面试官问谢飞机的问题本质上也是在问每个开发者你写的缓存代码到底是给正常运行的系统锦上添花还是给极端情况下的系统雪中送炭想清楚这个问题才算真正把缓存穿透的攻防思路用明白了。