缓存穿透难题:当 Value 为空字符串时,该如何优雅处理?
最近在优化缓存架构时,遇到一个很细节但很致命的问题:如果遇到 value 为 ""(空字符串),该怎么办?
我的直觉是,数据永远不要存 "",存一个默认值或者特殊标记即可。但深入思考后发现,这背后其实隐藏着架构设计上的一个经典陷阱。
问题现状:防穿透机制为何失效?
在常见的缓存穿透解决方案中,我们通常会采用"缓存空对象"的策略。即当数据库查询为空时,在 Redis 中存入一个临时值,防止恶意请求不断击穿缓存打到数据库。
但这里有一个大坑:如果你直接存 ""(空字符串),而代码中使用了 StrUtil.isNotBlank() 来判断缓存是否命中,问题就出现了。
深度剖析:isNotBlank 的判定逻辑
isNotBlank 是 Java 后端开发中处理字符串最常用的工具方法之一(通常来自 Hutool 的 StrUtil 或 Apache Commons 的 StringUtils)。
简单来说,它的含义是:"这个字符串既不是空的,也不全是空格。"
为了看清 isNotBlank 的威力,我们需要对比它和 isEmpty 的区别。下表展示了不同输入下的返回结果:
| 输入内容 | isEmpty (是否为空) |
isNotEmpty (不为空) |
isNotBlank (不为空白) |
|---|---|---|---|
null |
true | false | false |
"" (空串) |
true | false | false |
" " (多个空格) |
false | true | false |
"\t\n" (制表/换行) |
false | true | false |
" abc " (有内容) |
false | true | true |
核心区别在于对"空格"的态度 :isNotEmpty 只要长度大于 0 就认为"有东西";而 isNotBlank 会先执行 trim()(去掉两端空格),再判断长度。
失效的原因:
在代码中,我们通常这样写:
java
if(StrUtil.isNotBlank(shopJson)){
return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
}
如果 Redis 里存的是 "",isNotBlank 会返回 false。这导致程序认为缓存未命中,进而直接跳过缓存去查询数据库。结果就是,我们精心设计的防穿透机制直接失效了。
这在架构设计中被称为**"哨兵值"问题**。针对这个问题,目前业界主要有以下三种进阶解决方案。
方案一:使用特殊的"魔法哨兵值"(最推荐)
这是最简单且有效的方案。我们约定,不存 "",而是存一个业务上绝对不可能出现的特殊字符串,比如 @@NULL@@ 或者 NULL_PLACEHOLDER。
优势:
- 逻辑清晰:在 Redis 控制台一眼就能看出这是"防穿透"产生的占位数据,方便排查问题。
- 兼容性好 :完美兼容
isNotBlank等常规校验逻辑。
代码示例:
java
// 定义常量
private static final String CACHE_NULL_VALUE = "@@NULL@@";
// 1. 查询 Redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 逻辑分水岭
if (shopJson != null) {
// 命中缓存(可能是正常数据,也可能是魔法值)
if (CACHE_NULL_VALUE.equals(shopJson)) {
return Result.fail("店铺不存在!"); // 成功拦截,不查库
}
// 正常数据,反序列化
return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
}
// 3. shopJson == null,说明彻底没命中,去查库...
方案二:利用 null 与 "" 的区别(硬核逻辑版)
如果你坚持想利用空字符串,那么就必须改变判断逻辑,不能再用 isNotBlank,而是要精确判断 null。
核心逻辑如下:
null:Redis 里根本没这个 Key(缓存未命中,需要查库)。"":Redis 里有这个 Key,但内容是空的(缓存命中,内容是"空值",禁止查库)。
代码示例:
java
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 第一步:判断是否为正常数据
if(StrUtil.isNotBlank(shopJson)){
return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
}
// 第二步:判断是否为空值缓存(此时 shopJson 只能是 null 或 "")
if(shopJson != null){
// 走到这里说明 shopJson 是 "",即命中了空值缓存
return Result.fail("店铺不存在!");
}
// 第三步:shopJson 为 null,查库...
缺点:
逻辑相对绕,容易写出 Bug。而且 stringRedisTemplate 在某些配置或序列化场景下,可能会将丢失的 Key 也处理成空字符串,容易造成混淆。因此,这种方案并不推荐作为首选。
方案三:封装"结果包装类"(工业级方案)
如果你正在开发一个公共组件(例如 redis-starter),这是一种更加规范的做法。不要直接存业务对象,而是存一个包装类:
java
public class CacheData {
private T data;
private boolean isNull; // 标记是否为空值
}
存入 Redis 时,即使数据库查询结果为 null,我们也存入一个 isNull = true 的 CacheData 对象。
优势 :
彻底解决了 "" 的类型转换问题,反序列化永远不会报错,语义非常明确。
代价:
Redis 存储空间会稍微变大,因为每个 Key 都多了一个包装外壳。
架构师建议
结合实际开发经验,如果你的项目是中小型规模,方案一(魔法哨兵值) 是性价比最高的选择。
如果你正在维护像基础组件 redis-starter 这样的项目,建议在 Starter 里做一个自动化拦截器 。当注解发现数据库返回 null 时:
- 自动往 Redis 存入预设的魔法值(如
@@NULL@@)。 - 设置一个较短的 TTL(例如 2 分钟),防止数据真的上线了却因为空值缓存未过期而查不到。
更进一步,你可以在 Starter 中增加一个NullValueStrategy配置项。这样使用者可以在application.yml里自己决定:是存@@NULL@@,还是存一个自定义的默认对象。这会让你的 Starter 更有"产品感",也能优雅地解决空字符串带来的穿透隐患。