一、什么是缓存穿透?一个“查无此人”的麻烦
想象一下,你开了一家大型图书馆(这代表你的应用),为了快速找到热门书籍,你在门口放了一个智能书架(这就是Redis缓存)。当有人来借《三体》时,你首先看智能书架,有就直接给他,又快又省力。如果书架上没有,你再去庞大的书库(这就是数据库,如MySQL)里找,找到后除了把书给读者,还会在智能书架上放一本副本,方便下一个人。
现在,问题来了。如果来了一个人,他非要借一本根本不存在的书,比如《如何在一周内学会魔法》。你的智能书架(缓存)里肯定没有,于是你每次都得跑去庞大的书库(数据库)里翻个底朝天,结果当然是每次都找不到。这个反复查询一个不存在数据的过程,就是缓存穿透。
核心危害:大量的无效查询直接打到数据库上,数据库压力剧增,就像图书馆管理员被无数个借“魔法书”的人搞得筋疲力尽,导致无法服务真正想借《三体》的读者,最终可能拖垮整个系统。
二、方案一:缓存空对象——给“查无此人”做个标记
这是最简单直接的办法。既然数据库里没有这个“魔法书”,那我们就在智能书架(缓存)上,为这个不存在的书名放一个特殊的标记,比如一张写着“此书不存在”的纸条,并设置一个较短的过期时间。
这样,下一个再来借《如何在一周内学会魔法》的人,看到这个纸条,就知道不用去书库折腾了,直接告诉他“没有”就行。数据库的压力瞬间就消失了。
技术栈:Java + Spring Boot + Redis (Jedis/Lettuce)
下面我们看看如何用代码实现这个“缓存空对象”的策略。
// 技术栈:Java + Spring Boot + Redis (Lettuce)
@Service
public class BookService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private BookRepository bookRepository; // 假设是JPA访问MySQL的仓库
// 统一的缓存键前缀
private static final String CACHE_KEY_PREFIX = “book:”;
// 空对象的缓存值,用一个特殊字符串表示
private static final String CACHE_NULL_VALUE = “NULL_OBJECT”;
// 空对象的过期时间,比如5分钟,防止长期占用缓存
private static final long CACHE_NULL_TTL = 300L;
/**
* 根据ID查询书籍信息,带有缓存空对象逻辑
* @param id 书籍ID
* @return 书籍对象,如果不存在则返回null
*/
public Book getBookById(Long id) {
// 1. 构造缓存键
String cacheKey = CACHE_KEY_PREFIX + id;
// 2. 先从Redis缓存中尝试获取
Object value = redisTemplate.opsForValue().get(cacheKey);
// 3. 判断缓存命中情况
if (value != null) {
// 3.1 如果命中,且是空对象标记,直接返回null,避免查库
if (CACHE_NULL_VALUE.equals(value)) {
System.out.println(“缓存命中空对象,ID: “ + id);
return null;
}
// 3.2 如果命中,且是正常数据,直接返回
System.out.println(“缓存命中,ID: “ + id);
return (Book) value;
}
// 4. 缓存未命中,查询数据库
System.out.println(“缓存未命中,查询数据库,ID: “ + id);
Book book = bookRepository.findById(id).orElse(null);
// 5. 将数据库查询结果写入缓存
if (book == null) {
// 5.1 如果数据库中没有,缓存一个空对象标记
redisTemplate.opsForValue().set(cacheKey, CACHE_NULL_VALUE, CACHE_NULL_TTL, TimeUnit.SECONDS);
System.out.println(“数据库无数据,缓存空对象,ID: “ + id);
} else {
// 5.2 如果数据库中有,缓存真实数据(这里设置1小时过期)
redisTemplate.opsForValue().set(cacheKey, book, 1, TimeUnit.HOURS);
System.out.println(“数据库有数据,写入缓存,ID: “ + id);
}
// 6. 返回查询结果
return book;
}
}
优缺点分析:
- 优点:实现简单,能有效应对大量不存在的随机ID查询。
- 缺点:
- 内存浪费:如果恶意攻击者构造大量不同的无效Key,会导致缓存中塞满大量无意义的空值标记,占用宝贵的内存空间。
- 数据短期不一致:如果在空对象缓存有效期内,数据库中真实地添加了这个ID的数据,那么用户在这段时间内看到的依然是“不存在”。需要业务上能容忍这种短暂的不一致,或者通过主动删除缓存来更新。
三、方案二:布隆过滤器——图书馆的“总目录”
有没有一种方法,能在读者开口问之前,就提前知道他问的书名在图书馆里到底“有没有可能存在”呢?图书馆的“总目录”或“索引卡片柜”就是这个角色。布隆过滤器(Bloom Filter)就是我们在缓存系统前加装的一个“超级高效的索引”。
它是一个很长的二进制向量(bit数组)和一系列哈希函数组成。它的特点是:
- 说“没有”,那一定没有:如果布隆过滤器说某个Key不存在,那数据库里肯定不存在。
- 说“有”,不一定真有:它说某个Key存在,数据库里可能存在(有很小的误判率)。
我们把所有数据库中存在的合法ID,都预先“登记”到这个布隆过滤器里。当查询请求来时,先问布隆过滤器:“这个ID存在吗?” 如果它说“不存在”,我们就直接返回空结果,连缓存都不查了,彻底保护了缓存和数据库。如果它说“存在”,我们再走正常的“查缓存->查数据库”流程。
结合示例:让我们用Guava库提供的布隆过滤器来实现。
// 技术栈:Java + Spring Boot + Redis + Guava BloomFilter
@Service
public class BookServiceWithBloomFilter {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private BookRepository bookRepository;
private static final String CACHE_KEY_PREFIX = “book:”;
// 初始化一个布隆过滤器,预计插入100万个元素,期望的误判率为1%
private BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);
/**
* 服务启动时,初始化布隆过滤器(例如从数据库加载所有有效ID)
*/
@PostConstruct
public void initBloomFilter() {
List<Long> allBookIds = bookRepository.findAllIds(); // 假设这个方法获取所有ID
for (Long id : allBookIds) {
bloomFilter.put(id);
}
System.out.println(“布隆过滤器初始化完成,已加载 ” + allBookIds.size() + “ 个ID。”);
}
/**
* 使用布隆过滤器的查询方法
*/
public Book getBookByIdWithBloomFilter(Long id) {
// 1. 先问布隆过滤器
if (!bloomFilter.mightContain(id)) {
// 过滤器明确告诉我“这个ID很可能不存在”,直接返回null,拦截无效请求
System.out.println(“布隆过滤器拦截,ID不存在: “ + id);
return null;
}
// 2. 过滤器说“可能存在”,走正常缓存流程
String cacheKey = CACHE_KEY_PREFIX + id;
Object value = redisTemplate.opsForValue().get(cacheKey);
if (value != null) {
System.out.println(“缓存命中,ID: “ + id);
return (Book) value;
}
// 3. 缓存未命中,查询数据库
System.out.println(“缓存未命中,查询数据库,ID: “ + id);
Book book = bookRepository.findById(id).orElse(null);
if (book != null) {
// 4. 数据库有数据,写入缓存
redisTemplate.opsForValue().set(cacheKey, book, 1, TimeUnit.HOURS);
System.out.println(“数据库有数据,写入缓存,ID: “ + id);
} else {
// 5. 注意:这里数据库查不到,但过滤器却说可能存在,这就是误判的情况。
// 这种情况很少,但发生了。我们选择不缓存空对象,因为可能是恶意试探的无效ID。
System.out.println(“[误判情况] 布隆过滤器判断存在,但数据库实际不存在,ID: “ + id);
}
return book;
}
/**
* 当新增一本书时,需要更新布隆过滤器
*/
public void addBook(Book book) {
// 1. 保存到数据库...
Book savedBook = bookRepository.save(book);
// 2. 加入到布隆过滤器
bloomFilter.put(savedBook.getId());
// 3. 清除或更新缓存...
String cacheKey = CACHE_KEY_PREFIX + savedBook.getId();
redisTemplate.delete(cacheKey);
}
}
关联技术详解:布隆过滤器
布隆过滤器的核心在于空间效率和查询效率的极致权衡。它用多个哈希函数将同一个元素映射到位数组的多个不同位置,并将这些位置置为1。判断元素是否存在时,只要看这些位置是否都是1。由于哈希冲突,不同元素的位置可能重叠,导致“可能存在”的误判。但它永不漏判(说没有的一定没有),且占用的空间远小于存储所有元素本身,非常适合这种“存在性校验”的场景。Redis 4.0后也提供了BF.ADD和BF.EXISTS命令,可以将其功能放在Redis中,实现分布式共享。
优缺点分析:
- 优点:内存占用极低,能从根本上拦截绝大部分无效请求,对数据库保护效果最好。
- 缺点:
- 存在误判率:需要根据业务容量和可接受程度,在初始化时权衡“预期元素数量”和“误判率”。
- 数据删除/更新困难:传统的布隆过滤器不支持删除(因为不知道某一位是哪几个元素共用的)。如果需要删除,可以考虑使用变种如“计数布隆过滤器”,或定期重建。
- 需要预热初始化:需要将历史数据提前加载到过滤器中,对于数据量极大或变化频繁的场景,初始化和更新策略需要精心设计。
四、方案三:请求入口校验——把问题挡在最外面
很多时候,无效的请求本身就带有明显的特征。比如,我们的书籍ID都是正数,而攻击者传入了-1、0或abc这样的参数。我们可以在请求刚到达业务逻辑时,就进行一层校验。
这通常结合在API的控制器层或参数解析层完成,是最轻量、成本最低的第一道防线。
// 技术栈:Java + Spring Boot
@RestController
@RequestMapping(“/api/books”)
public class BookController {
@Autowired
private BookService bookService;
/**
* 根据ID获取书籍
* @param id 路径变量,必须是正整数
*/
@GetMapping(“/{id}”)
public ResponseEntity<Book> getBook(@PathVariable(“id”) @Min(1) Long id) {
// Spring MVC的@Min注解会配合@Validated进行校验
// 如果id不合法,请求在进入Service层之前就会被拦截,返回400 Bad Request
Book book = bookService.getBookById(id);
return book != null ? ResponseEntity.ok(book) : ResponseEntity.notFound().build();
}
// 或者,更直接地在方法内校验
@GetMapping(“/simple/{id}”)
public ResponseEntity<Book> getBookSimple(@PathVariable String id) {
// 手动校验:ID必须是纯数字且大于0
if (!id.matches(“\\d+”) || Long.parseLong(id) <= 0) {
System.out.println(“请求参数非法,直接拦截: “ + id);
return ResponseEntity.badRequest().body(null); // 返回400错误
}
Long bookId = Long.parseLong(id);
Book book = bookService.getBookById(bookId);
return book != null ? ResponseEntity.ok(book) : ResponseEntity.notFound().build();
}
}
优缺点分析:
- 优点:实现简单,零额外开销,对明显无效的请求(如非法参数格式、越界参数)拦截效果立竿见影。
- 缺点:防御能力有限,只能拦截有明显规则的无效请求。对于符合格式规则(如都是正整数)但数据库里就是不存在的随机ID攻击,无能为力。
五、应用场景、技术选型与总结
应用场景分析:
- 缓存空对象:适用于数据变化不频繁,且空值结果相对有限的场景。例如,查询用户信息,不存在的用户名就那么几个(如系统保留名、已删除的测试账号),可以放心缓存。
- 布隆过滤器:适用于数据字典明确、总量大、且相对稳定的场景。例如,电商系统中所有有效的商品ID、短链系统中所有已生成的短码。在风控和反垃圾领域,用于判断一个IP或手机号是否在黑名单中,也非常高效。
- 请求入口校验:适用于所有场景,应作为必备的基础校验层。它成本最低,能过滤掉低级的恶意请求或客户端错误。
技术优缺点与注意事项:
- 组合使用:在实际项目中,通常不会只采用一种方案。最佳实践是组合拳:先做请求入口校验(如ID格式、范围),再通过布隆过滤器拦截大批量随机攻击,最后对于漏网之鱼或布隆过滤器误判的请求,用缓存空对象兜底。
- 空值TTL设置:缓存空对象的过期时间不宜过长,通常几分钟到几十分钟,既要起到保护作用,又要避免长期占用内存和脏数据问题。
- 布隆过滤器更新:对于数据频繁增删的业务,需要考虑布隆过滤器的更新策略。对于新增,可以实时
put;对于删除,可以标记删除并定期重建,或使用支持删除的变种。 - 监控与告警:无论采用哪种方案,都需要监控缓存未命中率(Cache Miss Rate)和数据库QPS。如果发现异常升高,可能是遇到了新的攻击模式或方案有漏洞。
文章总结: 缓存穿透是高性能缓存架构中一个典型且危险的问题。解决它的核心思路是:避免让大量无效查询直达数据库。 我们介绍了三种主要武器:
- 缓存空对象像是“事后补救”,简单有效但有内存和一致性的代价。
- 布隆过滤器像是“事前预防”,从源头拦截,效率极高,是防御大规模随机攻击的利器,但需要理解其“可能存在”的误判特性。
- 请求入口校验则是“守门员”,负责过滤掉明显不合格的请求,是必须有的第一道防线。
没有银弹,在实际架构中,你需要根据业务的数据特性、变化频率和可接受的成本,灵活选择和组合这些策略,构建起一道坚固的缓存防线,确保你的系统在高并发下依然稳健。记住,好的架构总是在空间、时间、复杂度之间做出最合适的权衡。
评论