
缓存穿透这个问题很多后端开发第一步想到的方案就是“查不到就缓存空值”。这个思路本身没有错也确实能挡住一部分流量但如果你的系统已经处于高并发场景或者正在被恶意请求持续攻击缓存空值只能算及格离专业还有一段距离。原因很简单缓存空值解决的是“单个 key 反复穿透”的问题而真正的缓存穿透往往是大批量、多 key 维度、甚至带随机化后缀的请求。这种场景下空值缓存既占内存又有过期窗口还会污染缓存语义。所以更准确的判断是只会用缓存空值说明还没把穿透治理当成一个系统性问题来对待。这篇文章会用 Java Redis RedisBloom 的组合完整拆解几种缓存穿透治理方案的实现路径、适用边界和验证方法。内容包括缓存空值怎么落地、布隆过滤器怎么接入、互斥锁怎么防止并发击穿、限流怎么兜底以及一套可以在测试环境直接跑通的压测观察流程。看完之后你应该能清楚判断自己系统该选哪种方案而不是笼统地只知道“缓存空值”四个字。1. 核心方案速览方案拦截层次解决的问题实现成本防御强度参数校验接口层过滤明显非法的请求极低低缓存空值缓存层已查询过的空 key 二次穿透极低一般布隆过滤器缓存前置一定不存在的 key 直接拦截中强互斥锁缓存回填热点 key 并发击穿数据库中强限流降级网关/应用层异常流量兜底保护中强从表格可以看到不同方案解决的是不同层次的穿透问题。缓存空值处理的是“查过且不存在”的 key布隆过滤器处理的是“根本不存在”的 key互斥锁解决的是“存在但缓存过期瞬间被并发打爆”的 key限流则是最后一道安全网。真正的生产环境大多数情况下是多个方案叠加使用而不是只选一个。2. 缓存穿透的成因、危害与治理边界2.1 穿透的请求链路缓存穿透的典型链路是请求进入系统后先查缓存缓存未命中然后查询数据库数据库也不存在该记录于是本次请求返回空结果并且没有任何写回动作。单次请求这样做没有问题问题在于高频重复。如果请求端是脚本或者恶意用户他们可以伪造大量不存在的 userId、orderId、skuId每个请求都绕过缓存直接打到数据库。数据库连接数和 CPU 会随之飙升严重时直接拖垮数据库进而影响所有依赖这个数据库的业务。这种攻击的典型特征就是 key 高度随机、总量大、单 key 请求次数少。正因为单 key 请求次数少缓存空值方案的效果才大打折扣——攻击者可以不断换新 key让你缓存里的空值越来越多但数据库被访问的总次数并没有降下来。2.2 缓存空值方案的四个硬伤缓存空值的基本做法是数据库查询结果为空时仍然把一个空值对象写入缓存并设置一个较短的过期时间。后面再遇到相同的 key直接从缓存返回空不再打数据库。这个方案的硬伤主要有四个。第一内存被无效 key 占据。攻击者一旦构造海量不同 keyRedis 里就会积压大量空值记录内存占用持续上升。如果空值 TTL 设置过长Redis 内存淘汰压力会很大。第二需要一个短 TTL 的空窗期。为了不让空值长期占内存TTL 一般设得很短比如 60 秒。但 TTL 一过攻击者再次循环请求同一个 key数据库又会被打到。第三缓存语义被污染。缓存中出现大量代表“空”的占位符业务代码要额外判断“现在拿到的是缓存回填的临时值还是数据库真正的结果”很容易在后面的逻辑里埋坑。第四无法区分“空结果但合法”和“空结果但异常”。比如数据还没生成、请求参数非法、数据库超时查不到这些场景全部被空值缓存统一吞掉了排障难度直线上升。2.3 什么时候需要升级方案如果你的系统并发量低、业务 key 数量有限、没有恶意攻击风险缓存空值加合理的 TTL 完全够用。但出现以下信号时就应该考虑升级监控里数据库的 select 查询 QPS 出现不符合业务规律的突刺接口平均响应时间周期性抖动且集中在数据库慢查询Redis 内存里出现大量无业务含义的 key日志里出现大量“不存在数据”的判定分支。这些信号说明穿透已经从偶发变成了常态空值缓存已经无法兜住。3. 环境准备与前置条件3.1 基础组件本文示例基于 Java Spring Boot StringRedisTemplate Jedis数据库使用 MySQL。实际上换成任何语言和 Redis 客户端思路都一样。环境需求如下组件作用版本建议JDK运行 Spring Boot 服务8 或 11 即可Redis缓存与锁的存储6.0 以上RedisBloomRedis 布隆过滤器模块2.2 以上MySQL业务数据源按业务现状压测工具验证效果wrk 或 JMeterRedisBloom 模块可以直接下载编译后的 so 文件在 redis.conf 里通过loadmodule加载。加载完成后用BF.ADD、BF.EXISTS等命令做一次冒烟测试确认模块已经生效。3.2 启动前校验清单部署前逐项确认Redis 能连通RedisBloom 模块可用业务表具备主键索引联表查询具备复合索引数据库连接池最大连接数已知避免压测时无感知打爆服务具备基本的缓存封装层不要把 Redis 操作散落在业务代码里日志链路里能定位到“缓存未命中”“数据库已查”“空值已回填”三个关键节点。这些前置条件准备完毕再开始改代码。4. 缓存空值方案先用最短路径跑通4.1 实现逻辑缓存空值方案的 Java 实现核心思路是查询用户详情时如果数据库返回 null也写入一个标记对象并设置短 TTL。下面是一段可直接运行的模板代码表名和 DAO 需要按实际项目替换。Service public class UserServiceImpl implements UserService { private static final String USER_CACHE_KEY user:detail:; private static final String USER_NULL_CACHE_KEY user:null:; private static final long NULL_TTL_SECONDS 60; private static final long DATA_TTL_SECONDS 300; Autowired private StringRedisTemplate redisTemplate; Autowired private UserMapper userMapper; public UserVO getUserById(Long userId) { // 1. 先查正常缓存 String cacheKey USER_CACHE_KEY userId; String json redisTemplate.opsForValue().get(cacheKey); if (json ! null) { return JSON.parseObject(json, UserVO.class); } // 2. 查数据库 User user userMapper.selectById(userId); // 3. 查不到也写缓存TTL 设置短一点 if (user null) { redisTemplate.opsForValue() .set(USER_NULL_CACHE_KEY userId, EMPTY, NULL_TTL_SECONDS, TimeUnit.SECONDS); return null; } // 4. 回填真实数据缓存 redisTemplate.opsForValue() .set(cacheKey, JSON.toJSONString(user), DATA_TTL_SECONDS, TimeUnit.SECONDS); return convert(user); } }这里有一个容易被忽略的细节空值 key 和非空 key 必须使用不同的前缀。如果共用同一个 key用户从不存在变为存在时业务侧可能读到历史写入的空值标记导致数据不更新。4.2 效果验证代码发布到测试环境后按下面的步骤验证连续多次调用一个不存在的 userId 接口观察第一次查询走了数据库后续查询是否全部命中 Redis进入 Redis 客户端执行keys user:null:*确认空值 key 已写入等待 TTL 过期再次请求同一个 userId确认数据库再次被查询。第 4 步验证完成后你会直观看到空值缓存的“临时有效”特性。它是帮你挡了 60 秒流量但 60 秒后的下一个请求数据库依然会被打到。4.3 这个方案的边界缓存空值最适合的场景是热点不集中的内部系统、低频查询的管理后台、或者 key 总数可控的业务。这类场景穿透量本身不大短 TTL 的空值缓存完全能扛住。但它不适合高并发的 C 端接口。原因前面已经说过攻击者能构造无限新 key空值缓存的内存增长不可控数据库仍然会被高频查询。测试环境压测时你可以尝试用脚本生成 10 万个随机不存在的 key 并发请求观察 Redis 内存和数据库 QPS结论会很明显。5. 布隆过滤器拦截一定不存在的 key如果说缓存空值是在“查完之后”做补救布隆过滤器就是在“查询之前”做拦截。原理是把业务里所有可能存在的 key 提前放入一个 bitmap 结构查询时先判断 key 是否可能存在。返回不存在那一定不存在返回可能存在才继续往下走。5.1 RedisBloom 初始化使用 RedisBloom 模块初始化一个容量为 100 万、误判率 1% 的过滤器BF.RESERVE user:bloom 0.01 1000000命令格式为BF.RESERVE key error_rate capacity。容量设置需要按业务预估误判率越低占用的内存越大。日常测试可以先用小容量方便观察效果。Java 侧初始化代码Configuration public class RedisBloomConfig { private static final String BLOOM_KEY user:bloom; Value(${redis.host:127.0.0.1}) private String host; Value(${redis.port:6379}) private int port; PostConstruct public void initBloom() { try (Jedis jedis new Jedis(host, port)) { jedis.bfReserve(BLOOM_KEY, 0.01, 1_000_000L); } catch (Exception e) { // 过滤器已存在时会抛出异常忽略即可 } } }这段代码在服务启动时保证过滤器存在。要注意的是BF.RESERVE只能调用一次重复调用会报错所以实际项目中要加上存在性判断或者忽略异常。5.2 读路径接入查询路径调整为先走布隆过滤器不存在直接返回存在才继续查缓存和数据库。public UserVO getUserByIdWithBloom(Long userId) { // 0. 布隆过滤器前置判断 try (Jedis jedis new Jedis(host, port)) { boolean maybeExists jedis.bfExists(BLOOM_KEY, userId.toString()); if (!maybeExists) { return null; } } // 1. 正常缓存逻辑 String cacheKey USER_CACHE_KEY userId; String json redisTemplate.opsForValue().get(cacheKey); if (json ! null) { return JSON.parseObject(json, UserVO.class); } User user userMapper.selectById(userId); if (user null) { // 布隆过滤器已经挡掉了绝大多数不存在的key // 这里仍然保留空值缓存是为了兜住“合法但暂时无数据”的key redisTemplate.opsForValue() .set(USER_NULL_CACHE_KEY userId, EMPTY, NULL_TTL_SECONDS, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue() .set(cacheKey, JSON.toJSONString(user), DATA_TTL_SECONDS, TimeUnit.SECONDS); return convert(user); }这段代码的实现重点在于布隆过滤器和空值缓存不是二选一而是叠加。布隆过滤器拦掉随机攻击流量空值缓存兜住“合法 ID 但暂时没有数据”的场景。5.3 批量预热与全量构建布隆过滤器需要提前“记住”业务里所有合法 key否则真实存在的用户也会被误拦。这个构建过程是一个典型的批量任务不能靠手动一条条添加。Service public class BloomFilterWarmUpService { private static final String BLOOM_KEY user:bloom; Autowired private UserMapper userMapper; /** * 全量重建布隆过滤器建议低峰期执行 */ public void warmUp() { try (Jedis jedis new Jedis(host, port)) { int pageSize 1000; long lastId 0; while (true) { ListLong ids userMapper.selectIdsAfter(lastId, pageSize); if (ids null || ids.isEmpty()) { break; } // 使用 pipeline 批量提交避免逐条网络往返 Pipeline pipeline jedis.pipelined(); for (Long id : ids) { pipeline.bfAdd(BLOOM_KEY, id.toString()); } pipeline.sync(); lastId ids.get(ids.size() - 1); } } } }实际生产环境建议定时全量重建而不是增量添加。原因很简单标准布隆过滤器不支持删除如果系统里存在大量逻辑删除或失效数据过滤器里的“已存在”标记会越积越多误判率会被放大。全量重建虽然成本高但结构干净。批量预热要考虑分页性能。selectIdsAfter这种基于主键游标的分页方式比传统的 limit offset 更快适合大数据量场景。如果表数据在千万级别建议把全量重建放到离线任务里执行。5.4 容量与误判率设计容量和误判率直接决定内存占用。布隆过滤器的内存估算可以用近似公式内存大小(bit) ≈ 1.44 * n * log2(1/p)其中 n 是预估元素数量p 是期望误判率。比如要放 100 万个元素误判率 1%需要的 bit 数大约是1.44 * 1000000 * log2(100) ≈ 1.44 * 1000000 * 6.64 ≈ 956 万 bit ≈ 1.14 MB所以布隆过滤器在内存上的优势非常明显一百万个元素只要 1MB 左右。这个数字可以让你放心把容量预留得大一些因为内存成本很低。真正要小心的不是内存而是“误判会把真实存在的数据拦掉”这件事。注意布隆过滤器的误判方向是“可能存在但实际不存在”它不会把真实存在的 key 误判成不存在。所以用户数据的正确性不受影响最多是多放行一些不存在的请求到缓存层和缓存空值方案形成嵌套兜底。6. 互斥锁解决单 key 并发击穿布隆过滤器能挡掉“不存在”的 key但挡不住“存在但缓存刚好过期”的热点 key。热门数据在缓存过期的瞬间如果同时来几百个请求它们全部查缓存未命中然后一起打数据库。这个问题叫击穿和穿透是两回事但经常一起出现。互斥锁就是用来解决这个问题的。6.1 加锁回填核心思路是缓存未命中时先尝试获取一个分布式锁。只有拿到锁的线程才能查数据库并回填缓存其他线程则短暂等待后重试。public UserVO getUserByIdWithLock(Long userId) { String cacheKey USER_CACHE_KEY userId; String json redisTemplate.opsForValue().get(cacheKey); if (json ! null) { return JSON.parseObject(json, UserVO.class); } String lockKey lock: cacheKey; String lockValue UUID.randomUUID().toString(); // 使用 set nx ex 原子加锁 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (!locked) { try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 没拿到锁递归重试一次 return getUserByIdWithLock(userId); } try { // 双检拿到锁后再看一次缓存防止重复回填 json redisTemplate.opsForValue().get(cacheKey); if (json ! null) { return JSON.parseObject(json, UserVO.class); } User user userMapper.selectById(userId); if (user null) { redisTemplate.opsForValue() .set(USER_NULL_CACHE_KEY userId, EMPTY, NULL_TTL_SECONDS, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue() .set(cacheKey, JSON.toJSONString(user), DATA_TTL_SECONDS, TimeUnit.SECONDS); return convert(user); } finally { // 释放锁时必须校验是否为当前线程持有的锁 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), Collections.singletonList(lockKey), lockValue); } }这里有几个实现细节必须注意。锁的 value 用 UUID释放锁时用 Lua 脚本先校验再删除。这是为了防止一个线程的锁过期后另一个线程拿到了同名的锁第一个线程却把第二个线程的锁删掉。锁的过期时间要大于回填缓存的总耗时否则业务还没查完数据库锁就自动释放了其他线程趁虚而入。递归重试会造成深层次的栈调用生产环境建议改成 while 循环加最大重试次数防止极端情况下无限递归。6.2 锁的释放与线程安全上面代码里的finally块和 Lua 脚本是必须的不能偷懒直接调用delete(lockKey)。直接删除的问题在于锁到期后线程 A 还没执行完线程 B 拿到锁开始执行此时线程 A 结束并删除了 lockKey相当于把线程 B 的锁误删了线程 C 又拿到锁形成连锁问题。每次加锁设置 10 秒过期是一个通用配置。如果你的回填逻辑里包含复杂查询或远程调用建议单独评估把过期时间调大到 30 秒甚至更长或者使用 Redisson 这类带看门狗机制的客户端自动续期。6.3 补充限流兜底即使加了互斥锁数据库在缓存空窗期仍然要承受一次查询。恶意流量如果绕过合法 ID 检测大量请求同一批 ID 区间互斥锁只能保证“每个 key 只放一个线程进去”但不同 key 的请求仍然可以直接穿透。这种情况下需要在接口入口加一层限流。Component public class RateLimitInterceptor implements HandlerInterceptor { private final RateLimiter limiter RateLimiter.create(500); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!limiter.tryAcquire()) { response.setStatus(429); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:429,\msg\:\too many requests\}); return false; } return true; } }限流可以放在应用层也可以放在网关层。生产环境更推荐使用 Sentinel 或网关限流组件通过配置动态调整阈值不需要改动业务代码。这里给的是 Guava RateLimiter 的简化演示说明拦截器接入方式。7. 组合架构缓存穿透治理的完整链路7.1 分层职责单个方案都有短板生产环境普遍采用分层组合层级组件核心职责L1 接入层参数校验、限流过滤非法请求、控制峰值流量L2 拦截层布隆过滤器挡掉一定不存在的 keyL3 缓存层Redis 缓存 空值缓存承接合法请求的读流量L4 回源层互斥锁防止单个热点 key 并发打爆数据库L5 数据库连接池监控、慢查询告警识别问题、快速止损实际请求链路呼叫方发出的请求先经过参数校验非法的直接拒绝随后过限流超阈值的直接降级返回接着查布隆过滤器不存在的 key 立即返回空结果剩下的请求走 Redis 缓存命中的直接返回最后缓存未命中时由互斥锁保证只有一个线程查数据库并回填其他线程等待后复用缓存。7.2 建议的演进顺序不建议一次性把所有组件都铺上去改动量大会增加回归风险。建议按以下顺序逐步演进先上线参数校验和限流成本最低见效最快再接入布隆过滤器构建批量预热任务然后为热点 key 增加互斥锁逻辑最后把缓存空值 TTL、布隆过滤器容量、限流阈值全部参数化方便动态调整。每一层上线后都要对比数据库 QPS、Redis 内存、接口 RT 三项核心指标确认确实有收益再进入下一步。8. 性能观察与压测方法8.1 需要观察的指标治理缓存穿透到底有没有效果不能靠感觉。至少要观察四组指标指标观测位置说明DB QPS数据库监控下降明显说明穿透请求被拦住了Redis 内存Redis INFO空值 key 数量是否在可控范围接口 RT应用监控P99 是否回落缓存命中率Redis INFO命中率低说明大部分请求被布隆过滤器拦掉了8.2 压测步骤压测要在测试环境独立进行避免影响线上业务。步骤如下准备一批固定存在的 ID 和一批随机不存在的 ID使用 wrk 或 JMeter 分别对两类 ID 发起并发请求先测“无防护”的原始逻辑依次开启缓存空值、布隆过滤器、互斥锁每开启一层记录数据库 QPS、Redis 内存、接口响应时间。压测脚本示例# 对不存在的 userId 做并发压测 wrk -t8 -c200 -d60s \ -s /path/to/random_user.lua \ http://127.0.0.1:8080/api/user/detail?id99999999压测时注意不要在业务高峰期进行连接数要控制在数据库连接池阈值内测试完成后要清理产生的空值缓存和压测数据。8.3 布隆过滤器内存估算布隆过滤器的内存大小由容量和误判率决定估算公式为内存(bit) ≈ 1.44 * 预估元素数量 * log2(1 / 误判率)假设业务用户量 1000 万误判率设置 1%内存需求大约在 11MB 左右。这个量级对 Redis 来说几乎可以忽略不计。所以容量预留可以大胆一些避免业务增长后频繁重建过滤器。误判率则不建议设置太低0.1% 到 1% 之间是比较合理的范围再低性价比不高。9. 常见问题与排查方法问题现象可能原因排查方式解决方案真实用户被布隆过滤器拦截返回空数据过滤器构建时未覆盖全量数据抽查已存在 ID执行 BF.EXISTS 判定全量重建过滤器新注册用户通过 BF.ADD 补充进过滤器空值缓存导致数据延迟可见空值 TTL 设置过长查看空值 key 的剩余有效时间按业务可接受延迟调短 TTL或改用布隆过滤器主导拦截Redis 内存出现大量无效 key攻击者持续构造不存在的 key执行keys user:null:*统计数量调整空值 TTL上线布隆过滤器前置拦截互斥锁死锁请求大量超时锁释放失败或递归重试过深查看 Redis 中 lock 前缀 key 是否残留用 Lua 脚本释放锁把递归改成 while 重试并设置最大次数布隆过滤器误判率升高数据删除较多过滤器无法删除对比存量数据与过滤器标记差异周期性全量重建过滤器压测时数据库 QPS 仍然很高压测的 ID 区间也被布隆过滤器判定为存在使用随机不存在的 ID 验证拦截效果确认容量和误判率配置是否合理接口限流误伤正常用户阈值设置过低查看限流日志与正常 QPS 对比用网关配置动态调整阈值排查时先看日志里“缓存未命中”“数据库已查”“空值已回填”三个关键节点能快速定位请求是卡在哪一层。再配合 Redis 的慢查询日志和数据库慢查询日志穿透问题大多能在十分钟内定位。10. 最佳实践与使用建议缓存穿透治理不是一次性改造而是一个持续演进的过程。下面这些工程实践建议可以直接套用到现有项目缓存空值只作为兜底不作为主防线。设置独立前缀和短 TTL避免与真实数据互相污染。布隆过滤器采用全量定时重建 新数据实时添加的组合策略。标准布隆过滤器不支持删除数据变更频繁时全量重建是保证准确性的最直接手段。互斥锁和缓存回填要保证原子性。加锁、查库、回填、释放锁全链路都要考虑异常情况尤其要处理好锁的续期和误删。所有缓存 key 设计统一前缀例如业务:模块:类型:ID。排查问题和统计数据时一目了然。压测前先明确数据库连接池上限和 Redis 内存限制避免测试过程本身造成故障。涉及用户 ID、手机号、订单号的请求日志必须做脱敏处理不允许打印完整业务数据。每层防护组件都要有开关配置。生产环境出现异常时能第一时间回退到上一版逻辑而不是紧急改代码发布。看回到标题这句话缓存空值确实是初学者最常用的招它不丢人只是一个起点。真正工程化的做法是用布隆过滤器压住大规模无效 key用互斥锁保护热点 key 的回源用限流守住系统整体的安全水位。每一步都不复杂但组合起来效果会非常明显。如果你正准备优化缓存链路建议先在测试环境跑通布隆过滤器的批量预热再叠加互斥锁压测一次数据库的查询压力变化会让你对“穿透治理”有一个更直观的判断。