尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

缓存穿透不再怕:布隆过滤器与空值缓存双管齐下的Spring Boot实践

缓存穿透不再怕:布隆过滤器与空值缓存双管齐下的Spring Boot实践 线上 Redis 缓存穿透数据库连接数被打满、慢查询告警不断这是后端同学最容易撞上的场景之一。很多人第一反应是把查不到的 key 也缓存起来缓存一个空值下次再有人查这个 key 就直接返回不再打到数据库。这个思路本身没有错但如果你以为“缓存空值”就是解决缓存穿透的全部那确实还停留在初学者阶段。原因很简单缓存空值解决的是“同一个不存在的 key 反复穿透”的问题它并不能拦住“大量随机生成的不同不存在的 key”。真实攻击场景里攻击者会在一次请求里不断换新的随机 ID这时候缓存空值不仅挡不住反而会让 Redis 里堆满没有价值的占位 key。等内存被打满整个缓存服务都会受影响。这篇文章会把缓存穿透这件事讲透先分析缓存空值方案为什么只能算“及格线”再讲布隆过滤器如何在数据库之前做存在性判断最后给出一套“参数校验 布隆过滤器 空值兜底 监控告警”的完整 Spring Boot 实现。你可以直接照着落地也可以在面试时把这条链路讲清楚。1. 缓存穿透为什么值得重新认识1.1 缓存穿透的典型链路缓存穿透简单说就是请求的数据在缓存里不存在在数据库里也不存在于是每次请求都会绕过缓存直接打到数据库。正常查询流程是这样的请求进来先查 Redis。Redis 命中直接返回。Redis 未命中查询数据库。数据库返回结果回填 Redis返回给调用方。这套流程在“数据存在”时没有问题。一旦出现“数据根本不存在”的情况第 3 步每次都会真实发生。在低并发下多查几次数据库没什么感觉但一旦请求量上来或者有人恶意遍历不存在的 ID数据库就会面临巨大压力。常见的触发场景有三类场景说明恶意遍历攻击者通过接口参数不断传入不存在的 ID绕过缓存直击数据库数据已删除业务上删除了一条记录但调用方仍拿着旧 ID 请求参数错误上游传入了负数、超长字符串、无意义 UUID 等非法 ID很多人会把缓存穿透、缓存击穿、缓存雪崩混在一起这里先做一个简单区分。缓存击穿是某个热点 key 过期瞬间大量请求同时打到数据库缓存雪崩是大量 key 在同一时间过期导致数据库压力骤增。它们的前提都是“数据存在”而缓存穿透的前提是“数据根本不存在”因此处理思路完全不同。本文只讨论缓存穿透。1.2 缓存空值是不是银弹为什么会提出缓存空值方案因为它落地成本极低查询数据库返回 null 时把 key 写进 Redisvalue 存一个占位符设置一个短 TTL。下次同样 key 再来直接命中占位符返回空结果。这个方案在面对“固定几个不存在的 ID 反复请求”时很有效。比如一条已经删除的业务数据ID 是固定的被外部系统反复同步查询缓存空值可以挡住绝大多数数据库查询。但它有几个明显的死角。第一防不住随机 key 攻击。攻击者每次请求都换一个新 ID第一个请求还是会穿透到数据库同时这个空的占位 key 会被写入 Redis请求越多Redis 里无意义 key 越多。第二TTL 不好设置。TTL 太短空值很快就过期穿透概率回升TTL 太长如果这个 key 在数据库里被重新创建了调用方在 TTL 内读到的仍然是“数据不存在”造成短时间数据不一致。第三会占用 Redis 内存。正常缓存存的是有价值的业务数据空值缓存每一条都不产生业务收益却消耗真实内存。一旦空值 key 数量级达到千万甚至亿级对 Redis 的成本影响就不能忽视了。所以缓存空值不是不能用而是它应该作为整套方案的最后一道兜底而不是第一道防线。真正要做的是在请求到达数据库之前先用一个足够廉价的机制判断“这个 ID 到底存不存在”。2. 缓存空值方案的完整拆解2.1 缓存空值到底怎么落地先看一个最基础的缓存空值实现后面第 4 章会给综合方案。这里先用最简单的代码展示核心逻辑。// 文件路径src/main/java/com/example/cache/NullCacheDemoService.java Service public class NullCacheDemoService { private static final String NULL_PLACEHOLDER __NULL__; private static final Duration NULL_TTL Duration.ofSeconds(60); private final StringRedisTemplate redisTemplate; public NullCacheDemoService(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public String query(String id) { String key data:demo: id; // 1. 查缓存 String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return NULL_PLACEHOLDER.equals(cached) ? null : cached; } // 2. 缓存未命中查数据库 String value queryFromDatabase(id); // 3. 数据库没有数据也回填一个占位符 if (value null) { redisTemplate.opsForValue().set(key, NULL_PLACEHOLDER, NULL_TTL); return null; } // 4. 数据库有数据正常回填 redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(10)); return value; } private String queryFromDatabase(String id) { // 这里替换为真实的 Mapper / DAO 查询 return null; } }这段代码有一个很关键的细节没有直接把 Java 的 null 写入 Redis。因为 Spring Data Redis 的默认序列化器并不支持把 null 作为 value 写入并且 null 值本身无法区分“缓存没有数据”和“缓存了空数据”。所以实际项目中会用约定好的占位符比如__NULL__或空 JSON 对象再在读取时转成业务空结果。2.2 TTL 长短的背后博弈缓存空值最容易被忽略的是 TTL 设计。如果 TTL 设置得很短比如 30 秒那同一个不存在的 key 每 30 秒就会穿透一次数据库。在恶意请求持续攻击的场景下数据库依然会频繁被查询只是压力被摊薄了并没有真正解决穿透。如果 TTL 设置得很长比如 30 分钟那么当数据库里新增了一条对应 ID 的数据后调用方在这 30 分钟内始终读取到“数据不存在”。对于强一致要求较高的业务这属于不可接受的脏数据。更麻烦的是不同业务场景对“不存在”的容忍时间完全不一样。商品详情页可以接受几分钟的新数据延迟但订单状态、交易信息这类接口场景空值缓存太大会引发数据一致性问题。此外如果所有空值 key 的 TTL 都完全相同大量占位 key 会在同一时间过期过期后短时间内又集体穿透到数据库产生类似缓存雪崩的波动。因此一个常规优化是给 TTL 加上随机扰动比如基础 60 秒再随机加 0 到 30 秒。2.3 三种方案的横向对比做技术选型时不能只看某一种方案的优点还要看它的拦截位置、资源消耗和适用场景。方案拦截位置内存/存储成本能否防随机 key实现复杂度适用场景参数校验入口处无只能防非法格式低所有接口都应做的第一道防线缓存空值数据库之后每个不存在 key 一条 Redis 记录不能低存量系统快速止损已知固定 key 反复穿透布隆过滤器数据库之前固定大小与数据总量相关能中新系统、存在性判断场景接口限流网关/入口无能减缓攻击中兜底手段不能单独解决穿透从这张表能看出缓存空值在四个方案里的定位其实很清晰它能解决“同一批不存在 key 反复打 DB”但解决不了“大量不同不存在 key 持续打 DB”。这也是为什么说“只用缓存空值”是初学者的原因——它没有把问题拦截在更前面的环节。3. 布隆过滤器把请求拦截在数据库之前3.1 布隆过滤器的基本原理布隆过滤器是一个很经典的位图数据结构。它内部是一个很长的二进制数组初始值全部为 0另外配套 k 个不同的哈希函数。插入一个元素时对这个元素计算 k 次哈希得到 k 个数组位置把这些位置全部置为 1。查询一个元素时同样计算 k 次哈希检查对应位置是否都为 1。只要有一个位置是 0就能肯定这个元素不存在如果所有位置都是 1只能说“可能存在”。用一句话解释它的核心特性布隆过滤器可以判断一个元素一定不存在但无法百分百判断一个元素一定存在。数据量固定时位数组越长、哈希函数个数越合理误判率越低。但布隆过滤器不支持删除操作因为删除一个元素需要把对应的位重新置为 0而这些位可能被其他元素共享贸然置 0 会影响其他元素的判断结果。3.2 为什么它适合解决“不存在”问题缓存穿透的本质是“查询了一个不存在的 ID”。如果我们能在缓存之前就知道这个 ID 在当前业务里不可能存在就不需要继续访问缓存和数据库了。布隆过滤器正好适合承担这个角色。把数据库当前所有存在的 ID 预加载到布隆过滤器里查询时先判断如果布隆过滤器说“这个 ID 一定不存在”直接返回空结果不查缓存不查数据库。如果布隆过滤器说“可能存在”再继续走缓存和数据库。这一步真正的价值在于把“不存在”的判断成本压缩到了一个很小的内存结构里。一个只有几百万条数据的表用布隆过滤器也只消耗几十 MB 内存比缓存大量空值 key 要划算得多。用一个通俗类比来说布隆过滤器就像公司前台手里的一份“不在场名单”。你问一个人今天在不在公司前台如果能在这份名单里找到对方直接告诉你不在根本不需要进办公室翻工位。3.3 布隆过滤器的使用边界布隆过滤器不是万能的使用前必须想清楚以下几点。第一它需要预知数据规模。构建布隆过滤器时要传入预期的元素数量和期望误判率。如果实际数据量远超预期误判率会快速上升导致大量不存在的 ID 被判断为“可能存在”慢慢失去拦截效果。第二它不天然支持动态增长。数据库里的 ID 是不断新增的每次新增数据都要调用布隆过滤器的 put 方法把新 ID 加入进去。如果忘记更新新产生的真实数据也会被误判为不存在直接返回空结果造成线上事故。第三标准版布隆过滤器不能删除。如果业务上有大量删除操作已删除 ID 仍然留在布隆过滤器里会让这些 ID 继续走“可能存在”分支这时候需要靠缓存空值或数据库查询兜底。第四多实例部署时要考虑初始化一致性。如果应用有多个节点每个节点各自维护一份布隆过滤器必须保证所有节点在同一次启动重建时用的是同一份全量数据否则会出现 A 节点判断存在、B 节点判断不存在的差异。4. 综合缓存防穿透方案设计与代码实现4.1 整体架构在实际生产项目中我不会单独依赖某一种方案而是组合使用。推荐的调用链路如下参数校验ID 为空、长度异常、格式非法直接返回。布隆过滤器判断ID 一定不存在直接返回不进缓存不进数据库。查询 Redis命中真实值返回命中空值占位符返回空结果。查询数据库有数据则回填缓存无数据则回填短 TTL 空值。监控告警记录穿透次数、空值命中次数、数据库慢查询。这个链路里缓存空值不再是主方案而是“布隆过滤器误判之后”的兜底手段。因为布隆过滤器存在误判率即使判断“可能存在”数据库里可能依然没有这条记录此时写一个短 TTL 空值缓存可以避免同一个误判 key 反复穿透数据库。4.2 项目依赖与配置先用一个 Spring Boot 工程演示环境以 Java 8、Spring Boot 2.x 为例。如果你用的是 Spring Boot 3.x部分依赖坐标需要跟着升级但核心思路不变。!-- 文件路径pom.xml -- dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version31.1-jre/version /dependency /dependenciesredis 连接配置# 文件路径src/main/resources/application.yml spring: application: name: cache-demo redis: host: 127.0.0.1 port: 6379 password: timeout: 2000ms这里用 Guava 的 BloomFilter 做演示它适合单机内存布隆过滤器。如果项目是多实例部署并且布隆过滤器数据量很大可以考虑 Redisson 提供的 RBloomFilter它把位图存放在 Redis 里多个应用节点共享同一份数据。4.3 布隆过滤器初始化在实际代码中布隆过滤器需要在应用启动时完成初始化把数据库里的存量 ID 全部加载进去。下面的示例模拟了初始化过程实际项目需要替换为真实的 DAO 查询。// 文件路径src/main/java/com/example/cache/config/BloomFilterHolder.java Component public class BloomFilterHolder { private static final int EXPECTED_IDS 100_0000; private static final double FPP 0.01; private final BloomFilterCharSequence bloomFilter; public BloomFilterHolder() { this.bloomFilter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), EXPECTED_IDS, FPP ); loadAllIdsFromDatabase(); } private void loadAllIdsFromDatabase() { // 这里替换为真实的 Mapper 查询将表中所有存在的主键 ID 加载进来 ListString allIds queryAllExistsIds(); for (String id : allIds) { bloomFilter.put(id); } } private ListString queryAllExistsIds() { // 示例数据实际应返回数据库全量 ID return List.of(A1001, A1002, A1003, B2001, B2002); } public boolean mightContain(String id) { return bloomFilter.mightContain(id); } public void add(String id) { bloomFilter.put(id); } }代码里EXPECTED_IDS是预估的数据量FPP是期望误判率。这两个参数直接影响位数组大小和哈希函数个数。预估数据量最好比当前存量数据再放大一些避免业务增长后误判率快速上升。4.4 核心查询服务实现接下来是核心查询逻辑这段代码已经把第 4.1 节的链路完整串起来了。// 文件路径src/main/java/com/example/cache/service/DataQueryService.java Service public class DataQueryService { private static final String NULL_PLACEHOLDER __NULL__; private static final String CACHE_KEY_PREFIX data:item:; private static final Duration DATA_TTL Duration.ofMinutes(10); private static final Duration NULL_TTL_BASE Duration.ofSeconds(30); private final StringRedisTemplate redisTemplate; private final BloomFilterHolder bloomFilterHolder; public DataQueryService(StringRedisTemplate redisTemplate, BloomFilterHolder bloomFilterHolder) { this.redisTemplate redisTemplate; this.bloomFilterHolder bloomFilterHolder; } public String queryData(String id) { // 第一层参数校验 if (id null || id.isEmpty() || id.length() 32) { return null; } // 第二层布隆过滤器判断 ID 是否一定不存在 if (!bloomFilterHolder.mightContain(id)) { return null; } String key CACHE_KEY_PREFIX id; // 第三层查询 Redis 缓存 String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return NULL_PLACEHOLDER.equals(cached) ? null : cached; } // 第四层查询数据库 String value queryFromDatabase(id); // 第五层回填缓存 if (value null) { // 布隆过滤器存在误判这里用短 TTL 空值兜底并加上随机扰动 int randomSeconds ThreadLocalRandom.current().nextInt(30); redisTemplate.opsForValue().set( key, NULL_PLACEHOLDER, NULL_TTL_BASE.plusSeconds(randomSeconds) ); return null; } redisTemplate.opsForValue().set(key, value, DATA_TTL); return value; } private String queryFromDatabase(String id) { // 替换为真实的 Mapper / DAO 查询 return null; } }简单解释一下这段代码的设计顺序。参数校验放在最前面是因为它的成本最低。如果调用方传了一个负数 ID布隆过滤器都不需要参与判断直接返回即可。布隆过滤器放在缓存之前而不是之后是为了尽可能减少 Redis 请求。一个不存在 key 如果已经进入了缓存链路即便最后被空值兜底也会产生一次 Redis 读操作和一次 Redis 写操作。对于大规模随机攻击这些无效的 Redis 请求同样消耗资源。数据库回填逻辑里空值 TTL 加上了 30 秒的随机扰动避免大量占位 key 同时间过期导致的一次性穿透。4.5 对外接口与运行为了验证效果再写一个简单的 Controller。// 文件路径src/main/java/com/example/cache/controller/DataController.java RestController RequestMapping(/api/data) public class DataController { private final DataQueryService dataQueryService; public DataController(DataQueryService dataQueryService) { this.dataQueryService dataQueryService; } GetMapping(/{id}) public String query(PathVariable String id) { return dataQueryService.queryData(id); } }启动项目后接口路径为/api/data/{id}。这里演示的是一个字符串 ID实际项目中可能是 Long 类型自增主键也可能是业务唯一编码逻辑没有差别。如果你使用的是多实例部署需要额外注意每个应用节点都会创建一个 BloomFilterHolder启动时会各自加载数据库全量 ID。如果某次新增数据只在一个写了接口的节点上 put 了新 ID而其他只读节点没有更新布隆过滤器就会出现部分节点查不到新数据的情况。解决这个问题有两个方向一是写操作时通过消息通知所有节点更新布隆过滤器二是使用 Redis 集中存储的布隆过滤器实现。5. 运行验证与效果评估5.1 本地验证步骤代码写完后建议按下面的步骤做一轮本地验证。第一步启动本地 Redis确保可以通过redis-cli ping返回 PONG。第二步启动 Spring Boot 应用观察启动日志里布隆过滤器是否初始化完成。如果初始化阶段有 SQL 报错大概率是加载全量 ID 的查询出了问题。第三步用 curl 分别请求几种不同特征的数据# 请求一个已存在的 ID curl http://localhost:8080/api/data/A1001 # 请求一个不存在但格式正常的 ID curl http://localhost:8080/api/data/NOT_EXIST_999 # 请求一个非法 ID长度超过 32 位 curl http://localhost:8080/api/data/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa第四步在 Redis 里查看缓存 key 和 TTLredis-cli # 查看缓存 key 是否写入 keys data:item:* # 查看不存在 ID 的 TTL应该是一个 30 到 60 秒之间的随机值 ttl data:item:NOT_EXIST_999第五步连续请求同一个不存在的 ID观察数据库日志中 SQL 查询次数。正常情况下只有第一次请求会触发数据库查询后续请求都会被空值缓存挡住。5.2 如何量化方案效果在本地环境里验证只能证明“链路能跑通”真正要回答的问题是这套方案上线后到底降低了多少数据库压力我建议至少盯住以下四个指标指标观察方式健康表现数据库 QPS数据库监控平台压测期间无明显尖峰接口平均响应时间APM 监控不因穿透而明显升高Redis 空值 key 数量keys data:item:*或抽样统计保持稳定不持续膨胀布隆过滤器拦截占比日志中统计直接返回次数拦截占比越高说明效果越好如果需要做一次快速的压测可以用 ab 或 JMeter。压测时建议重点关注一个场景持续传入随机生成的不存在 ID。在这个场景下如果接口仍然产生了大量数据库慢查询说明布隆过滤器的误判率配置偏高或者参数校验没有拦住足够多的非法请求。这里提醒一句压测会真实打到接口和数据库一定要在测试环境执行并且压测流量不可到达生产数据库。6. 常见问题与排查思路问题现象可能原因排查方式解决方案布隆过滤器重启后失效每次启动才加载全量 ID加载失败未告警检查启动日志、确认是否存在初始化异常初始化失败时进程直接失败退出避免带病上线新插入的数据查不到写入数据库后没有更新布隆过滤器检查新增数据的写入链路是否调用 add在 DB 写入成功后同步调用 bloomFilterHolder.addRedis 中出现大量占位 key空值 TTL 设置过长或非法请求过多统计空值 key 数量和 TTL 分布缩短 TTL、增加随机扰动、加强参数校验布隆过滤器误判导致 DB 仍有查询数据量超过初始化容量或误判率设置过低查看布隆过滤器实际误判情况按业务增长预估容量或定期重建布隆过滤器RedisTemplate 报无法序列化 null直接把 null 写入了 Redis检查写入代码value 是否为 null使用约定的占位符代替 null多实例下各节点判断结果不一致各节点本地布隆过滤器不同步对比不同节点对同一 ID 的判断结果用 Redis 集中式布隆过滤器或统一消息更新机制这几种问题里最需要警惕的是“新插入的数据查不到”。很多同学把布隆过滤器初始化好就结束了忽略了后续新增数据的更新导致数据库里明明有数据接口却一直返回空。这也说明布隆过滤器并不是配置一次就一劳永逸的组件它需要和业务写入链路紧密配合。7. 工程最佳实践7.1 分层防护而不是指望一个组件从整个链路来看缓存穿透的防御应该是分层的。参数校验是第一层成本最低拦截最明显的非法请求。布隆过滤器是第二层拦截绝大多数“合法格式但一定不存在”的请求。缓存是第三层承载正常的热点数据。数据库是最后一层只接收真正需要落库查询的请求。每一层都有自己不可替代的价值单靠任何一层都无法实现完美防御。缓存空值很适合作为这一整套体系里最后的“安全网”但如果整个方案只有缓存空值那数据库前面的防御就是空的。7.2 布隆过滤器的更新策略布隆过滤器的使用关键是数据生命周期要闭环。初始化阶段需要把存量数据全部加载写入阶段每次新增数据要同步 put删除阶段由于布隆过滤器不支持删除被删除的 ID 依然会被判断为“可能存在”此时只能靠缓存空值和数据库查询兜底。如果删除量很大并且这些删除 ID 经常被查询可以考虑定期重建布隆过滤器用当前数据库全量数据重新生成一份。另外一个容易被忽略的细节是布隆过滤器的预期容量应该按业务增长后的数据量来设置而不是按当前数据量设置。否则用了半年后数据量翻倍误判率会明显升高防穿透效果会打折扣。7.3 缓存空值的 TTL 与监控虽然缓存空值是兜底但它的 TTL 设计同样不能随意。基础原则是在业务容忍的“新数据可见延迟”之内尽量把 TTL 调短并给 TTL 增加随机抖动避免占位 key 集体过期。生产环境还应记录两类关键日志。第一类是布隆过滤器直接拦截的日志用来评估拦截率第二类是数据库查询后回填空值缓存的日志用来观察误判率。如果第二类日志占比偏高说明布隆过滤器的容量或误判率配置需要调整。安全方面缓存防穿透只是系统防护的一部分接口层仍应配合鉴权、限流、幂等等手段。不要把所有的安全性都寄托在缓存方案上。8. 总结从及格线到靠前的系统设计回到标题用缓存空值解决缓存穿透的是不是都是初学者准确地说只想到缓存空值这一种方案才是初学者。缓存空值本身不是错误它是一种真实有效的兜底手段但它解决不了随机 key 攻击也解决不了 Redis 内存被占满的问题。真正成熟的方案是把参数校验、布隆过滤器、缓存空值、限流监控组合在一起形成一条从入口到数据库的完整防线。如果你正在设计一个查询接口不妨先问自己一个问题有多少请求其实从一开始就不应该继续往后走想清楚这个问题你就不会把“缓存空值”当成唯一答案而是会去考虑如何在更靠前的位置挡住无效流量。接下来的学习方向也很明确可以看看 Cuckoo Filter 如何支持删除操作研究 Redisson 的 RBloomFilter 如何把布隆过滤器集中到 Redis 中再深入理解缓存击穿、缓存雪崩与缓存穿透的联动治理。把这些问题串起来后端缓存的整体认知会比单纯背概念扎实很多。
返回列表