上周线上冒出来一个特别邪门的报错:java.lang.ClassCastException: LinkedHashMap cannot be cast to OrderVO。
邪门在三件事上:只在缓存命中时报,缓存没命中时一切正常;本地怎么都复现不了;清空 Redis 就好了,但过两天又来。
我一开始怀疑是 Jackson 配置问题,查了半天没结果。最后把这个缓存模块 2445 行源码从头扒了一遍,才发现报错的那行代码其实写对了 ------真正的问题藏在另外五个地方,它们共同的特点是:不抛异常、不打日志、功能看起来一切正常,就是缓存没生效或者缓存了错的东西。
这篇是「框架源码拆解」系列第 10 篇,上一篇拆 config 动态配置时预告过要讲缓存和锁续期,今天还债。
TL;DR:六条结论先看
赶时间的话看这六条就够,后面是展开:
- 缓存值反序列化后泛型信息会丢 ------
List<OrderVO>取出来变成List<LinkedHashMap>,报错发生在调用点而不是反序列化那一刻,所以异常栈看着莫名其妙。项目用"按方法返回类型回炉转换"解决了,这是全文最值得抄的一段。 - 改一行缓存定义配置,可能让全站缓存静默失效 ------Redis 里存着一份权威定义,和代码里的定义做全字段全等比较 ,任何一处不同就抛异常,被 catch 后降级为"穿透"。而那份 Redis 定义没有 TTL,重启也不会消失。
- 作用域是 TENANT 的缓存,在没有登录态时 100% 不生效 ------定时任务、MQ 消费者、异步线程里调用,缓存完全跳过,且没有任何日志。
- 一条非法的策略快照会导致每 30 秒清空一次本地缓存------校验失败时只清了本地快照,Redis 里那份删不掉,30 秒后又被拉回来,循环往复。
clear()是唯一没有异常兜底的入口 ------get/put/evict都包了 try-catch,只有clear没有,一旦抛异常就是"数据库改了、缓存没清"的脏数据。- 全链路 fail-open 是可信的------任何一个环节挂了都降级成穿透,不阻断业务,而且每个入口有独立的 LongAdder 计数。这个设计意识比很多自研缓存强。
一、这个缓存模块长什么样
它不是简单的 @Cacheable 封装,是一套带控制面的受管缓存。分两层:
less
┌─────────────────────────────────────────────────────────────┐
│ 业务代码 │
│ @ForgeCacheable(cacheName = "order:detail", key = "#id") │
└──────────────────────────┬──────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ForgeCacheAspect(@Order = LOWEST_PRECEDENCE - 100) │
│ ① 解析 key(scope + SpEL → SHA-256 摘要) │
│ ② 查缓存 → 命中则按方法返回类型恢复泛型 → 返回 │
│ ③ 未命中 → 执行方法 → 事务提交后写缓存 │
└──────────────────────────┬──────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ForgeManagedCacheManager │
│ ┌──── 控制面(Redis RMap,持久化)────────────────┐ │
│ │ :control:definitions 代码定义的权威副本 │ │
│ │ :control:policies 管理端下发的策略覆盖 │ │
│ │ :control:events Pub/Sub 控制指令 │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌──── 数据面(三种模式)──────────────────────────┐ │
│ │ LOCAL → Caffeine │ │
│ │ REDIS → Redisson RMapCache │ │
│ │ MULTI → Caffeine(L1) + RMapCache(L2) + Pub/Sub │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
注解极其克制,只有两个属性:
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ForgeCacheable {
String cacheName();
String key() default "";
}
没有 ttl、没有 cacheManager、没有 condition。 因为设计意图是:TTL、模式、容量这些运维属性不应该写死在代码里,全部由控制面下发。缓存定义在代码里声明,运行时的策略可以被管理端覆盖:
java
public record EffectiveCachePolicy(
boolean enabled,
CacheMode cacheMode, // LOCAL / REDIS / MULTI
long localTtlSeconds,
long redisTtlSeconds,
int localMaxSize,
boolean cacheNull, // 是否缓存 null(防穿透)
long nullTtlSeconds,
long policyVersion
) implements Serializable {}
看到 cacheNull 和 nullTtlSeconds 我就放心了一半------缓存穿透是处理了的。很多自研缓存第一个坑就是这儿。
二、先说最值钱的一段:泛型擦除是怎么被救回来的
这是我扒这个模块最大的收获,先单独讲。
现象
缓存里存的是 List<OrderVO>,取出来遍历时报错:
java
List<OrderVO> orders = orderService.listByTenant(tenantId);
for (OrderVO order : orders) { // ← 这一行抛 ClassCastException
...
}
// java.lang.ClassCastException:
// class java.util.LinkedHashMap cannot be cast to class OrderVO
为什么
Redis 里存的是 JSON,JSON 数组反序列化时,Jackson 不知道元素原本是什么类型 ,只能给成 LinkedHashMap。这是 Java 类型擦除 + JSON 弱类型共同作用的结果,跟用不用 Redisson 没关系,RedisTemplate + Jackson2JsonRedisSerializer 一样会中招。
为什么报错在 for 循环那一行? 因为泛型擦除后 List<OrderVO> 在运行时就是 List,LinkedHashMap 放进去完全合法,编译器插入的 checkcast 直到你取元素时才执行。所以异常栈指向的是业务代码,跟缓存半点关系都看不出来------这就是它邪门的原因。
怎么解的
这个模块的做法是:拿到被缓存方法的声明返回类型,把缓存值"回炉"转换一次。
java
private Object restoreCachedValue(Method method, Object cachedValue) {
if (cachedValue == null) {
return null;
}
JavaType returnType = objectMapper.getTypeFactory()
.constructType(method.getGenericReturnType());
if (!requiresTypeConversion(cachedValue, returnType)) {
return cachedValue;
}
return objectMapper.convertValue(cachedValue, returnType);
}
关键在 requiresTypeConversion------它不是"是集合就转",而是递归下钻到容器元素逐个判断,避免无谓的转换开销:
java
private boolean requiresTypeConversion(Object value, JavaType targetType) {
if (value == null || targetType == null
|| targetType.getRawClass() == Object.class) {
return false;
}
if (!targetType.getRawClass().isInstance(value)) {
return true; // 连外层都不匹配,必转
}
if (!targetType.isContainerType()) {
return false; // 非容器,外层匹配就够了
}
if (targetType.isArrayType()) {
JavaType contentType = targetType.getContentType();
if (contentType == null || contentType.getRawClass() == Object.class) {
return false;
}
int length = Array.getLength(value);
for (int i = 0; i < length; i++) {
if (requiresTypeConversion(Array.get(value, i), contentType)) {
return true; // 递归:数组元素
}
}
return false;
}
if (value instanceof Map<?, ?> map) {
JavaType keyType = targetType.getKeyType();
JavaType contentType = targetType.getContentType();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (requiresTypeConversion(entry.getKey(), keyType)
|| requiresTypeConversion(entry.getValue(), contentType)) {
return true; // 递归:Map 的 key 和 value
}
}
return false;
}
JavaType contentType = targetType.getContentType();
if (contentType == null || contentType.getRawClass() == Object.class
|| !(value instanceof Iterable<?> iterable)) {
return false;
}
for (Object item : iterable) {
if (requiresTypeConversion(item, contentType)) {
return true; // 递归:集合元素
}
}
return false;
}
三个细节值得单独记:
targetType.getRawClass() == Object.class直接返回 false ------返回类型是Object时无从判断,不瞎转。- 数组 / Map / Iterable 三条分支分开处理 ------
Map要同时检查 key 和 value 的类型,很多实现只检查 value。 - 转换失败会主动清理缓存 :切面里捕获
IllegalArgumentException后调cacheManager.evict(...),把这条脏缓存删掉,避免它一直报错。
java
if (lookup.hit()) {
try {
return restoreCachedValue(invocation.method(), lookup.value());
} catch (IllegalArgumentException exception) {
log.warn("受管缓存值类型恢复失败,已清理并穿透: cache={}, returnType={}", ...);
cacheManager.evict(invocation.definition(), key.get());
}
}
这段代码可以直接抄到任何自研缓存里。 如果你项目里有"从 Redis 取出来要手动 convertValue 一遍"的祖传代码,多半就是没做这件事。
三、五个静默失效的坑
坑 1:改一行 TTL 配置,全站缓存静默失效
现象 :某次发布把 localTtlSeconds 从 300 改成 600,上线后发现命中率掉到 0,DB 压力陡增。日志里只有一条 warn。回滚配置也没用。
源码 :每次 get/put 都会先 register(definition):
java
public void register(CacheDefinition definition) {
validateDefinition(definition);
definitions.compute(definition.identity(), (identity, existing) -> {
if (existing != null) {
requireCompatibleDefinition(existing, definition);
return existing;
}
registerRemoteDefinition(definition); // ← 首次才写远端
return definition;
});
}
private void registerRemoteDefinition(CacheDefinition definition) {
CacheDefinition remote = definitionMap().putIfAbsent(definition.identity(), definition);
if (remote != null && !compatibleDefinition(remote, definition)) {
throw new IllegalStateException("远端缓存定义冲突: " + definition.identity());
}
}
compatibleDefinition 是10 个字段全等比较------TTL、容量、模式、作用域、cacheNull、nullTtl,任何一个不同都算冲突:
java
private boolean compatibleDefinition(CacheDefinition first, CacheDefinition second) {
return first.applicationCode().equals(second.applicationCode())
&& first.cacheName().equals(second.cacheName())
&& first.defaultMode() == second.defaultMode()
&& Set.copyOf(first.allowedModes()).equals(Set.copyOf(second.allowedModes()))
&& first.scope() == second.scope()
&& first.localTtlSeconds() == second.localTtlSeconds()
&& first.redisTtlSeconds() == second.redisTtlSeconds()
&& first.localMaxSize() == second.localMaxSize()
&& first.cacheNull() == second.cacheNull()
&& first.nullTtlSeconds() == second.nullTtlSeconds();
}
为什么坑 :这个异常被 get() 的 catch (RuntimeException) 吃掉,降级成"穿透"------业务完全正常,只是缓存不生效。而 definitionMap() 是普通 RMap,没有 TTL,重启也不会消失。所以你回滚配置也没用,Redis 里那份永远是旧的。
更麻烦的是触发时机:只有本地 definitions 为空时才会查远端 ,也就是新实例启动的第一次缓存调用。滚动发布时老实例一直正常,新实例全部静默穿透,监控上表现为"命中率缓慢下降"。
怎么改:两个方向------
java
// 方案 A:冲突时以代码为准覆盖远端(代码是唯一事实来源)
private void registerRemoteDefinition(CacheDefinition definition) {
CacheDefinition remote = definitionMap().putIfAbsent(definition.identity(), definition);
if (remote != null && !compatibleDefinition(remote, definition)) {
log.warn("缓存定义变更,以代码定义覆盖远端: {}", definition.identity());
definitionMap().fastPut(definition.identity(), definition); // 覆盖而非抛异常
}
}
// 方案 B:保留抛异常,但把 Redis 定义加上 TTL + 启动时校准
// 已有 removeOverridesNotIn() 的对称设计,definitions 却没有,属于遗漏
快速自查 :redis-cli --scan --pattern '*managed-cache:control:definitions*',看看里面有没有过期版本。
坑 2:TENANT 作用域的缓存,在没有登录态时完全不生效
现象 :给一个租户维度的字典查询加了 @ForgeCacheable(scope = TENANT),接口压测命中率 95%。但同一段逻辑放到定时任务里跑,命中率 0。
源码:key 解析的第一步是解析作用域:
java
private Optional<String> resolveScope(CacheScope scope) {
if (scope == CacheScope.GLOBAL) {
return Optional.of("global");
}
CacheIdentity identity = identityProvider.current();
if (identity == null || identity.tenantId() == null) {
return Optional.empty(); // ← 拿不到租户 → 直接放弃缓存
}
...
}
而默认实现是从登录态取:
java
public class SessionCacheIdentityProvider implements CacheIdentityProvider {
@Override
public CacheIdentity current() {
try {
return new CacheIdentity(
SessionHelper.getTenantId(),
SessionHelper.getUserId(),
SessionHelper.getActiveOrgId());
} catch (RuntimeException exception) {
return new CacheIdentity(null, null, null); // ← 吞异常,无日志
}
}
}
回到切面:
java
if (key.isEmpty()) {
return joinPoint.proceed(); // ← 没有任何日志
}
为什么坑 :定时任务、MQ 消费者、@Async 线程里没有登录上下文,SessionHelper.getTenantId() 要么返回 null 要么抛异常被吞掉 → 作用域解析为空 → 缓存被静默跳过。不报错、不告警,只是慢。
这个坑和本系列第 2 篇(多租户)讲的是同一个根因:上下文靠 ThreadLocal 传递,跨线程就断 。区别只是那次的后果是 SQL 不带 tenant_id,这次是缓存不生效。
怎么改:至少加一行日志,让"缓存为什么没生效"可观测:
java
if (key.isEmpty()) {
log.debug("受管缓存跳过:无法解析作用域,检查执行线程是否携带登录上下文: cache={}",
annotation.cacheName());
return joinPoint.proceed();
}
定时任务场景的正确做法是显式绑定租户上下文 再调用------TenantContextHolder 提供了带值的执行方法(executeIgnore 是绕开租户过滤的,两边正好相反,别用混了):
java
TenantContextHolder.executeWithTenant(tenantId, () -> {
dictService.listByType(type); // 这样缓存才会生效
});
坑 3:一条非法策略快照,导致每 30 秒清空一次本地缓存
现象:L1 命中率周期性掉到 0,每 30 秒一次,规律得像定时任务。
源码:策略校验失败时的降级路径:
java
private EffectiveCachePolicy effectivePolicy(CacheDefinition definition) {
refreshPolicySnapshotIfDue();
CachePolicyOverride override = overrides.get().get(definition.identity());
EffectiveCachePolicy policy = EffectiveCachePolicy.from(definition, override);
try {
validatePolicy(definition, policy);
return policy;
} catch (IllegalArgumentException exception) {
recordFailure(definition.identity(), "应用缓存策略", exception);
removeOverrideFromSnapshot(definition.identity(), override); // ← 只清本地!
closeHandle(definition.identity()); // ← 销毁 L1
return EffectiveCachePolicy.from(definition, null);
}
}
而 removeOverrideFromSnapshot 只动内存里的 AtomicReference:
java
private void removeOverrideFromSnapshot(String identity, CachePolicyOverride expected) {
overrides.updateAndGet(current -> { /* 只改本地 Map */ });
}
Redis 里那份 override 没被删除。 于是 30 秒后:
java
private void refreshPolicySnapshotIfDue() {
// policyRefreshSeconds 默认 30
Map<String, CachePolicyOverride> remote = policyMap().readAllMap();
replaceOverrides(remote); // ← 又把非法策略拉回来了
}
replaceOverrides 发现策略变了 → closeHandle → L1 被清空。循环开始。
触发条件:Redis 里的 override 和当前代码约束冲突。最容易撞上的是这条校验:
java
if (policy.cacheMode() == CacheMode.MULTI
&& policy.localTtlSeconds() > policy.redisTtlSeconds()) {
throw new IllegalArgumentException("多级缓存本地TTL不能大于Redis TTL");
}
以及这条------注意它无条件校验 nullTtlSeconds > 0,哪怕 cacheNull = false:
java
if (policy.localTtlSeconds() <= 0 || policy.redisTtlSeconds() <= 0
|| policy.localMaxSize() <= 0 || policy.nullTtlSeconds() <= 0) {
throw new IllegalArgumentException("缓存TTL和本地容量必须大于0");
}
也就是说:你设置"不缓存 null"顺手把 nullTtlSeconds 写成 0,整个缓存就废了,而且是每 30 秒清一次的废法。
怎么改:降级时同步清理远端,让失败收敛而不是循环:
java
} catch (IllegalArgumentException exception) {
recordFailure(definition.identity(), "应用缓存策略", exception);
removeOverrideFromSnapshot(definition.identity(), override);
if (redissonClient != null && override != null) {
try {
policyMap().fastRemove(override.identity()); // ← 补上这一句
} catch (RuntimeException ex) {
recordFailure(override.identity(), "清理非法策略快照", ex);
}
}
closeHandle(definition.identity());
return EffectiveCachePolicy.from(definition, null);
}
坑 4:clear() 是唯一没有异常兜底的入口
现象:一次数据修复后,部分用户看到的还是旧数据,等 TTL 过期才恢复。
源码:对比四个入口------
java
public CacheLookup get(...) { try { ... } catch (RuntimeException e) { ... } } // ✅
public void put(...) { try { ... } catch (RuntimeException e) { ... } } // ✅
public void evict(...) { try { ... } catch (RuntimeException e) { ... } } // ✅
public void clear(CacheDefinition definition) {
register(definition); // ← 可能抛 IllegalStateException,无兜底
clearWithoutPublish(...);
publish(...);
}
get/put/evict 都规规矩矩包了 try-catch,clear 漏了。而 register() 在坑 1 的场景下恰恰会抛异常 ------于是 clearWithoutPublish 和 publish 都不会执行,缓存没清,但调用方以为清了。
切面里那层 catch 会把异常吞成一条 warn:
java
afterCommit(annotation.cacheName(), "清空", () -> cacheManager.clear(invocation.definition()));
// afterCommit 内部:catch (RuntimeException e) → log.warn("受管缓存清空失败,业务结果保持不变")
为什么坑 :失效操作失败比读操作失败危险得多------读失败最多慢一点,清失败就是脏数据,而且没人知道。
怎么改 :照抄 evict 的写法补上:
java
public void clear(CacheDefinition definition) {
try {
register(definition);
clearWithoutPublish(definition.applicationCode(), definition.cacheName());
publish(new CacheControlMessage(CacheControlAction.CLEAR,
definition.applicationCode(), definition.cacheName(), null));
} catch (RuntimeException exception) {
recordFailure(definition.identity(), "清空受管缓存", exception);
throw exception; // 清空失败必须让调用方知道
}
}
注意最后那句 throw------失效操作不应该静默降级,这和读操作的 fail-open 策略正好相反。
坑 5:缓存值带 @class,改包名等于全量缓存报废
源码:
java
public record ManagedCacheValue(
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, property = "@class") Object value,
boolean nullValue,
long localTtlNanos
) implements Serializable {}
Redis 里存的是这样的 JSON:
json
{"@class":"com.mdframe.xxx.OrderVO","orderId":1001,"amount":9900}
这行注解是必要的------否则 Object value 反序列化不出具体类型。但它带来两个后果:
后果一:重构改包名 = 全量缓存反序列化失败。 老缓存的 @class 指向旧类名,新代码找不到这个类 → 反序列化异常 → 被 catch 降级为穿透。表现是"发版后命中率暴跌,一小时后才慢慢恢复"。
后果二:反序列化目标类不受约束。 用的 ObjectMapper 是 new ObjectMapper().findAndRegisterModules(),没有配置 BasicPolymorphicTypeValidator 。这意味着 @class 里写什么类就实例化什么类。正常业务下没问题,但如果 Redis 被未授权访问或数据被污染,这就是一条反序列化利用链。
怎么改:加白名单校验,并把类型标识与物理类名解耦:
java
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class)
.allowIfSubType("com.mdframe.") // 只允许自家包
.build();
mapper.activateDefaultTyping(validator,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
改包名的问题可以用 @JsonTypeName + 自定义 TypeIdResolver 做一层逻辑名映射,让缓存里存的是 orderVO 而不是全限定类名。
四、它做得对的地方(这些是值得抄的)
扒完坑也得说好话,而且这几处是真下了功夫的。
1. 多级缓存的失效广播,连"断线期间"都想到了
java
this.statusListenerId = invalidationTopic.addListener(new StatusListener() {
@Override
public void onSubscribe(String channel) {
localCache.invalidateAll(); // 重连即全清 L1
}
@Override
public void onUnsubscribe(String channel) {
// 重新订阅时统一清空,断开期间继续由当前 L1 TTL 约束陈旧窗口。
}
});
onUnsubscribe 方法体是空的,但那句注释就是设计文档------订阅断开期间收不到失效消息,这段窗口内的陈旧数据靠 L1 自身的 TTL 兜底,重连后一次性清空。这个边界情况九成自研实现会漏掉。
L1 的 TTL 也不是固定值,而是跟着缓存值走:
java
.expireAfter(new Expiry<String, ManagedCacheValue>() {
@Override
public long expireAfterCreate(String key, ManagedCacheValue value, long currentTime) {
return value.localTtlNanos(); // null 值用 nullTtlSeconds,正常值用 localTtlSeconds
}
...
})
缓存 null 的 TTL 和正常值分开------防穿透的 null 值 TTL 通常要设短一点,这里做了区分。
2. 缓存 key 是摘要,不长也不裸奔
java
String material = scopeMaterial.get() + "|" + toJson(keyValue);
return Optional.of(sha256(material));
scope 前缀 + 参数 JSON → SHA-256。好处有三个:key 长度恒定(不会被超长参数撑爆)、不含特殊字符(不用担心 Redis key 里的 : {} 冲突)、租户/用户维度天然隔离在 key 里。
3. 写缓存等事务提交
java
private void afterCommit(String cacheName, String operation, Runnable action) {
transactionExecutor.afterCommit(() -> { ... });
}
java
public void afterCommit(Runnable action) {
if (TransactionSynchronizationManager.isActualTransactionActive()
&& TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { action.run(); }
});
return;
}
action.run(); // 无事务则立即执行
}
避免"事务回滚了但缓存写进去了"这种经典脏数据。切面顺序 @Order(LOWEST_PRECEDENCE - 100) 也保证了它在事务切面外层,这一步才成立。
4. 每个入口独立计数
java
private static final class CacheCounters {
private final LongAdder hits = new LongAdder();
private final LongAdder misses = new LongAdder();
private final LongAdder puts = new LongAdder();
private final LongAdder evictions = new LongAdder();
private final LongAdder failures = new LongAdder(); // ← 失败也有数
}
用 LongAdder 而不是 AtomicLong,高并发下争用更小。而且 failures 单独计数 ------这条就是排查坑 1、坑 3 的关键线索:命中率掉的时候,如果 failures 在涨,那就是缓存本身出问题了,不是业务真的没命中。
五、接入实战:4 步
第 1 步:声明缓存定义(类级别,一次声明全类复用)
java
@ForgeCacheConfig(
name = "order:detail",
mode = CacheMode.MULTI, // LOCAL / REDIS / MULTI
scope = CacheScope.TENANT, // GLOBAL / TENANT / TENANT_USER / TENANT_USER_ORG
localTtlSeconds = 60,
redisTtlSeconds = 600,
localMaxSize = 1000,
cacheNull = true,
nullTtlSeconds = 30 // 注意:必须 > 0,否则校验不通过
)
@Service
public class OrderServiceImpl implements OrderService {
第 2 步:方法上加注解
java
@Override
@ForgeCacheable(cacheName = "order:detail", key = "#orderId")
public OrderVO detail(Long orderId) {
return orderMapper.selectVoById(orderId);
}
@Override
@ForgeCacheEvict(cacheName = "order:detail", key = "#orderId")
public void updateStatus(Long orderId, Integer status) {
orderMapper.updateStatus(orderId, status);
}
key 不写的话默认用全部参数序列化后的摘要,简单场景够用。
第 3 步:确认执行线程有身份上下文
scope != GLOBAL 时必须有登录态,否则走坑 2(注意 scope 默认值就是 TENANT,不显式写也会中招)。定时任务里要显式绑定:
java
TenantContextHolder.executeWithTenant(tenantId, () -> orderService.detail(orderId));
第 4 步:验证真的生效了
看计数:hits + misses > 0 才说明缓存跑起来了。如果 misses 在涨而 hits 是 0,去查 failures。
java
// ManagedCacheView 里有完整计数,可以直接暴露到监控端点
record ManagedCacheView(CacheDefinition definition, EffectiveCachePolicy policy,
boolean overridden, long hits, long misses, long puts, long evictions, long failures)
六、可带走的 6 条诀窍
- 反序列化后按方法返回类型回炉一次。 泛型信息在 JSON 里是丢的,
List<T>取出来是List<LinkedHashMap>,报错在调用点而不是缓存层。递归检查到容器元素级别再决定要不要转,别无脑convertValue。 - 缓存定义不要做"全字段全等"校验。 改 TTL 是正常运维操作,不该让全站缓存失效。要么以代码为准覆盖远端,要么给远端定义加 TTL 并做启动校准。
- 失效操作不能 fail-open。 读缓存失败可以穿透,清缓存失败必须抛------前者只是慢,后者是脏数据。
- 降级路径要收敛,不能循环。 校验失败时如果只清本地不请远端,下一次刷新又会把问题数据拉回来,变成周期性抖动。
- 作用域依赖上下文时,一定要在跳过时留日志。 "缓存为什么没生效"是最高频的疑问,一行
debug能省掉半天排查。 - 缓存值里存全限定类名,等于把重构风险写进了数据。 要么加类型白名单,要么用逻辑类型名做映射。
七、最后说两句
这个模块 2445 行,架构设计水平明显高于前面拆过的几个模块------控制面/数据面分离、策略可热更新、多级缓存带失效广播、泛型擦除有兜底方案,这些都不是顺手能写出来的。
但它有个很现实的问题:@ForgeCacheable 目前全项目只有测试在用,业务代码零接入。 我把仓库翻了一遍,除了 src/test 下的 7 个测试类,生产代码里一个注解都没找到。
所以坑 1、坑 3、坑 4 这些目前还没在真实流量里暴露过------它们是我读源码推演出来的,不是线上事故。这也是为什么我把它写成"坑"而不是"事故复盘"。
反过来讲,现在是接入的最好时机:趁还没有历史包袱,把坑 1 的定义冲突和坑 3 的策略循环先修掉,成本就是几行代码。
另外上一篇预告的分布式锁续期 这次没讲到------因为这个模块里 ICacheService 只提供了 setIfAbsent,真正的锁封装在 forge-starter-idempotent 里(上一篇拆幂等时提到过:STRICT 模式下锁租期固定 5 秒且 watchdog 没启动)。那部分我打算放到下一篇单独讲,把 Redisson 的 leaseTime 和 watchdog 机制一次说透。
如果这篇帮你少踩一个坑,点个赞吧。 说实话,泛型擦除那段我扒到的时候愣了一下------之前在别的项目里遇到同类问题,最后是靠"手动 convertValue"绕过去的,从没想过能做成通用机制。
评论区想问问:你们项目的缓存,遇到过"命中了但类型不对"这种玄学报错吗? 我赌一半人遇到过但没往缓存上想,因为异常栈压根不指向缓存。
本文拆解的是 Forge Admin 框架的
forge-starter-cache模块(2445 行 main + 7 个测试类)。 项目地址:Gitee | GitHub 系列前作:数据权限 SQL 改写 → 多租户隔离 → 拦截器注册顺序 → 幂等 5 坑 → 操作日志 5 个反直觉设计 → 认证链路账号锁定失效 → Excel 的@Async三重叠加 → WebSocket 内存 Broker → 动态配置@Scheduled被注释