ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

缓存穿透别只靠缓存空值,布隆过滤器+互斥锁才是生产级组合

缓存穿透别只靠缓存空值,布隆过滤器+互斥锁才是生产级组合 缓存穿透这个问题后端开发基本都会遇到。很多文章给出的方案就是“缓存空值”简单粗暴查不到数据就把空值也写进缓存下次再来查直接从缓存里拿走空结果不再打数据库。这个方案本身没有错但它有一个非常容易被忽略的问题它只能解决“偶发穿透”解决不了“系统性问题”。如果一个接口同时面对大量不存在的数据查询你用空值缓存去扛扛到最后Redis 里塞满了脏 key 和空 key内存被无效数据吃光真正热的数据反而被挤掉系统从“缓存穿透”演变成“缓存雪崩”这就不是加一个 if 判断能救回来的。这篇文章不是否定缓存空值而是要把它放到正确的位置上。先讲清楚缓存穿透到底是怎么发生的再对比空值缓存、布隆过滤器、互斥锁三种方案的适用边界最后给出一个生产级组合方案包含完整代码和验证方式。如果你现在还在“查到空就 set(key, null)”这条路上一路走到黑建议认真看完再动手改。1. 缓存穿透的本质请求打到了一个“不存在”的洞上先把概念对齐。缓存穿透指的是查询的数据在缓存中不存在在数据库里也不存在每次请求都绕过缓存直接打到数据库。正常缓存流程是这样的请求进来先查 Redis命中就返回。没命中查数据库。数据库有数据回写到缓存返回给调用方。这个流程有一个前提数据库里得有这条数据。如果数据库里根本没有这个 id那么第 2 步必然发生第 3 步永远不会执行。更麻烦的是同一个不存在的 id 可以被反复查询每次查询都完整地走一遍 Redis 未命中 → MySQL 查询的过程。从数据库角度看这些查询毫无意义却真实消耗了连接数、CPU 和磁盘 IO。并发一高数据库先扛不住。下面是最常见的“裸奔”代码很多项目的一版实现就是长这样。// 文件路径UserServiceImpl.java public User getUserById(Long id) { // 1. 先查缓存 String key user: id; String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseObject(json, User.class); } // 2. 缓存没有查数据库 User user userMapper.selectById(id); // 3. 数据库有回填缓存 if (user ! null) { stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES); return user; } // 4. 数据库也没有直接返回 null return null; }这段代码的问题很明显如果 user 为 null它不会被写入缓存。下一次同样的请求来了还是要查一遍数据库。假设某天营销活动上线订单详情页被人用脚本遍历用户 id从 1 到 100000 挨个查而你数据库里的用户只有 5000 个。那么有 95000 个请求会直接打到 MySQL。数据库连接池一旦被占满正常用户的查询也会跟着超时。这就是缓存穿透最典型的攻击场景用不存在的 id 制造大量无效查询。它和“热点 key 过期瞬间出现的并发查询”严格来说不是一回事但混合出现时会让排查更困难。2. 缓存空值方案是怎么工作的它的适用边界在哪缓存空值的做法非常直观当数据库查询返回 null 时也把这个“空”缓存起来只是过期时间设得很短比如 60 秒。改造后的代码// 文件路径UserServiceImpl.java public User getUserByIdWithEmptyCache(Long id) { String key user: id; String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { // 注意这里要区分“空值缓存”和“真实数据” if (EMPTY.equals(json)) { return null; } return JSON.parseObject(json, User.class); } User user userMapper.selectById(id); if (user ! null) { stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES); } else { // 数据库查不到也写缓存过期时间 60 秒 stringRedisTemplate.opsForValue().set(key, EMPTY, 60, TimeUnit.SECONDS); } return user; }这段代码的关键点有两个缓存 value 从“只存真实数据”变成了“真实数据 空值标记”两种状态读取时要做特殊判断。空值的过期时间要比真实数据短得多避免无效数据长期占内存。从执行流程看空值缓存确实降低了数据库压力同一个不存在的 id 在 60 秒内再次查询时直接命中缓存里的EMPTY不会走到数据库。但这里有一个必须想清楚的边界空值缓存适合的是“业务上确实存在、但偶尔查不到”的场景。举个例子用户删除了自己的某个订单但客户端还持有旧订单 id短时间内有重试请求。这种偶发性查询用空值缓存挡一下完全合理。但如果你的接口正在被人恶意扫描请求pattern是id1000001、id1000002、id1000003……每一个 id 都是新的、从来没有被缓存过。那么空值缓存方案退化成什么就是每查一个不存在的 id先写一个EMPTY到 Redis下一次攻击换一个新 idRedis 里又多一条无意义的 key数据库一样被查询。更糟糕的是大量EMPTYkey 会占满 Redis 内存。Redis 内存淘汰策略如果是allkeys-lru空 key 还可能把热 key 挤掉。到了这一步等于你用缓存空值把“缓存穿透”变成了“缓存雪崩”。所以结论是缓存空值是一个“止痛药”不是“疫苗”。它对低频、重复的不存在查询有效对高频、分布式的不存在查询是无效的甚至有害。3. 为什么说“只会用缓存空值”不够三大硬伤很多初学者看到缓存空值思路简单、代码好写就以为这就是缓存穿透的最终解。实际上它有几个硬伤在真实项目里会逐渐暴露出来。3.1 硬伤一无效 key 占用内存每条空值缓存都是一条 Redis key。如果业务有大量不存在的实体类型比如“用户”“订单”“商品”每个类型存几万个空 key内存消耗很快。有人会说可以把过期时间设短一点比如 10 秒。但过期时间太短又会回到“频繁穿透”的问题上。这就是一个跷跷板过期时间长内存受不了过期时间短保护效果差。3.2 硬伤二无法区分“空值缓存”和“真实数据”真实业务里数据可能在一个时间点从“不存在”变成“存在”。举个例子用户下单后支付回调还没完成此时订单详情查询返回“订单不存在”被空值缓存记录。30 秒后支付成功订单数据落库。此时再查同一个订单 id命中的还是 30 秒前的EMPTY即便这个订单现在已经真实存在了用户也看不到。这个问题的本质是空值缓存把“数据库里没有”这个瞬时状态强行缓存成了一个持续状态。类似地如果一个真实数据被删除而它之前命中了空值缓存你可能读到过期数据。用大白话讲空值缓存实际上是在缓存一个“断言”而这个断言很可能在下一秒就过时。3.3 硬伤三对“大量不同 key”的攻击无效这是最致命的一点。缓存穿透真正可怕的地方在于攻击者或异常调用方会持续使用不同的不存在 key 去请求接口。如果用curl 循环 10000 个 id每个 id 都不存在空值缓存会写 10000 个 key 到 Redis同时数据库也被查了 10000 次。你不仅没有挡住穿透还额外创造了缓存写入开销。所以如果一个方案只靠“缓存空值”来面对这种场景注定是挡不住的。4. 生产环境更值得采用的四种方案既然缓存空值只能解决很小的一个场景那什么方案能解决大范围问题下面四个方案是按“防护层级”排列的它们不是互斥关系而是组合关系。4.1 方案一布隆过滤器前置拦截布隆过滤器Bloom Filter的核心思想是用多个哈希函数把“可能存在”的数据映射到一个位数组里。查询一个 key 时如果位数组判断“肯定不存在”那就直接返回只有当位数组判断“可能存在”时才继续往下查。它的优点是空间占用极小100 万个 key 只用不到 1MB 的位数组而且查询时间复杂度是 O(k)k 是哈希函数的个数。它的缺点是存在误判率它只能告诉你“一定不存在”或者“可能存在”。也就是说它会偶尔把不存在的 key 放过去但绝不会把存在的 key 挡在门外。在实际项目中一般把布隆过滤器放在缓存之前先把“一定不存在”的请求拦掉剩下的少数“可能存在但实际不存在”的请求再让缓存空值去兜底。4.2 方案二分布式互斥锁避免击穿如果缓存没有命中数据库也没有数据但同一时刻有 1000 个线程都在查同一个不存在的 key怎么办分布式互斥锁的思路是让这些线程只有一个去查数据库其他线程等待结果。String lockKey lock:user: id; boolean locked stringRedisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (locked) { // 拿到锁查数据库回填缓存释放锁 } else { // 没拿到锁说明其他线程正在查短暂休眠后重试 Thread.sleep(50); return getUserByIdWithLock(id); }这个方案避免的是“同一个 key 的并发穿透”但如果有 1000 个不同的不存在 key锁的数量也会膨胀效果会打折扣。所以互斥锁更适合“热点 key 过期瞬间”的场景。4.3 方案三逻辑过期 异步刷新为了避免缓存空值过短导致反复穿透真实项目中经常使用“逻辑过期”方案缓存里保存的 value 不设 TTL而是存一个过期时间戳读取时发现逻辑过期先返回旧值再异步去刷新缓存。这个方案的核心价值是即便数据源暂时查不到缓存里也还有旧值不会直接放空流量打到数据库。配合空值缓存时可以用更长的逻辑过期时间降低空 key 的刷新频率。4.4 方案四参数校验和限流降级很多时候缓存穿透不是恶意攻击而是调用方传了非法参数。一个/user/{id}接口id 必须是正整数如果直接传id-1或idabc连数据库都不应该查直接在入口处拦截。另外在数据库层面做读限流在网关/接口层做降级是兜底的最后一道防线。它们不能阻止穿透发生但能保证数据库在极端情况下不被拖垮。5. 完整示例代码布隆过滤器 空值缓存 互斥锁组合实现下面用一个实际场景来演示组合方案的代码怎么写。场景设定查询用户信息用户 id 范围 1 到 100000实际存在 5000 个用户恶意请求随机生成不存在的 id 进行查询。我们希望做到对一定不存在的 id直接在入口返回 null不打 Redis、不打数据库。对可能存在但不存在的 id用短时间空值缓存兜底。对热点 id 的并发查询用分布式锁保证只有一个线程回源。5.1 引入依赖为了不引入太重的依赖下面是 Mavenpom.xml中的核心依赖!-- 文件路径pom.xml -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.2-jre/version /dependency这里使用 Guava 提供的布隆过滤器实现便于在单机应用里快速验证。生产环境如果需要支持动态扩容可以替换为 Redis 布隆过滤器插件或自研位数组实现。5.2 初始化布隆过滤器把数据库里的存量用户 id 加载到布隆过滤器中。这里的put操作是幂等的重复执行不影响正确性。// 文件路径UserBloomFilter.java Component public class UserBloomFilter { private static final int EXPECTED_INSERTIONS 100000; private static final double FPP 0.01; private final BloomFilterLong bloomFilter BloomFilter.create( Funnels.longFunnel(), EXPECTED_INSERTIONS, FPP); PostConstruct public void init() { // 实际项目中这里应该扫描数据库主键或从离线任务加载 // 这里模拟加载 1 到 5000 的用户 id for (long i 1; i 5000; i) { bloomFilter.put(i); } } public boolean mightContain(Long id) { return bloomFilter.mightContain(id); } }5.3 组合查询逻辑核心查询流程如下参数校验id 不能为空、必须大于 0。布隆过滤器判断是否“一定不存在”是则直接返回 null。查 Redis命中则判断是空值还是真实数据。Redis 未命中尝试获取分布式锁。拿到锁后查数据库回填缓存没拿到锁则等待重试。// 文件路径UserServiceV2.java Service public class UserServiceV2 { Resource private StringRedisTemplate stringRedisTemplate; Resource private UserMapper userMapper; Resource private UserBloomFilter userBloomFilter; private static final String EMPTY_FLAG EMPTY; private static final long CACHE_TTL 30L; private static final long EMPTY_TTL 30L; private static final long LOCK_TTL 5L; public User getUserById(Long id) { // 1. 参数校验非法 id 直接拒绝 if (id null || id 0) { return null; } // 2. 布隆过滤器前置判断 if (!userBloomFilter.mightContain(id)) { return null; } String key user: id; // 3. 查 Redis String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { if (EMPTY_FLAG.equals(json)) { return null; } return JSON.parseObject(json, User.class); } // 4. 缓存未命中尝试加锁回源 String lockKey lock:user: id; boolean locked Boolean.TRUE.equals( stringRedisTemplate.opsForValue().setIfAbsent(lockKey, 1, LOCK_TTL, TimeUnit.SECONDS)); if (locked) { try { // 双检拿到锁后再查一次缓存避免重复回源 json stringRedisTemplate.opsForValue().get(key); if (json ! null) { return EMPTY_FLAG.equals(json) ? null : JSON.parseObject(json, User.class); } User user userMapper.selectById(id); if (user ! null) { stringRedisTemplate.opsForValue().set( key, JSON.toJSONString(user), CACHE_TTL, TimeUnit.MINUTES); } else { // 空值缓存兜底TTL 很短 stringRedisTemplate.opsForValue().set( key, EMPTY_FLAG, EMPTY_TTL, TimeUnit.SECONDS); } return user; } finally { // 注意释放锁时最好检查 value避免误删别人的锁 stringRedisTemplate.delete(lockKey); } } else { // 5. 没拿到锁说明其他线程正在回源短暂等待后递归重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getUserById(id); } } }这段代码融合了三种策略。注意几个细节拿到锁之后会二次查缓存。因为在你等待锁的过程中前一个线程可能已经完成了缓存回填。不双检的话同一个 key 会被重复查库。空值缓存用的是EMPTY_FLAG字符串而不是null这是为了能区分“缓存里没这个 key”和“缓存里有但数据是空的”。释放锁前没有校验 value简化了实现。生产环境如果有多线程竞争激烈建议使用 Lua 脚本实现「先比对 value 再删除」的原子操作。6. 运行结果与效果验证通过下面两个测试场景可以直观看到不同方案的效果差异。6.1 测试场景 A重复查询同一个不存在的 id调用接口GET /user/999999连续请求 100 次。方案Redis 写入MySQL 查询响应结果无防护0 次100 次null空值缓存1 次1 次null布隆过滤器0 次0 次null在这个场景里空值缓存的表现很好只需要第一次查一次库剩下 99 次全部命中EMPTY。6.2 测试场景 B批量请求大量不同不存在的 id模拟 10000 个随机不存在的 id每个 id 查询 1 次。方案Redis 新增 keyMySQL 查询说明无防护010000数据库被打爆空值缓存1000010000数据库没被打爆但 Redis 内存爆炸布隆过滤器 空值缓存少量少量布隆挡住绝大多数少量误判靠空值缓存兜底从这个对比可以看出布隆过滤器解决的是“不同 key 的大范围穿透”空值缓存解决的是“重复 key 的短时穿透”。二者组合才是生产环境真正能扛住压力的配置。6.3 验证命令启动应用后可以这样验证缓存数据# 查看某个 key 的 TTL redis-cli ttl user:999999 # 查看缓存值 redis-cli get user:999999 # 预期输出EMPTY # 查看 Redis 内存使用 redis-cli info memory | grep used_memory_human如果user:999999的 TTL 是 30 秒左右自动消失说明空值缓存机制生效如果大量 key 都命中EMPTY说明布隆过滤器的误判率可能设置得太高需要调低 FPP误判率参数。7. 常见问题与排查思路组合方案要真正落地下面几个问题是绕不开的。问题现象可能原因排查方式解决方案布隆过滤器没有拦截任何请求初始化时没有加载数据或加载的是另一个数据源查看init()日志确认 key 数量确保启动时导入全量主键增量数据也要实时同步到布隆过滤器空值缓存频繁穿透数据库空值 TTL 设置过短攻击 id 随机性极强几乎没有重复把 TTL 加到 60 秒观察调大 TTL或在空值缓存前面再加一层接口限流缓存中EMPTY与真实数据混淆真实业务数据可能本身就是字符串EMPTY用redis-cli get检查 value改为对象序列化格式例如{empty:true}或对 key 加前缀区分大量 key 在短时间内同一时刻过期空值缓存全部设置了相同的 TTL到期后同时失效查看 Redis 的 expire 统计给 TTL 增加随机抖动例如 30 到 60 秒随机互斥锁一直获取不到接口超时锁没有设置过期时间或锁释放逻辑有异常查看 Redis 中lock:*的残留数量必须给锁设置 TTL用 finally 保证释放生产环境优先使用 Lua 脚本还有一个非常容易被忽略的问题布隆过滤器只支持添加不支持删除。如果用户被删除或主键被复用布隆过滤器里仍然保留它的位查询结果会变成“可能存在”穿过布隆过滤器后空值缓存会以 null 兜底。这种“多一层查询”的代价是可以接受的但一定要在设计文档里写清楚避免后来人误以为布隆过滤器删数据也能即时生效。8. 最佳实践与工程建议8.1 不要迷信单一方案缓存穿透没有“一招制敌”的方法。正确的思路是分三层接口层参数校验拒绝非法 id网关层限流保护后端。缓存层布隆过滤器拦住大多数不存在的 key空值缓存兜住少量穿透的 key。数据库层连接池限制、SQL 超时配置、只读流量分发到从库。每一层都只解决一部分问题叠加起来才是完整防护。8.2 缓存空值务必设置短 TTL 和随机抖动生产环境推荐写法cache.empty.ttl.min30 cache.empty.ttl.max60 # 空值缓存TTL使用 random(30, 60) 秒避免同时过期如果业务允许空值缓存最好预留一个“数据恢复窗口”。比如用户支付回调最长延迟 60 秒那么空值 TTL 至少大于这个窗口否则会出现“订单已支付但查询还是空”的窗口期。8.3 布隆过滤器的容量估算生产环境中Guava 的BloomFilter.create需要提前估算两个参数expectedInsertions预期元素数量。宁大勿小太小会导致误判率急剧上升。fpp误判率。如果内存充足建议设为0.001千分之一以下。插入过滤器之后如果系统需要扩容需要注意重新构建布隆过滤器比在旧的过滤器上继续 put 更可靠。可以考虑给过滤器加版本号每次全量重建时切换版本。8.4 监控是缓存方案的一部分不监控就无法发现缓存穿透。建议至少监控这几个指标Redis 内存使用率。缓存 key 总量增长趋势。数据库慢查询数量特别是select by id类 SQL。接口层 QPS 和回源率读缓存未命中次数 / 总请求数。空值缓存命中次数。回源率是最直观的信号。如果某接口的回源率从 5% 升到 30%需要立刻排查是不是有大量不存在 key 进入。8.5 数据变更时的缓存一致性空值缓存会放大数据一致性风险。商品上架、用户注册这类“从无到有”的数据如果已经被空值缓存了必须主动删除对应 key。推荐的做法是写操作完成后主动 delete 缓存 key而不只是更新缓存值。// 文件路径UserAdminService.java public void createUser(User user) { // 1. 写数据库 userMapper.insert(user); // 2. 删除可能的空值缓存 String key user: user.getId(); stringRedisTemplate.delete(key); }采用“先写库再删缓存”的顺序能有效避免空值缓存挡住真实数据。9. 总结与后续学习方向回过头来看缓存空值这个方案被初学者当成“万能药”原因在于它直观、好写、马上能看到效果。但生产环境里的缓存穿透从来不是单一场景而是流量、数据分布、业务生命周期共同作用的结果。只靠一个set(key, EMPTY)是扛不住的。这篇文章想表达的核心判断是缓存空值是缓存穿透防护链条中的一环不是全部。把它定位成“兜底措施”与布隆过滤器、分布式锁、参数校验、限流降级组合使用才能在生产环境真正站得住脚。空值的 TTL、布隆过滤器的容量和误判率、锁的释放和超时任何一个细节没有处理干净系统都会在某个极端流量下暴露出问题。如果你现在准备动手重构缓存层建议按照这个顺序落地先梳理业务中存在“不存在数据查询”的场景确认穿透的主要来源是恶意攻击、热点过期还是数据延迟。再根据来源选择布隆过滤器、空值缓存、互斥锁的优先级。最后补上监控和告警让回源率、内存增长成为你每天关注的指标。缓存穿透、缓存空值、布隆过滤器、互斥锁这些概念单独拿出来都不复杂但生产环境的难点从来都是“组合”。把这套组合练熟了再回头看那些只写两三行代码的“缓存空值解决穿透”文章你会更清楚它到底解决了什么、没解决什么。
返回列表