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 RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, 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前面。查询顺序变成:本地缓存命中就返回;本地缓存未命中再看Redis;Redis还没有才走布隆过滤器和互斥锁回源。同时用一个后台任务定时刷新热点key的本地缓存,比如每5秒自行回源一次,把最新的数据推到本地缓存里。这样即使Redis里的key过期了,本地缓存仍然能扛住大部分请求,回源线程量被压到极低。
@Configuration public class LocalCacheConfig { @Bean public Cache<String, 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故障期间,接口不至于完全不可用。平时这个兜底逻辑不显眼,但出了大故障时,它才是保证系统不挂的最后一道防线。
面试官问谢飞机的问题,本质上也是在问每个开发者:你写的缓存代码,到底是给正常运行的系统锦上添花,还是给极端情况下的系统雪中送炭?想清楚这个问题,才算真正把缓存穿透的攻防思路用明白了。