资讯详情

资讯详情

缓存穿透防护实战:布隆过滤器与空值缓存方案对比

分享一套缓存穿透防护实战方案包含概念讲解、代码示例、布隆过滤器与空值缓存的对比选型以及高并发场景下的数据库保护策略。无论是准备面试、做数据库课程设计还是改造真实项目这份内容都能直接复用。缓存穿透是后端开发里非常经典的问题也是面试中高频出现的 Redis 考点。很多同学背过“缓存穿透、击穿、雪崩”的概念但一到项目里就不知道怎么落地。本文不讨论理论直接拆解缓存穿透的完整链路并给出可运行的 Spring Boot Redis 防护代码。1. 缓存穿透是什么先讲一个简单场景1.1 缓存工作机制回顾在正式说穿透之前先回顾一下缓存系统的工作流程。通常我们会在数据库前面加一层 Redis 缓存目的是降低数据库压力。当一个查询请求进来时系统会先查缓存如果缓存里有数据直接返回不走数据库。如果缓存里没有数据再去查数据库。数据库返回结果后再把结果写入缓存方便下次直接使用。这个流程看起来没什么问题但它隐含了一个前提用户查询的 key 对应数据是存在的。用伪代码表示经典的“缓存旁路”模式public Object getProduct(String productId) { // 1. 先查缓存 Object value redis.get(product: productId); if (value ! null) { return value; } // 2. 缓存没有查数据库 Product product productMapper.selectById(productId); // 3. 数据库有结果写回缓存 if (product ! null) { redis.set(product: productId, product, 1800); } return product; }这个逻辑在正常数据查询下是合理的但一旦遇到数据库中根本不存在的数据缓存就成了摆设。1.2 什么是缓存穿透缓存穿透指的是查询一个数据库中必然不存在的数据。因为数据库里没有这条记录所以缓存里永远不可能有每次请求都会越过缓存直接打到数据库。举个例子商品系统中存在商品 ID 1 到 1000攻击者用id-1、id99999999、idabc之类的不存在的 key 不断请求。每一次请求都会走Redis 查不到。MySQL 查不到。不写缓存。请求结束。如果有大量并发请求同时发起MySQL 会瞬间收到大量无效查询。对于单机数据库来说几千个并发就可能把连接池打满导致正常请求也无法访问。1.3 穿透、击穿、雪崩的区别这三个概念非常容易混淆放在一起对比概念核心问题现象缓存穿透查询不存在的数据缓存永远为空请求直达数据库缓存击穿某个热点 key 失效大量请求同时打到数据库缓存雪崩大量 key 同时失效大面积缓存失效数据库被压垮穿透和击穿很像但本质不同。击穿是“缓存曾经有突然没了”穿透是“缓存从来没有过”。雪崩则是规模更大的击穿。后面会看到它们的防护手段也不同。击穿靠互斥锁、逻辑过期穿透靠空值缓存、布隆过滤器。2. 缓存穿透为什么危险数据库在替谁背锅2.1 一次不存在查询的完整链路下面用一张图描述缓存穿透时一次请求经过的节点客户端 - API 网关 - 业务服务 - Redis未命中 - MySQL未命中 - 返回空看似只是多查了一次数据库但如果这个请求被自动化脚本循环发送数据库就会持续收到无效 SQL。常见的攻击场景是恶意用户遍历不存在的 ID。爬虫抓取不存在的页面。前端代码 bug导致 key 生成错误不断请求错误 ID。业务数据删除后没有被缓存层同步处理。这些场景的共同点是请求的 key 没有任何价值但数据库付出了真实的查询成本。2.2 风险定量分析以一个简单的 MySQL 单表查询为例SELECT id, name, price FROM product WHERE id ?假设这条 SQL 平均执行时间是 5ms。一个普通的 4 核 8G 数据库最大并发连接数配置为 200。当每秒有 10 万个不存在 ID 的请求到达时数据库的 QPS 会迅速超过承载上限。再看缓存的作用。正常情况下Redis 能扛住每秒 10 万次读请求而 MySQL 在这种压力下早已告警。缓存穿透等于把一个本应由 Redis 承担的压力转嫁给了 MySQL。更麻烦的是MySQL 的慢查询日志会被无意义的 SQL 刷屏DBA 无法分辨哪些是真正的慢查询影响排查效率。2.3 哪些系统最容易中招缓存穿透并不是所有系统都会遇到它通常出现在以下场景用户维度查询用户 ID 可被枚举。商品、订单详情ID 连续递增容易被遍历。短链接服务短码被恶意猜测。搜索系统频繁查询无结果的关键词。课程设计或 Demo 项目ID 生成规则简单容易被爬虫或同学扫库。如果系统没有登录限制、没有参数校验、没有频控那么穿透风险会非常高。3. 给数据库请保安四种主流防护方案针对缓存穿透业界已经形成了比较成熟的防护套路。下面逐个介绍。3.1 方案一缓存空值这是最简单、最直接的方式。当数据库查询结果为空时也把这个空结果缓存起来并设置一个较短的过期时间比如 2 到 5 分钟。public Object getProduct(String productId) { String cacheKey product: productId; Object value redis.get(cacheKey); // 缓存有值直接返回 if (value ! null) { return value; } // 查数据库 Product product productMapper.selectById(productId); if (product null) { // 空值也缓存TTL 设置短一点 redis.set(cacheKey, , 120); return null; } redis.set(cacheKey, product, 1800); return product; }这里的核心点是空值缓存时间不能太长否则数据库插入新数据后用户依然会读到空缓存。空值要和其他正常值区分开比如使用特殊占位符而不是直接存null。内存会有额外消耗需要评估无效 key 的量级。优点实现简单不依赖额外组件。缺点占用 Redis 内存。如果攻击者生成大量随机 key每个 key 都要缓存空值会撑爆内存。如果 key 量太大空值缓存本身也会成为污染源。所以空值缓存通常只适合 key 数量有限、非法 key 比例不高的场景。3.2 方案二布隆过滤器布隆过滤器是一种概率型数据结构专门用来判断“一个元素是否存在于集合中”。它的特点是说“可能存在”结果不一定存在。说“肯定不存在”结果一定不存在。利用这个特性可以在请求进入缓存之前先用布隆过滤器判断 key 是否合法。如果过滤器认为 key 不存在直接拒绝查询请求连 Redis 都不会进更不会到数据库。布隆过滤器内部是一个位数组和多个哈希函数。插入元素时会计算多个哈希位置并置为 1查询时只要有一个哈希位置为 0就说明元素肯定不存在。使用示例以 Guava 为例import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; public class Demo { public static void main(String[] args) { // 预计插入 10000 个元素误判率 0.01 BloomFilterInteger filter BloomFilter.create( Funnels.integerFunnel(), 10000, 0.01 ); filter.put(1); filter.put(2); filter.put(3); System.out.println(filter.mightContain(1)); // true System.out.println(filter.mightContain(4)); // 大概率 false System.out.println(filter.mightContain(999)); // false } }优点空间效率非常高100 万个元素只需要大约 1 MB 左右的内存且查询时间复杂度为 O(k)k 是哈希函数个数。缺点存在误判率可能会把不存在的 key 判为存在但不会把存在的 key 判为不存在。不支持删除元素。分布式场景下需要维护多个服务实例共享同一个布隆过滤器。3.3 方案三接口层参数校验这是最容易忽略但成本最低的防护手段。很多缓存穿透都是因为接口层没有做基础校验把明显非法的参数放进了查询链路。例如ID 不能为负数。ID 长度不能超过某范围。时间参数必须符合格式。分页参数不能无限大。用户状态必须是合法枚举值。public Object getProduct(String productId) { // 参数校验拦截非法请求 if (productId null || productId.length() 0 || !productId.matches(\\d{1,8})) { throw new IllegalArgumentException(productId 不合法); } // 继续查询... }这个方案无法阻止“合法但不存在的 ID”但能过滤掉大量垃圾请求。3.4 方案四限流与降级在缓存穿透攻击发生的时候即使做了空值缓存或布隆过滤器仍然可能存在误判或大量合法 key 的并发请求。这时候需要限流和降级来保护数据库。限流可以基于 Redis 的计数器、令牌桶算法也可以使用 Sentinel、Hystrix 等组件。主要目标是当某个 key 或某个接口的 QPS 超过阈值时直接拒绝部分请求而不是全部打到数据库。降级的意思是当数据库出现异常或者压力过大时先返回兜底数据或错误提示保证系统整体可用。一个简单的 Redis 限流伪代码public boolean tryAcquire(String key, int limit, int windowSeconds) { Long count redis.incr(key); if (count 1) { redis.expire(key, windowSeconds); } return count limit; }调用时if (!tryAcquire(rate:product: productId, 10, 1)) { // 超过每秒 10 次直接丢弃 return null; }这四类方案可以组合使用。实际生产系统中往往是先做参数校验再用布隆过滤器拦截非法 key最后用空值缓存兜底再配合限流保护数据库。4. 实战Spring Boot Redis 实现缓存穿透防护下面通过一个完整的 Spring Boot 项目演示如何从最基础的缓存代码升级到具备空值缓存、布隆过滤器的完整方案。4.1 环境准备JDK 1.8 或 11。Maven 3.6 以上。Spring Boot 2.7.x其他版本思路一致。Redis 6.x本地启动并占用默认端口 6379。MySQL 8.x数据库中有一张product表。IDE 推荐 IntelliJ IDEA。项目结构如下src/main/java/com/example/cache/ ├── CachePenetrationApplication.java ├── controller/ProductController.java ├── service/ProductService.java ├── config/BloomFilterConfig.java └── common/BloomFilterHelper.java4.2 基础缓存查询代码先写一个没有防护的查询接口。ProductController.javaRestController RequestMapping(/product) public class ProductController { Resource private ProductService productService; GetMapping(/{id}) public Result getProduct(PathVariable Long id) { return Result.ok(productService.getProduct(id)); } }ProductService.javaService public class ProductService { Resource private StringRedisTemplate stringRedisTemplate; Resource private ProductMapper productMapper; private static final String CACHE_PREFIX product:; public Product getProduct(Long id) { String cacheKey CACHE_PREFIX id; // 1. 查缓存 String json stringRedisTemplate.opsForValue().get(cacheKey); if (json ! null) { return JSON.parseObject(json, Product.class); } // 2. 缓存没有查数据库 Product product productMapper.selectById(id); // 3. 回写缓存 if (product ! null) { stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 1800, TimeUnit.SECONDS); } return product; } }这个版本的问题已经说过如果id对应数据不存在缓存不会写入下一次请求还会查数据库。4.3 增加空值缓存在getProduct方法中把“查不到”的情况也缓存起来。private static final String EMPTY_FLAG EMPTY; public Product getProductWithNullCache(Long id) { String cacheKey CACHE_PREFIX id; String json stringRedisTemplate.opsForValue().get(cacheKey); if (json ! null) { if (EMPTY_FLAG.equals(json)) { return null; } return JSON.parseObject(json, Product.class); } Product product productMapper.selectById(id); if (product null) { // 空值缓存 120 秒 stringRedisTemplate.opsForValue().set(cacheKey, EMPTY_FLAG, 120, TimeUnit.SECONDS); return null; } stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 1800, TimeUnit.SECONDS); return product; }这样同一个不存在 ID 的第二次请求就会命中空值缓存不再访问数据库。4.4 引入布隆过滤器空值缓存虽然有效但只能拦截“重复的非法 key”。如果攻击者每秒换一个新 ID空值缓存会不断填充新 key最终耗尽 Redis 内存。更优雅的方案是布隆过滤器。在查询前先判断 ID 是否存在。这里用 Redisson 的 RBloomFilter它是分布式实现适合多实例部署。添加依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.20.1/version /dependency注意Redisson 版本需要和 Spring Boot 版本兼容。如果项目已经用了 Redisson直接复用即可。创建配置类Configuration public class BloomFilterConfig { Bean public RBloomFilterLong productBloomFilter(RedissonClient redissonClient) { RBloomFilterLong bloomFilter redissonClient.getBloomFilter(productBloomFilter); // 初始化预计元素 100000误判率 0.01 bloomFilter.tryInit(100000L, 0.01); return bloomFilter; } }启动时把所有商品 ID 写入布隆过滤器。可以在CommandLineRunner中初始化Component public class BloomFilterInitializer implements CommandLineRunner { Resource private ProductMapper productMapper; Resource private RBloomFilterLong productBloomFilter; Override public void run(String... args) { ListLong ids productMapper.selectAllIds(); ids.forEach(productBloomFilter::add); System.out.println(布隆过滤器初始化完成共加载 id 数量 ids.size()); } }查询服务中增加过滤逻辑public Product getProductWithBloomFilter(Long id) { // 布隆过滤器判断不存在直接返回 if (!productBloomFilter.contains(id)) { return null; } // 以下逻辑与普通缓存查询相同 String cacheKey CACHE_PREFIX id; String json stringRedisTemplate.opsForValue().get(cacheKey); if (json ! null) { return JSON.parseObject(json, Product.class); } Product product productMapper.selectById(id); if (product ! null) { stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 1800, TimeUnit.SECONDS); } return product; }由于布隆过滤器存在误判极少数不存在的 ID 可能通过过滤器此时仍可以配合空值缓存兜底。4.5 压测验证本地可以使用wrk或ApacheBench做简单压测。ab -n 10000 -c 100 http://localhost:8080/product/999999这个 ID 在数据库中不存在。对比两个版本无防护版本MySQL QPS 接近 10000数据库连接池被打满接口平均耗时升高。布隆过滤器版本绝大多数请求直接返回MySQL QPS 为 0接口平均耗时在 1ms 以内。当然压测结果取决于机器性能和数据库配置但结论是一致的布隆过滤器能直接将无效查询拦截在最前端。5. 缓存穿透排查清单如果在你的系统中已经出现了数据库压力异常怀疑是缓存穿透可以按照下面的清单逐步排查。排查项操作判断标准1. 查看 Redis 命中率redis-cli info stats查看keyspace_hits和keyspace_misses命中率突然下降misses 大量增加2. 查看 DB 慢查询开启 MySQL 慢查询日志分析SELECT条件大量相同 WHERE 条件的 SQL3. 检查请求日志统计 API 访问 TOP N key大量重复或递增的不存在 ID4. 检查缓存 key查看 Redis 中是否存在空值缓存没有空值缓存说明穿透没被拦截5. 检查参数校验确认接口层是否有正则、枚举、范围校验非法参数直接进入 Service6. 检查布隆过滤器确认过滤器是否初始化是否包含全量 ID新数据未及时同步导致误判7. 检查限流配置确认接口是否有 QPS 限制无限流时风险极大如果以上步骤都无法定位可以把请求链路日志打开追踪一个非法 ID 从进入接口到数据库查询的完整过程。6. 最佳实践与工程建议6.1 缓存空值的 TTL 不要拍脑袋空值缓存的过期时间要根据业务更新频率设置。如果商品数据可能随时新增TTL 建议 30 到 120 秒如果数据基本不变可以设置 5 分钟。过长的 TTL 会导致刚新增的数据短时间内查不到。6.2 布隆过滤器要处理数据同步布隆过滤器初始化后新增的数据必须同步写入过滤器。否则新增记录会被误判为不存在导致用户永远无法查询到新数据。常见做法是新增数据时调用bloomFilter.add(id)。如果数据量大可以使用定时任务批量同步。不要把布隆过滤器的初始化放在每次查询时。6.3 多层防护组合使用单靠一种方案往往不够。推荐组合接口层做参数校验。布隆过滤器拦截不存在的 ID。空值缓存兜底极小概率的误判。网关或服务层做限流。数据库连接池设置合理最大值。6.4 重视日志和监控为缓存查询加上必要的日志但注意不要打印全量参数。可以打印 key 的 hash、ID 段、缓存命中情况。监控指标建议包含Redis keyspace hits/misses。数据库 QPS。接口 P99 延迟。空值缓存的数量。布隆过滤器误判率。6.5 不要在缓存前无脑加锁有同学为了防止缓存穿透给查询方法加synchronized或分布式锁。这对于击穿是有效的但对穿透无效因为锁只能保证同一个 key 只有一个线程去查库如果 key 本身不存在锁释放后后续请求依然会查库。而且高并发下锁会降低吞吐量。7. 总结与延伸缓存穿透的核心是“查询不存在的数据”它让缓存层形同虚设把压力直接转嫁给数据库。解决思路无非两条让无效请求在早期被拦截或者让无效结果被缓存吸收。本文给出的四种方案中空值缓存适合快速落地。布隆过滤器适合大规模合法 ID 的场景。参数校验是最基础也是必须做的。限流是系统安全的最后一道防线。实际项目里建议先从空值缓存和参数校验做起等确认出现大量非法 key 后再引入布隆过滤器。这样既能把风险控制住又不会让系统复杂度过早膨胀。下一步可以继续学习缓存击穿和缓存雪崩的防护比如互斥锁、逻辑过期、多级缓存等。把这些方案完整落地一遍对 Redis 和数据库的底层行为会有更深的理解。如果本文对你有帮助可以收藏备用遇到问题也欢迎在评论区交流。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →