ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

缓存穿透防护实战:缓存空值、布隆过滤器与限流策略解析

缓存穿透防护实战:缓存空值、布隆过滤器与限流策略解析 缓存穿透这个问题很多团队是在线上事故中第一次认识它的。某个周二的晚上监控大屏突然飘红数据库连接池被耗尽接口平均响应时间从 30ms 涨到 5 秒用户端看到的页面全是超时和错误。开发同学登录服务器看日志发现同一个商品 ID 在短时间内被请求了上万次而这个商品 ID 在数据库里根本不存在。这是一次非常典型的缓存穿透事故。请求绕过缓存直接打到数据库把一个 MySQL 实例活生生打挂了。更麻烦的是这种请求通常是恶意脚本在循环遍历你杀了一个 ID他就换下一个几乎无法通过人工封号解决。这篇文章就把缓存穿透这件事讲透它到底是什么为什么这么难防以及缓存空值、布隆过滤器、接口限流这三道防线分别怎么落地。最后我会给出一套可运行的 Spring Boot 示例代码方便你直接在自己的项目中对照实践把数据库从“裸奔”状态下救回来。1. 缓存穿透到底“穿透”了什么一次夜间事故的复盘先复盘一下文章开头说的那次事故。你的系统正常情况下是这样的用户查询商品详情首先查 Redis如果 Redis 里面有数据直接返回根本不碰数据库。只有当 Redis 里没有这个 key 时才会去查 MySQL查完之后再把数据回填到 Redis。正常请求用户 - Redis命中 - 返回 缓存未命中用户 - Redis未命中 - MySQL - 回填 Redis - 返回这个流程看起来没有问题但里面藏着一个漏洞当查询的 key 在 Redis 和 MySQL 中都不存在时缓存层就没有任何作用了。攻击者构造一个不存在的商品 ID比如 99999999。你的程序查 Redis发现没有这个 key于是去查 MySQLMySQL 也查不到程序返回“商品不存在”同时也不会写入缓存。下一次请求同样的 ID程序再查 Redis还是没有再去查 MySQL。每一次请求都穿越了缓存直达数据库。攻击者不需要费多大力气只要用脚本循环遍历一批不存在的 ID就能把你数据库的 QPS 打满。这就是“缓存穿透”这个名称的由来流量绕过了缓存这层盾牌直接穿透到了数据库身上。真正容易踩坑的地方是很多人以为缓存穿透是 Redis 配置问题或者缓存中间件的问题其实完全不是。缓存组件本身工作正常它只是没有能力挡住“查不到数据”的请求因为这类请求天然无法被缓存命中。这是一个分布式系统防御设计的问题而不是缓存组件故障的问题。从本质上看缓存穿透之所以危险是因为它把数据库暴露在了无差别流量的冲击之下。数据库的性能是有限的它的连接数、线程数、磁盘 IO 都有上限而攻击者制造无效请求的成本几乎为零。你越依赖数据库数据库就越脆弱你越依赖缓存缓存就越容易被“查无此 key”的流量绕过。要解决这个问题核心逻辑有三条路第一让缓存能够处理“查到空结果”的情况第二在查询缓存之前用更廉价的数据结构判断这个 key 到底存不存在第三在接口层限制可疑流量的访问频率。后面三个章节会逐一展开。2. 缓存穿透、缓存击穿、缓存雪崩三个概念别再傻傻分不清在讲方案之前必须先厘清三个经常一起出现的概念。很多人在面试里能背出定义一到排查问题时就把它们混为一谈最终导致方案用错。概念关键特征触发条件典型解决思路缓存穿透查询的数据在缓存和数据库中都不存在恶意请求遍历不存在的 ID缓存空值、布隆过滤器、参数校验、限流缓存击穿某个热点 key 过期瞬间大量请求同时打到数据库热点数据过期且并发极高互斥锁、逻辑过期、热点数据预热缓存雪崩大量 key 同时过期导致数据库瞬间压力暴增大批 key 设置了相同的过期时间过期时间加随机值、多级缓存、熔断降级三者的区别用场景来理解最直观。缓存穿透是“打了一个不存在的靶子”靶子本来就不在所以缓存永远挡不住。缓存击穿是“打一个很热的靶子但靶子刚好被撤了下来”也就是热点 key 在过期的一瞬间缓存失效并发请求全部落到数据库。缓存雪崩则是“所有靶子一起被撤下来”大量 key 在同一时间过期数据库承受不了瞬间的请求洪峰。从解决方向上看三者完全不同。缓存穿透的核心是“如何低成本地判断 key 不存在”缓存击穿的核心是“如何保证热点 key 过期时只有一个请求去重建缓存”缓存雪崩的核心是“如何避免过期时间过于集中”。本文只解决缓存穿透但你需要知道它的边界。一个完整的缓存防护体系往往需要同时考虑穿透、击穿和雪崩少了任何一个环节数据库都可能被找到新的突破口。3. 最容易被打穿的业务场景哪些项目风险最高并非所有项目都会遇到缓存穿透。它通常需要满足两个条件一是业务中存在“查询不到数据”的合法场景二是接口接收外部传入的 ID 或者编码。如果你的系统满足这两个条件那么缓存穿透就只是一个时间问题。以下四类场景是最常见的重灾区。商品详情类接口。商品 ID 是用户可猜测的而且商品可能存在下架、删除等状态。攻击者只需要从 1 开始递增遍历就能制造大量不存在的 ID。电商大促期间这类接口往往没有严格限流一旦被打穿数据库会立刻成为瓶颈。订单查询类接口。订单号通常是业务号可预测性较强。很多系统在查询订单时先查缓存再查数据库最后返回“订单不存在”。但一个不存在的订单号依然会触发完整的数据库查询链路。用户 Token 或账号校验。这类接口使用频繁流量大而且外部攻击者可以随机生成无效 Token 来试探。每一次无效 Token 校验都会穿透缓存直接把压力施加到用户表或登录日志表上。短链解析和邀请码核销类业务。短链码、邀请码的字符空间有限攻击者可以批量遍历。如果数据库中没有对应记录缓存又无法命中请求就会全部打到数据库。这些场景有一个共同特点接口对外开放参数可预测业务存在“查不到是正常情况”的语义。只要你的系统满足这三个条件就必须主动考虑缓存穿透防护而不是等监控报警之后再去救火。另外还要警惕一种容易忽略的情况业务逻辑里“软删除”的数据。比如商品状态被置为 1已删除查询主流程可能不会返回但数据库里确实存在这条记录。如果查询条件只过滤状态位不带主键白名单这类数据也会表现出“查不到”的特征从而成为穿透流量的目标。4. 方案一缓存空值给不存在的 key 也安排一个座位缓存穿透最直接的原因是“查不到的数据无缓存可命”。那么思路自然就来了既然查不到那我把“查不到”这个结果也缓存起来问题不就解决了吗这就是缓存空值方案。4.1 原理与实现思路当数据库查询结果为空时不要直接返回而是把一个特殊标记写入缓存并设置一个较短的过期时间。这样下一次同样的 key 过来缓存能命中这个空值标记接口直接返回“不存在”不再查询数据库。关键代码如下// 文件路径src/main/java/com/example/demo/service/ProductService.java Service public class ProductService { private static final String PRODUCT_CACHE_PREFIX product:detail:; private static final String EMPTY_MARK EMPTY; Autowired private StringRedisTemplate redisTemplate; Autowired private JdbcTemplate jdbcTemplate; private final ObjectMapper objectMapper new ObjectMapper(); public Product getProductById(Long id) { // 第 1 步参数校验后面会细讲 if (id null || id 0) { return null; } String cacheKey PRODUCT_CACHE_PREFIX id; // 第 2 步查缓存 String cachedValue redisTemplate.opsForValue().get(cacheKey); if (cachedValue ! null) { if (EMPTY_MARK.equals(cachedValue)) { return null; } try { return objectMapper.readValue(cachedValue, Product.class); } catch (JsonProcessingException e) { // 缓存数据损坏时按穿透处理回源数据库 redisTemplate.delete(cacheKey); } } // 第 3 步查数据库 Product product queryFromDb(id); // 第 4 步回填缓存 if (product null) { // 空值也缓存过期时间短一点并加随机抖动防止集中过期 int emptyCacheSeconds 180 new Random().nextInt(120); redisTemplate.opsForValue().set( cacheKey, EMPTY_MARK, emptyCacheSeconds, TimeUnit.SECONDS ); return null; } // 真实数据缓存 30 分钟同时加随机抖动 int realCacheSeconds 1800 new Random().nextInt(300); try { String json objectMapper.writeValueAsString(product); redisTemplate.opsForValue().set( cacheKey, json, realCacheSeconds, TimeUnit.SECONDS ); } catch (JsonProcessingException e) { throw new RuntimeException(商品序列化失败, e); } return product; } private Product queryFromDb(Long id) { try { return jdbcTemplate.queryForObject( SELECT id, name, price FROM product WHERE id ?, new BeanPropertyRowMapper(Product.class), id ); } catch (EmptyResultDataAccessException e) { // 查询结果为空返回 null 给上层做空值缓存 return null; } } }4.2 这里真正容易踩坑的地方缓存空值方案实现简单效果立竿见影但它有几个容易被忽略的坑。第一个坑是空值 key 占内存。恶意流量遍历 100 万个不存在的 ID就会在 Redis 中产生 100 万个空值 key。这些 key 虽然设置了过期时间但在过期之前依然占用内存。如果业务量大Redis 内存会快速上涨。解决办法是给空值 key 设置统一前缀比如null:后续可以写定时任务批量清理或者降低空值缓存时间。第二个坑是空值缓存时间不宜过长。如果你把不存在的商品 ID 缓存 30 分钟而商品刚好在 10 分钟后被重新上架用户在 20 分钟内仍然查不到这个商品。这会直接导致数据一致性问题而且很难排查。因此空值缓存时间建议控制在 2 到 5 分钟加上随机抖动既起到防穿透作用又能接受短时间内的数据延迟。第三个坑是要区分“缓存空值”和“缓存数据异常”。当缓存中读取到EMPTY_MARK时返回空结果当读到非空字符串但 JSON 解析失败时说明缓存数据已经损坏应该删除缓存并回源数据库而不是直接返回“不存在”。这段逻辑在示例代码中已经体现出来很多初学者会漏掉。缓存空值适合的业务形态是数据量不大、攻击流量有限、希望快速落地解决。它是防穿透方案的“下限”几乎每个项目都应该先做这一步。5. 方案二布隆过滤器把非法 key 拦截在查询之前缓存空值方案是事后拦截请求先打到缓存发现没命中然后缓存空值把后续请求挡住。但空值 key 本身还会短暂占内存而且如果攻击流量在空值过期后再次发起依然会穿透。布隆过滤器则是一种前置拦截方案在查缓存之前先判断 key 到底存不存在如果过滤器认为不存在直接返回连 Redis 都不查。5.1 布隆过滤器是怎么工作的布隆过滤器是一个非常节省内存的概率性数据结构。它的核心逻辑是把所有合法 key 通过多个哈希函数映射到一个很长的位数组上在位数组中把对应位置置为 1。查询时同样计算多个哈希值如果所有对应位置都是 1则认为 key “可能存在”只要有任何一个位置为 0则可以确定 key “一定不存在”。简单理解它是一个“大概率在名单里”的检查员。如果它说“不在”那就一定不在如果它说“在”只是大概率在也有小概率误判。对于缓存穿透场景这个特性非常合适。我们只关心“这个 ID 到底是不是合法商品 ID”如果一个 ID 被过滤器判定为不存在就直接返回“商品不存在”根本不用继续查缓存和数据库。即使误判了一个不存在的 ID 为可能存在最坏的结果也就是多查一次数据库不会返回错误结果。5.2 使用 Guava 实现布隆过滤器在 Java 项目中最常用的布隆过滤器实现是 Google Guava。以下代码示例展示了如何在应用启动时加载所有商品 ID并在查询时进行过滤判断// 文件路径src/main/java/com/example/demo/filter/ProductBloomFilter.java Component public class ProductBloomFilter { // 预计要放入过滤器的元素数量 private static final int EXPECTED_INSERTIONS 100000; // 误判率越低越占内存这里设置为 1% private static final double FPP 0.01; private final BloomFilterLong bloomFilter BloomFilter.create(Funnels.longFunnel(), EXPECTED_INSERTIONS, FPP); Autowired private JdbcTemplate jdbcTemplate; PostConstruct public void init() { ListLong ids jdbcTemplate.queryForList( SELECT id FROM product, Long.class ); ids.forEach(bloomFilter::put); } /** * 判断商品 ID 是否可能存在。 * false 表示一定不存在true 表示可能存在。 */ public boolean mightContain(Long id) { return bloomFilter.mightContain(id); } }接入查询链路时只需要在ProductService.getProductById方法的最前面加一段判断Autowired private ProductBloomFilter productBloomFilter; public Product getProductById(Long id) { if (id null || id 0) { return null; } // 布隆过滤器前置判断不存在直接返回 if (!productBloomFilter.mightContain(id)) { return null; } // 后续查缓存、查数据库的逻辑保持不变 }5.3 布隆过滤器的局限与更新策略布隆过滤器也不是银弹使用它之前必须想清楚数据更新策略。最大的问题在于新增数据的同步。布隆过滤器在应用启动时一次性加载所有商品 ID但业务是动态的每天都会有新商品上架。如果新增商品后没有把新 ID 放入过滤器这个新商品会被误判为“不存在”用户永远查不到。解决做法有三种增量更新在商品创建的 Service 方法中同步调用bloomFilter.put(id)。实现简单但需要保证业务代码中所有创建入口都不遗漏一旦遗漏就会产生线上问题。定时全量重建每天凌晨从数据库重新加载全部商品 ID构建新的过滤器。需要解决新旧过滤器切换的问题可以用双缓冲即维护两份过滤器重建完成后切换引用。混合策略增量更新保证实时性定时重建兜底防止增量遗漏积累。另一个局限性是过滤器启动加载大 key 集合时耗时较长。如果平台有上亿商品数据一次性加载可能耗时几分钟。可以考虑只用热点或高权重商品的 ID 构建过滤器或使用 Redis 的 BitMap 实现分布式布隆过滤器避免单应用内存受限。布隆过滤器适用的业务形态是数据量大、相对稳定、存在可预知的合法 key 全集并且希望把无效流量在 Redis 之前就拦截掉。在中小项目里它不是第一优先级但在高并发 C 端系统中它通常是防穿透体系里最前面的一道闸门。6. 方案三接口层防护流量层面的最后一道保险缓存空值和布隆过滤器解决的是“数据层面”的穿透问题但还有一个维度需要防守如果攻击者流量足够大即使每个 key 只查一次数据库也能把你的数据库打挂。这时候光靠缓存层的方案就不够了还需要在接口层和流量层做防护。6.1 参数校验是第一道口子很多缓存穿透事故的根源是接口没有对入参做基本校验任何数字都可以进入查询链路。先看你的接口是不是也存在这种情况。一个商品 ID 不可能小于等于 0不可能超过某个合理的业务范围也通常不会是负数。如果这些非法参数都能直接进入 Service 层说明你的参数校验还不到位。在 Spring Boot 项目中可以引入spring-boot-starter-validation或在 Controller 层手动校验。GetMapping(/{id}) public ResultProduct getProduct(PathVariable Long id) { if (id null || id 0 || id 999999999L) { return Result.error(无效的商品 ID); } return productService.getProductById(id); }注意参数校验只是过滤掉“明显非法”的请求并不能防御“ID 本身合法但商品不存在”的穿透流量。所以参数校验是地基但不能只靠它。6.2 基于 key 维度的限流把恶意请求挡在业务逻辑之外限流是接口层保护数据库的关键手段。我们的目标是对同一个 key 的访问频率进行限制一旦超过阈值直接拒绝请求。在实践中更专业的做法是基于 Redis 的滑动窗口或者令牌桶实现分布式限流。这里先给一个本地内存版的时间窗口限流示例虽然只适用于单机演示但限流思路和代码结构完全一样// 文件路径src/main/java/com/example/demo/limit/KeyRateLimiter.java Component public class KeyRateLimiter { // key - 访问时间戳队列 private final ConcurrentHashMapString, DequeLong visitWindow new ConcurrentHashMap(); // 每个 key 在 60 秒内最多允许访问 50 次 private static final int LIMIT 50; private static final long WINDOW_MILLIS 60_000L; /** * 检查当前 key 是否超过限流阈值。 */ public boolean isOverLimit(String key) { long now System.currentTimeMillis(); DequeLong deque visitWindow.computeIfAbsent(key, k - new ArrayDeque()); synchronized (deque) { // 清理窗口之外的时间记录 while (!deque.isEmpty() now - deque.peekFirst() WINDOW_MILLIS) { deque.pollFirst(); } if (deque.size() LIMIT) { return true; } deque.addLast(now); } return false; } }然后在查询商品前先判断当前 key 是否已经被限流Autowired private KeyRateLimiter keyRateLimiter; public Product getProductById(Long id) { String rateLimitKey rate:product: id; if (keyRateLimiter.isOverLimit(rateLimitKey)) { throw new BizException(请求过于频繁请稍后重试); } // 其余查询逻辑不变 }这个本地实现有几个明显问题第一它只在单机内存中生效多实例部署时必须换成 Redis Lua 实现第二它把同一个 key 的正常并发请求也限住了比如秒杀活动中有 1000 人同时抢购同一个商品第一个请求到达后后面 949 个请求都会被误伤。因此限流阈值必须根据业务压测结果来决定并且针对抢购等热点场景要单独设计。6.3 限流解决的是“恶意流量”问题而不是“数据不存在”问题需要说明的是接口限流并不能在数据结构层面解决缓存穿透它的作用是当缓存层和布隆过滤器都被绕过时限制攻击速率避免数据库瞬间被打挂。一套完整的接口防护还应该包括IP 维度的限流、用户维度的限流、短期失败次数的黑名单机制以及第一时间对异常流量来源进行告警。这些能力通常由网关层承担但如果你没有网关这些逻辑就必须写在应用层。接口层防护适用任何对外提供服务的系统。它不一定能完全消除穿透流量但它能保证你的数据库在最坏情况下依然有喘息的空间。7. 三种方案选型对比别把全部武器一次堆上去讲了三种方案你可能会纠结到底该用哪一种。这里做一个直接了当的选型建议。方案实现成本对内存的占用防穿透效果误伤/数据风险适用阶段缓存空值低较高空值 key 也会占内存好能挡住重复无效请求低但存在短暂的数据一致性问题中小项目、快速上线兜底布隆过滤器中低位数组非常省内存好能在缓存之前拦截大部分无效 key如果新增数据不同步会“误杀”真实数据数据量大、key 集合稳定的系统接口限流与参数校验中高低辅助性兜底不能根除穿透阈值设置不当会误伤正常用户公网接口、恶意攻击场景从实践角度看我建议按业务体量分三档来选第一档刚起步的项目或者内部系统直接做参数校验 缓存空值。不要引入布隆过滤器因为动态数据同步的成本比收益还高。缓存空值能挡住 90% 的重复穿透流量足够应对常规风险。第二档有一定流量的 C 端系统参数校验 缓存空值 接口限流。重点是限流阈值要经过压测不能拍脑袋。这个组合能应对大多数恶意遍历场景。第三档大规模高并发平台在前两档基础上增加布隆过滤器并配套完善的数据同步机制。同时把限流下沉到网关层应用层只保留业务校验和最后的保护逻辑。这里有一个常见的决策误区很多人喜欢一次性把三种方案都堆上去觉得越多的方案越安全。但实际上每多一个组件就多一份维护成本和一个故障点。布隆过滤器的误杀风险、限流的误伤风险都比缓存空值更隐蔽如果团队没有足够的监控和快速回滚能力不建议在项目初期就全量上线。记住一句话缓存空值是兜底布隆过滤器是拦截限流是保命。三个方案不是互相替代的关系而是按风险等级逐层设防的关系。8. 完整实战Spring Boot 集成缓存空值与接口限流前面讲完了原理和选型这一节提供一个可以直接运行的 Spring Boot 示例工程。示例会组合“参数校验 缓存空值 本地 key 维度限流”三套逻辑并附加布隆过滤器的可选接入方式方便你对照练习。8.1 环境准备与项目结构本文示例以 Spring Boot 3.2.x 为基础使用 JDK 17操作系统不限。Redis 建议使用 5.0 以上版本MySQL 使用 5.7 或 8.0 均可。如果你本机没有 MySQL也可以用内存数据库 H2 代替SQL 部分需要做少量调整。!-- 文件路径pom.xml -- ?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent groupIdcom.example/groupId artifactIdcache-penetration-demo/artifactId version1.0.0/version namecache-penetration-demo/name description缓存穿透防护示例/description properties java.version17/java.version /properties 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.3-jre/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies /project# 文件路径src/main/resources/application.yml server: port: 8080 spring: application: name: cache-penetration-demo redis: host: 127.0.0.1 port: 6379 timeout: 3000ms datasource: url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver示例工程结构如下src/main/java/com/example/demo ├── CachePenetrationDemoApplication.java ├── controller │ └── ProductController.java ├── service │ └── ProductService.java ├── entity │ └── Product.java ├── limit │ └── KeyRateLimiter.java └── common └── Result.java8.2 核心代码实现产品实体类// 文件路径src/main/java/com/example/demo/entity/Product.java public class Product { private Long id; private String name; private BigDecimal price; public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getName() { return name; } public void setName(String name) { this.name name; } public BigDecimal getPrice() { return price; } public void setPrice(BigDecimal price) { this.price price; } }统一返回体// 文件路径src/main/java/com/example/demo/common/Result.java public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(String message) { ResultT result new Result(); result.code 500; result.message message; return result; } // getter / setter 省略建议使用 lombok 简化 }限流器就是前面提到的时间窗口本地版// 文件路径src/main/java/com/example/demo/limit/KeyRateLimiter.java Component public class KeyRateLimiter { private final ConcurrentHashMapString, DequeLong visitWindow new ConcurrentHashMap(); private static final int LIMIT 50; private static final long WINDOW_MILLIS 60_000L; public boolean isOverLimit(String key) { long now System.currentTimeMillis(); DequeLong deque visitWindow.computeIfAbsent(key, k - new ArrayDeque()); synchronized (deque) { while (!deque.isEmpty() now - deque.peekFirst() WINDOW_MILLIS) { deque.pollFirst(); } if (deque.size() LIMIT) { return true; } deque.addLast(now); } return false; } }产品 Service组合了参数校验、缓存空值和限流逻辑// 文件路径src/main/java/com/example/demo/service/ProductService.java Service public class ProductService { private static final String PRODUCT_CACHE_PREFIX product:detail:; private static final String EMPTY_MARK EMPTY; Autowired private StringRedisTemplate redisTemplate; Autowired private JdbcTemplate jdbcTemplate; Autowired private KeyRateLimiter keyRateLimiter; private final ObjectMapper objectMapper new ObjectMapper(); public Product getProductById(Long id) { if (id null || id 0) { return null; } String cacheKey PRODUCT_CACHE_PREFIX id; String rateLimitKey rate:product: id; if (keyRateLimiter.isOverLimit(rateLimitKey)) { throw new RuntimeException(请求过于频繁请稍后重试); } String cachedValue redisTemplate.opsForValue().get(cacheKey); if (cachedValue ! null) { if (EMPTY_MARK.equals(cachedValue)) { return null; } try { return objectMapper.readValue(cachedValue, Product.class); } catch (JsonProcessingException e) { redisTemplate.delete(cacheKey); } } Product product queryFromDb(id); if (product null) { int emptyCacheSeconds 180 new Random().nextInt(120); redisTemplate.opsForValue().set(cacheKey, EMPTY_MARK, emptyCacheSeconds, TimeUnit.SECONDS); return null; } int realCacheSeconds 1800 new Random().nextInt(300); try { String json objectMapper.writeValueAsString(product); redisTemplate.opsForValue().set(cacheKey, json, realCacheSeconds, TimeUnit.SECONDS); } catch (JsonProcessingException e) { throw new RuntimeException(商品序列化失败, e); } return product; } private Product queryFromDb(Long id) { try { return jdbcTemplate.queryForObject( SELECT id, name, price FROM product WHERE id ?, new BeanPropertyRowMapper(Product.class), id ); } catch (EmptyResultDataAccessException e) { return null; } } }Controller// 文件路径src/main/java/com/example/demo/controller/ProductController.java RestController RequestMapping(/product) public class ProductController { Autowired private ProductService productService; GetMapping(/{id}) public ResultProduct getProduct(PathVariable Long id) { if (id null || id 0 || id 999999999L) { return Result.error(无效的商品 ID); } try { Product product productService.getProductById(id); if (product null) { return Result.error(商品不存在); } return Result.success(product); } catch (RuntimeException e) { return Result.error(e.getMessage()); } } }8.3 可选的布隆过滤器接入方式如果你在真实项目中决定使用布隆过滤器可以在上述工程基础上增加ProductBloomFilter类然后在ProductService.getProductById的最前面增加一次判断。注意只有在你能保证商品 ID 增量同步到过滤器时才建议开启否则不要贸然接进去。// 文件路径src/main/java/com/example/demo/filter/ProductBloomFilter.java Component public class ProductBloomFilter { 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); Autowired private JdbcTemplate jdbcTemplate; PostConstruct public void init() { ListLong ids jdbcTemplate.queryForList( SELECT id FROM product, Long.class ); ids.forEach(bloomFilter::put); } public boolean mightContain(Long id) { return bloomFilter.mightContain(id); } }接入查询服务Autowired private ProductBloomFilter productBloomFilter; public Product getProductById(Long id) { if (id null || id 0) { return null; } if (!productBloomFilter.mightContain(id)) { return null; } // 其余逻辑不变 }启动类// 文件路径src/main/java/com/example/demo/CachePenetrationDemoApplication.java SpringBootApplication public class CachePenetrationDemoApplication { public static void main(String[] args) { SpringApplication.run(CachePenetrationDemoApplication.class, args); } }8.4 初始化数据表执行下面的 SQL 创建商品表并插入两条测试数据CREATE TABLE product ( id BIGINT PRIMARY KEY, name VARCHAR(64) NOT NULL, price DECIMAL(10, 2) NOT NULL ); INSERT INTO product (id, name, price) VALUES (1, iPhone 15 Pro, 8999.00), (2, MacBook Pro 14, 14999.00);9. 运行结果与效果验证启动 Redis 和 MySQL 后在项目根目录执行mvn spring-boot:run应用启动成功后用 curl 验证效果。第一次查询存在的商品 IDcurl http://localhost:8080/product/1预期返回{code:200,message:success,data:{id:1,name:iPhone 15 Pro,price:8999.00}}连续查询第二次Redis 中已经有缓存观察日志会发现不会再次查询数据库。查询不存在的商品 IDcurl http://localhost:8080/product/999第一次会查数据库并写入空值缓存。预期返回{code:500,message:商品不存在,data:null}然后再次请求相同的 ID预期返回结果相同但数据库不会收到新的查询。可以到 Redis 中确认空值 key 已写入redis-cli 127.0.0.1:6379 GET product:detail:999 EMPTY 127.0.0.1:6379 TTL product:detail:999 (integer) 172验证限流在短时间内用同一个不存在的 ID 连续请求超过 50 次预期会看到接口返回“请求过于频繁请稍后重试”。如果需要模拟真实压测效果可以用ab等工具对同一个不存在的 ID 发起高频请求同时观察数据库的查询日志或者慢日志。你会发现在没有空值缓存之前每一次请求都会落到数据库而接入空值缓存和限流之后除了第一批请求后续请求都被 Redis 和限流逻辑拦截了。ab -n 10000 -c 100 http://localhost:8080/product/999判断成功的标准有三个第一应用日志中 MySQL 查询次数不再随请求数线性增长第二Redis 中空值 key 数量保持稳定第三数据库连接池水位和 QPS 没有明显波动。10. 常见问题与排查思路下面整理了缓存穿透防护落地过程中比较常见的几类问题大家可以对照排查。问题现象可能原因排查方式解决方案Redis 内存快速上涨空值 key 过多用redis-cli --bigkeys或SCAN统计空值前缀 key 数量降低空值过期时间写清理任务清理null:前缀 key或考虑布隆过滤器前置拦截新上架商品查询返回“不存在”布隆过滤器没有同步增量 ID查看商品创建方法中是否调用了put方法在创建入口同步更新过滤器或采用定时全量重建策略正常用户请求被限流误伤限流阈值设置过低查看限流日志和 QPS 峰值根据压测数据调整阈值对抢购热点单独配置或改用令牌桶算法空值缓存过期后再次打爆数据库空值时间太短攻击流量持续查看 Redis 空值 key 的过期时间和 DB QPS 曲线调长空值缓存时间并加随机抖动叠加布隆过滤器拦截并发第一次请求时数据库瞬间冲高缓存未命中时没有互斥重建查看 DB QPS 峰值与 Redis key 过期时间是否对应对缓存重建增加分布式锁同一 key 只允许一个线程查 DB商品数据库更新后缓存还是旧值缓存没有及时失效检查更新方法是否删除缓存采用 Cache Aside 模式先更新数据库再删除缓存并做好重试机制排查时有一个基本顺序先看 Redis 缓存是否生效再看空值 key 是否存在再看限流日志最后才去翻数据库慢查询日志。很多问题并不是第一层防线失效而是多层防线中的某一层配置出了问题逐层检查能快速缩小范围。11. 生产环境最佳实践从“能防”到“防得住”方案落地之后更重要的是把防护体系变成可持续运行的生产能力。从工程实践角度看有四个方向值得投入。第一个方向是监控指标。缓存穿透防护是否生效不能靠感觉要靠数据。建议至少监控以下指标Redis 缓存命中率、数据库查询 QPS、空值 key 数量、限流拒绝次数。其中空值 key 数量是一个很敏感的指标如果它在短时间内急剧增长说明要么有攻击流量要么业务上出现了大量无效查询需要第一时间告警。第二个方向是缓存过期时间的随机性。无论是真实数据还是空值缓存过期时间都要加随机抖动。如果所有 key 都在同一秒过期缓存穿透问题还没解决就可能先引发缓存雪崩。随机值的范围可以参考基础过期时间的 10% 到 30%。第三个方向是优雅降级。在极端情况下即使有缓存空值和布隆过滤器数据库仍可能因为瞬时流量过大而濒临崩溃。此时需要应用层具备快速降级能力对非核心接口直接返回降级数据对核心接口启用简化版查询必要时直接熔断数据库查询先保住系统可用性再恢复数据一致性。第四个方向是全链路压测。不要在事故发生后才验证方案是否有效。上线前用 ab、JMeter 或 Locust 模拟高频穿透流量观察数据库 QPS 和连接池水位。压测目标不是“不报错”而是“在攻击流量翻倍时数据库依然有冗余容量”。如果你发现数据库 QPS 已经打满说明防线层级不够需要继续往前加拦截。第五个方向是安全边界。对于公网接口建议在网关层增加 IP 维度的限流和黑名单机制并记录攻击特征。对明显的遍历行为比如短时间内大量不同 ID 的 404 请求可以自动识别并加入临时黑名单。这些机制的实现成本和运维成本不低但一旦遇到恶意攻击它们往往是保护数据库不被拖垮的关键。12. 最后要说的话缓存穿透从来都不是缓存的错而是你的系统在数据访问链路上缺少“安保意识”。如果把数据库比作一家银行的金库缓存就是金库外面的接待大厅而布隆过滤器是大门处的安检员缓存空值是给“找不到人”的情况做的登记簿限流则是保安队伍。每一层防线都有自己的作用少了任何一层金库的直接暴露风险都会升高。如果你是在接手一个老项目我建议先不要急着把三种方案全部堆上去。第一步翻一翻线上日志确认是否存在大量“查无此数据”的重复请求第二步把接口参数校验补上给每个读接口加一个合理的边界约束第三步实现缓存空值这是投入产出比最高的一步。等观察一段时间确认确实存在恶意穿透流量再逐步引入布隆过滤器和分布式限流。数据库是慢变量保护它的关键永远是把流量挡在更前面的位置。希望这篇文章能帮你少踩一个洞也少熬一个夜。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表