跳到主要内容

JetCache @CacheInvalidate 清理缓存没生效?key 生成规则不一致的静默失败

TL;DR​

  • @Cached 和 @CacheInvalidate 里的 key 是两个互相独立的 SpEL 表达式,JetCache 不会帮你校验它们是否等价。
  • 两边求值结果经过 keyConvertor 序列化后的物理 key不一致时,cache.remove(key) 删不落任何东西:不抛异常、不打日志,本地缓存下连返回值都是"成功"。
  • 我们踩的坑:写缓存时 key 是 {#userId}(SpEL 内联集合,从 Spring Cache 的写法带过来的习惯),失效时 key 是 #userId。一对方括号之差,物理 key 就是 [1] 和 1 的区别。
  • 一句话结论:同名缓存的读、写、删,key 生成规则必须完全一致,否则清理失败,而且不会有任何告警。

现象:改了权限,前端永远不变​

权限中心的经典场景:用户角色列表走缓存。

@Cached(name = "getUserPermissions", key = "{#userId}", expire = 3600, cacheType = CacheType.LOCAL)
List<SysRole> getUserPermissions(Long userId) { ... }

管理端给用户调整角色后,更新接口上挂着失效注解:

@CacheInvalidate(name = "getUserPermissions", key = "#userId")

然后诡异的事情发生了:角色改完,用户重新登录、刷新,拉到的还是旧权限。概率 100%。

更诡异的是日志:整个调用链干干净净,没有异常、没有 WARN,@CacheInvalidate 仿佛只是"执行过了"。condition 没写、name 是同一个字符串常量——注解逻辑这么简单,还能怎么坏?

于是直接在注解 AOP 真正干活的地方打断点:CacheHandler#doInvalidate。

断点一:删除动作确实执行了​

断点命中在 cache.remove(key) 这一行:

断点停在 cache.remove(key),key 是 Integer 1

注意 IDEA 的 inline 提示:cache: CacheHandler$CacheHandlerRefreshCache@15761, key: 1。

  • key 是 {Integer@15540} 1,即入参 userId = 1,SpEL 求值正常;
  • cache 的实际类型是 CacheHandler$CacheHandlerRefreshCache——JetCache 为支持 @CacheRefresh 给真实缓存包的一层代理,LinkedHashMapCache 本体裹在更深处。

所以第一轮怀疑被排除:注解确实生效了,缓存实例也拿到了,key 也求值出来了。那问题只可能在"删"这个动作上——删了,但没删中。

断点二:缓存里那条还在,但 key 是"另一个世界"的​

在断点处对 cache 变量做 Evaluate,把代理层层展开,最后落到真实的 LinkedHashMapCache:

Evaluate cache:innerMap 里存的 key 是字符串 [1]

这张图信息量很大,一个一个说:

  1. cache 字段里是真正的 LinkedHashMapCache,它的 innerMap(LinkedHashMapCache$LRUMap)只有一条记录;
  2. 这条记录的 key 是字符串 "[1]"(IDEA 会给字符串加引号渲染,内容就是 [1]);
  3. 对应的 value 是 CacheValueHolder,里面装着 ArrayList<SysRole>:SysRole(id=1, roleCode=DEVELOPER...)、SysRole(id=2, roleCode=PRODUCT...)——正是我们以为删掉的旧权限。

把两边放在一起对比,现场就清楚了:

  • 断点处要删的 key:Integer 1
  • 缓存里实际存的 key:字符串 "[1]"

1 是"逻辑 key","[1]" 是"物理 key",这中间少了一层认知:KeyConvertor 会先对 SpEL 的求值结果做一次序列化,真正进入缓存的是序列化之后的产物。 序列化策略相同、输入不同,物理 key 就对不上。

源码背景:key 的两次"变身"​

先把"逻辑 key → 物理 key"这条链看清楚。以下源码摘自 JetCache 主分支(2.x 各版本在这一段逻辑上没有实质差异)。

无论是写还是删,最终都会经过 buildKey(AbstractEmbeddedCache):

public Object buildKey(K key) {
Object newKey = key;
Function<K, Object> keyConvertor = config.getKeyConvertor();
if (keyConvertor != null) {
newKey = keyConvertor.apply(key);
}
return newKey;
}

写入路径:@Cached 方法被调用 → cache.computeIfAbsent(key, loader) → 内部 GET(key) → do_GET:

protected CacheGetResult<V> do_GET(K key) {
Object newKey = buildKey(key); // 写入用 buildKey 后的 key
CacheValueHolder<V> holder = (CacheValueHolder<V>) innerMap.getValue(newKey);
...
}

删除路径:doInvalidate → cache.remove(key) → do_REMOVE:

protected CacheResult do_REMOVE(K key) {
innerMap.removeValue(buildKey(key)); // 删除同样会 buildKey
return CacheResult.SUCCESS_WITHOUT_MSG;
}

注意一个重要结论:读写删走的是同一个 Cache 实例上的同一个 keyConvertor。所以"key 不一致"已经排除了序列化器差异的可能;差异只能来自 @Cached 和 @CacheInvalidate 两处 SpEL 求值结果本身的类型和形状。

我们的 starter 默认 convertor 是 Fastjson2KeyConvertor:

public Object apply(Object originalKey) {
if (originalKey == null) {
return null;
}
if (originalKey instanceof String) {
return originalKey;
}
return JSON.toJSONString(originalKey);
}

也就是说,非字符串的 key 一律做 JSON 序列化:

JSON.toJSONString(1)                          // "1"
JSON.toJSONString(Collections.singletonList(1)) // "[1]"

现场还原​

现在把两边的 key 生成规则摆出来对照:

// 写入侧(getUserPermissions 上)
@Cached(name = "getUserPermissions", key = "{#userId}", ...)
// SpEL 内联集合字面量 ↑

// 失效侧(更新角色接口上)
@CacheInvalidate(name = "getUserPermissions", key = "#userId")

{#userId} 是 SpEL 的 Inline List 语法。这个写法基本是从老 Spring Cache 项目里 @Cacheable(key = "{#userId}") 平移过来的习惯——在 Spring Cache 里这是"组合 key"的标准姿势。但 JetCache 默认也是 SpEL,{...} 照样会被解析成合法的内联集合,不报任何错,只是悄悄把 key 从 Integer 1 变成了 List[1]。

于是:

key 表达式求值结果fastjson2 序列化物理 key
写入{#userId}List[1][1]字符串 "[1]"
删除#userIdInteger 11字符串 "1"

LRUMap.remove("1") 在一个只存了 "[1]" 的 map 里自然什么都找不到。两边都"正确地"执行了,写的 key 和删的 key 却从头到尾没有相碰过。

为什么不会有任何告警​

这是本篇真正想强调的部分。回头看 CacheHandler#doInvalidate 全文:

private static void doInvalidate(CacheInvokeContext context, CacheInvalidateAnnoConfig annoConfig) {
Cache cache = context.getCacheFunction().apply(context, annoConfig);
if (cache == null) {
return; // 静默点 1:解析不到缓存实例
}
boolean condition = ExpressionUtil.evalCondition(context, annoConfig);
if (!condition) {
return; // 静默点 2:condition 为 false
}
Object key = ExpressionUtil.evalKey(context, annoConfig);
if (key == null) {
return; // 静默点 3:key 求值为 null
}
if (annoConfig.isMulti()) {
...
Set keys = new HashSet();
it.forEach(k -> keys.add(k));
cache.removeAll(keys);
} else {
cache.remove(key); // 静默点 4:删没删掉,没人知道
}
}

四个静默点,对应四种"看起来执行了、实际什么都没发生"的情形:

静默点 1:name/area 拼写不一致时,JetCache 会按注解现查(查不到还会创建)一个缓存实例——你删的是一个新的空缓存,同样无声无息。

静默点 2:condition 结果为 false,直接返回。

静默点 3:key 求值结果为 null 时静默返回。只有当 SpEL 本身抛异常时,ExpressionUtil.evalKey 才会打一条 error 日志再返回 null;表达式"能求值、但求出来的跟写缓存那套对不上"时,连这条日志都没有——本案例正是这种。

静默点 4:cache.remove(key) 的返回值被直接丢弃。而对于本地缓存,返回值也帮不了你:

// AbstractEmbeddedCache.do_REMOVE
protected CacheResult do_REMOVE(K key) {
innerMap.removeValue(buildKey(key));
return CacheResult.SUCCESS_WITHOUT_MSG; // 不管删没删到,永远是 SUCCESS
}

// Cache 接口的默认方法
default boolean remove(K key) {
return REMOVE(key).isSuccess(); // 于是永远是 true
}

LinkedHashMap.remove() 找不到 key 就返回 null,上层包装直接宣布"成功"。也就是说,哪怕你在业务代码里写 boolean ok = cache.remove(key) 想做兜底校验,对本地缓存来说这个 ok 也永远是 true——框架在 API 层就把"key 不存在"和"删除成功"合并且吞掉了。

这是 JetCache 的一种设计取向:缓存框架倾向于吞掉一切异常、把所有操作都降级成"结果对象",失效一个不存在的 key 被视作"nothing to do"的正常情形。代价就是:key 一致性这个唯一容易出错、又只有业务开发者能保证的东西,框架完全无法校验。

修复​

把两侧的 key 表达式逐字符对齐:

@Cached(name = "getUserPermissions", key = "#userId", expire = 3600, cacheType = CacheType.LOCAL)
List<SysRole> getUserPermissions(Long userId) { ... }

@CacheInvalidate(name = "getUserPermissions", key = "#userId")
void assignRoles(Long userId, List<Long> roleIds) { ... }

验证方式也很直接:断点再进一次 doInvalidate,执行完 cache.remove(key) 后 Evaluate cache,确认 innerMap 里那条 "[1]" 真的消失了(size 变成 0 或该 key 不再出现)。

如何预防​

1. 把 key 当接口契约管理。 同一个 name 的 @Cached / @CacheUpdate / @CacheInvalidate,key 表达式建议抽成公共常量;做不到的话,至少在 code review 时把"同一缓存的读写删 key 表达式逐字符一致"列为必查项。特别警惕从 Spring Cache 迁移过来的 {#userId} 写法——它合法、不报错,却悄悄改变了 key 的类型。

2. 给缓存行为写一个往返(round-trip)测试。 这是最有效的保险:

@Test
void invalidate_should_really_remove_cached_entry() {
// 1. 第一次调用:建立缓存(回源计数 +1)
permissionService.getUserPermissions(1L);

// 2. 触发失效注解所在的业务方法
permissionService.assignRoles(1L, List.of(2L));

// 3. 第二次调用:回源计数必须再次 +1
// 如果没 +1,说明读到了旧缓存 => 失效没生效 => key 不一致
permissionService.getUserPermissions(1L);
}

在 loader(或业务方法入口)打一个回源计数即可。失效"假成功"的 bug 在这个测试里会 100% 复现,而线上它选择沉默。

3. 排查同类问题的三板斧:

  • 断点打在 CacheHandler#doInvalidate,别打在业务方法上——注解逻辑都在代理层里;
  • Evaluate cache 变量时注意代理分层:先展开 RefreshCache/ProxyCache 到 cache 字段,再展开 innerMap,看物理 key 的字符串形态(是 "1" 还是 "[1]",一眼定案);
  • 外部缓存(Redis)直接 redis-cli --scan --pattern '*<name>*'(JetCache 的 key 带 area/name 前缀),看真实 key 的序列化形状。

4. 排查清单。 @CacheInvalidate 所有的静默分支:

情形是否有日志备注
name/area 不一致,解析到另一个(新创建的空)缓存实例无删了个寂寞
condition 为 false无预期行为,但容易被遗忘
key 表达式求值为 null / SpEL 报错SpEL 报错时有 error 日志
物理 key 不一致(本文 case)完全没有只有业务侧能发现
multi = true 但 key 不是 Iterable有 error 日志

另外,@CacheUpdate 有完全相同的问题:key 对不上时,cache.put(key, value) 会写入一个永远读不到的幽灵条目,同样无告警,还会顶掉 LRU 里的真实条目。