前言
在高并发的互联网应用场景中,缓存(如 Redis)已经成为保护数据库、提升系统响应速度的标准配置。然而,很多初中级开发者在使用缓存时,往往只关注缓存命中(Cache Hit)的情况,而忽略了查询结果为空(Null)的场景。
当大量请求查询一个不存在的数据时,如果每次请求都直接穿透缓存到达数据库,不仅会造成数据库压力激增,还可能导致系统响应变慢。这种现象被称为“缓存穿透”。
本文将通过通俗易懂的语言和完整的代码示例,带你掌握“空值缓存”技术,学会如何优雅地解决缓存穿透问题。
核心概念:什么是空值缓存?
1. 缓存穿透问题
想象一下,你是一家图书馆的管理员。读者经常来查询某本书是否存在。
- 正常情况:书存在,你告诉读者书架号,读者直接去拿。
- 异常情况:书不存在。如果你每次都说“我去库房查一下”,然后回来告诉读者“没有”,那么如果有 1 万个读者同时查询这本不存在的书,你就需要往返库房 1 万次。库房(数据库)会被累垮。
在系统中,这就是缓存穿透:查询一个根本不存在的数据,缓存层不存储,请求直接落到数据库层。如果恶意攻击者利用这一点,频繁查询不存在的 ID,可能导致数据库宕机。
2. 空值缓存解决方案
空值缓存的核心思想是:即使数据库查询结果为空,也将这个“空结果”存入缓存中,并设置一个较短的过期时间。
回到图书馆的例子:
- 当发现书不存在时,你在查询台上贴一个纸条:“这本书没有,有效期 5 分钟”。
- 接下来的 5 分钟内,再有读者来问,你直接看纸条告诉他“没有”,不需要再去库房。
- 5 分钟后,纸条失效。如果书真的上架了,下次查询就能正常获取。
3. 方案对比
| 方案 | 数据库压力 | 缓存内存占用 | 数据一致性 | 实现复杂度 |
|---|---|---|---|---|
| 不使用空值缓存 | 高(每次查询都访问 DB) | 低 | 高 | 低 |
| 使用空值缓存 | 低(仅第一次访问 DB) | 中(存储空键值) | 中(依赖过期时间) | 中 |
代码实战:从入门到优化
为了让大家能够直接运行理解,我们将使用本地 Map 模拟 Redis 缓存,使用随机逻辑模拟数据库查询。
1. 场景设定
- 业务:根据用户 ID 查询用户信息。
- 问题:某些 ID 在数据库中不存在。
- 目标:防止不存在的 ID 频繁查询数据库。
2. 完整代码示例
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 用户实体类
*/
class User {
private Long id;
private String name;
public User(Long id, String name) {
this.id = id;
this.name = name;
}
@Override
public String toString() {
return "User{id=" + id + ", name='" + name + "'}";
}
}
/**
* 模拟缓存条目,包含值和过期时间
*/
class CacheEntry {
private Object value;
private long expireTime; // 过期时间戳
public CacheEntry(Object value, long ttlMillis) {
this.value = value;
// 如果 ttl 为 0,表示不过期(实际生产中不建议空值不过期)
if (ttlMillis > 0) {
this.expireTime = System.currentTimeMillis() + ttlMillis;
} else {
this.expireTime = Long.MAX_VALUE;
}
}
public boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
public Object getValue() {
return value;
}
}
/**
* 模拟缓存服务(实际项目中通常使用 Redis)
*/
class CacheService {
private final Map<String, CacheEntry> cache = new HashMap<>();
// 存入缓存
public void put(String key, Object value, long ttlMillis) {
cache.put(key, new CacheEntry(value, ttlMillis));
System.out.println("[缓存] 写入 key: " + key + ", 值: " + (value == null ? "NULL" : value));
}
// 获取缓存
public Object get(String key) {
CacheEntry entry = cache.get(key);
if (entry == null) {
return null;
}
// 检查是否过期
if (entry.isExpired()) {
cache.remove(key);
System.out.println("[缓存] key: " + key + " 已过期,移除");
return null;
}
return entry.getValue();
}
}
/**
* 模拟数据库服务
*/
class DatabaseService {
// 模拟数据库中存在的数据 ID 为 1, 2, 3
public User queryById(Long id) {
System.out.println("[数据库] 查询 ID: " + id);
// 模拟数据库查询耗时
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (id >= 1 && id <= 3) {
return new User(id, "User" + id);
}
// 返回 null 表示数据不存在
return null;
}
}
/**
* 业务服务层:包含空值缓存逻辑
*/
class UserService {
private final CacheService cacheService = new CacheService();
private final DatabaseService databaseService = new DatabaseService();
// 缓存 key 前缀
private static final String CACHE_KEY_PREFIX = "user:";
// 正常数据缓存时间:5 分钟
private static final long NORMAL_CACHE_TTL = 5 * 60 * 1000;
// 空值缓存时间:30 秒(防止数据不一致时间过长)
private static final long NULL_CACHE_TTL = 30 * 1000;
public User getUserById(Long id) {
String key = CACHE_KEY_PREFIX + id;
// 1. 先查缓存
Object cachedValue = cacheService.get(key);
// 2. 缓存命中
if (cachedValue != null) {
// 特殊标记:如果缓存中存的是特定对象表示空值,这里简化为直接判断 null
// 在实际 Redis 中,通常存储一个特殊字符串如 "NULL_VALUE"
if (cachedValue instanceof NullObject) {
System.out.println("[缓存] 命中空值标记,直接返回 null");
return null;
}
System.out.println("[缓存] 命中正常数据");
return (User) cachedValue;
}
// 3. 缓存未命中,查询数据库
User user = databaseService.queryById(id);
// 4. 写入缓存
if (user == null) {
// 关键点:数据库也为空,存入空值缓存,防止穿透
// 使用一个特殊对象标记空值,因为 Map 的 null 可能代表缓存未命中
cacheService.put(key, new NullObject(), NULL_CACHE_TTL);
} else {
// 数据库有数据,存入正常缓存
cacheService.put(key, user, NORMAL_CACHE_TTL);
}
return user;
}
}
/**
* 空值标记类,用于区分“缓存没数据”和“数据库查出来是空”
*/
class NullObject {
@Override
public String toString() {
return "NULL_OBJECT_MARKER";
}
}
/**
* 测试主程序
*/
public class NullCacheDemo {
public static void main(String[] args) {
UserService userService = new UserService();
System.out.println("=== 第一次查询 ID: 100 (数据不存在) ===");
User user1 = userService.getUserById(100L);
System.out.println("结果:" + user1);
System.out.println("\n=== 第二次查询 ID: 100 (命中空值缓存) ===");
User user2 = userService.getUserById(100L);
System.out.println("结果:" + user2);
System.out.println("\n=== 第三次查询 ID: 1 (数据存在) ===");
User user3 = userService.getUserById(1L);
System.out.println("结果:" + user3);
System.out.println("\n=== 第四次查询 ID: 1 (命中正常缓存) ===");
User user4 = userService.getUserById(1L);
System.out.println("结果:" + user4);
}
}
3. 代码逻辑解析
- NullObject 标记类:在 Java Map 中,null 值通常表示键不存在。为了区分“缓存里没有这个键”和“数据库查出来是空”,我们定义了一个
NullObject类。在实际 Redis 使用中,通常存储一个特殊字符串(如 "NULL")。 - 差异化过期时间:
- 正常数据:缓存时间较长(如 5 分钟),减少数据库压力。
- 空值数据:缓存时间较短(如 30 秒),兼顾性能与数据一致性。如果数据库中稍后插入了该数据,缓存过期后能很快同步。
- 流程闭环:查询缓存 -> 命中返回 -> 未命中查库 -> 回写缓存(无论是否为空)。
常见问题与踩坑点
在实际生产环境中实施空值缓存,需要注意以下几个关键问题:
| 问题点 | 风险描述 | 建议解决方案 |
|---|---|---|
| 空值过期时间设置过长 | 如果数据库中新插入了该数据,但缓存中的空值未过期,用户将无法查到新数据。 | 空值缓存 TTL 宜短不宜长,建议设置为秒级(如 30s - 5min)。 |
| 空值过期时间设置过短 | 起不到保护数据库的作用,恶意请求仍可频繁穿透。 | 根据业务容忍度权衡,通常不低于 10 秒。 |
| 内存浪费 | 如果有大量随机 ID 攻击,缓存中会存储大量空值键,占用内存。 | 配合布隆过滤器(Bloom Filter)使用,先在拦截层过滤不存在的 ID。 |
| 数据一致性 | 缓存中的空值可能导致短暂的数据不可见。 | 接受最终一致性,或在数据写入时主动删除对应的空值缓存。 |
| 缓存雪崩风险 | 如果大量空值缓存同一时间过期,压力会瞬间回到数据库。 | 空值缓存的过期时间应加入随机值(如 30 秒 + 随机 1-5 秒)。 |
总结
空值缓存是解决缓存穿透问题最简单、最有效的方案之一。作为 Java 开发者,在设计缓存策略时,不应只关注正常流程,更要考虑异常和边界情况。
核心要点回顾:
- 识别场景:当存在大量查询不存在数据的请求时,必须考虑空值缓存。
- 标记空值:使用特殊对象或字符串区分“缓存miss"和“数据 null"。
- 控制 TTL:空值缓存的过期时间要短,并增加随机性,防止一致性问题和雪崩。
- 组合方案:对于高安全要求的场景,建议结合布隆过滤器进行多重防御。