缓存明明命中了却报 ClassCastException:拆完多级缓存控制面,我挖出 5 个静默失效的坑

上周线上冒出来一个特别邪门的报错:java.lang.ClassCastException: LinkedHashMap cannot be cast to OrderVO

邪门在三件事上:只在缓存命中时报,缓存没命中时一切正常;本地怎么都复现不了;清空 Redis 就好了,但过两天又来。

我一开始怀疑是 Jackson 配置问题,查了半天没结果。最后把这个缓存模块 2445 行源码从头扒了一遍,才发现报错的那行代码其实写对了 ------真正的问题藏在另外五个地方,它们共同的特点是:不抛异常、不打日志、功能看起来一切正常,就是缓存没生效或者缓存了错的东西。

这篇是「框架源码拆解」系列第 10 篇,上一篇拆 config 动态配置时预告过要讲缓存和锁续期,今天还债。


TL;DR:六条结论先看

赶时间的话看这六条就够,后面是展开:

  1. 缓存值反序列化后泛型信息会丢 ------List<OrderVO> 取出来变成 List<LinkedHashMap>,报错发生在调用点而不是反序列化那一刻,所以异常栈看着莫名其妙。项目用"按方法返回类型回炉转换"解决了,这是全文最值得抄的一段。
  2. 改一行缓存定义配置,可能让全站缓存静默失效 ------Redis 里存着一份权威定义,和代码里的定义做全字段全等比较 ,任何一处不同就抛异常,被 catch 后降级为"穿透"。而那份 Redis 定义没有 TTL,重启也不会消失
  3. 作用域是 TENANT 的缓存,在没有登录态时 100% 不生效 ------定时任务、MQ 消费者、异步线程里调用,缓存完全跳过,且没有任何日志
  4. 一条非法的策略快照会导致每 30 秒清空一次本地缓存------校验失败时只清了本地快照,Redis 里那份删不掉,30 秒后又被拉回来,循环往复。
  5. clear() 是唯一没有异常兜底的入口 ------get/put/evict 都包了 try-catch,只有 clear 没有,一旦抛异常就是"数据库改了、缓存没清"的脏数据。
  6. 全链路 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 {}

看到 cacheNullnullTtlSeconds 我就放心了一半------缓存穿透是处理了的。很多自研缓存第一个坑就是这儿。


二、先说最值钱的一段:泛型擦除是怎么被救回来的

这是我扒这个模块最大的收获,先单独讲。

现象

缓存里存的是 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> 在运行时就是 ListLinkedHashMap 放进去完全合法,编译器插入的 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;
}

三个细节值得单独记:

  1. targetType.getRawClass() == Object.class 直接返回 false ------返回类型是 Object 时无从判断,不瞎转。
  2. 数组 / Map / Iterable 三条分支分开处理 ------Map 要同时检查 key 和 value 的类型,很多实现只检查 value。
  3. 转换失败会主动清理缓存 :切面里捕获 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());
    }
}

compatibleDefinition10 个字段全等比较------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 发现策略变了 → closeHandleL1 被清空。循环开始。

触发条件: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 的场景下恰恰会抛异常 ------于是 clearWithoutPublishpublish 都不会执行,缓存没清,但调用方以为清了

切面里那层 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 条诀窍

  1. 反序列化后按方法返回类型回炉一次。 泛型信息在 JSON 里是丢的,List<T> 取出来是 List<LinkedHashMap>,报错在调用点而不是缓存层。递归检查到容器元素级别再决定要不要转,别无脑 convertValue
  2. 缓存定义不要做"全字段全等"校验。 改 TTL 是正常运维操作,不该让全站缓存失效。要么以代码为准覆盖远端,要么给远端定义加 TTL 并做启动校准。
  3. 失效操作不能 fail-open。 读缓存失败可以穿透,清缓存失败必须抛------前者只是慢,后者是脏数据。
  4. 降级路径要收敛,不能循环。 校验失败时如果只清本地不请远端,下一次刷新又会把问题数据拉回来,变成周期性抖动。
  5. 作用域依赖上下文时,一定要在跳过时留日志。 "缓存为什么没生效"是最高频的疑问,一行 debug 能省掉半天排查。
  6. 缓存值里存全限定类名,等于把重构风险写进了数据。 要么加类型白名单,要么用逻辑类型名做映射。

七、最后说两句

这个模块 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 个测试类)。 项目地址:GiteeGitHub 系列前作:数据权限 SQL 改写 → 多租户隔离 → 拦截器注册顺序 → 幂等 5 坑 → 操作日志 5 个反直觉设计 → 认证链路账号锁定失效 → Excel 的 @Async 三重叠加 → WebSocket 内存 Broker → 动态配置 @Scheduled 被注释

相关推荐
T_Apollo1 小时前
2.5 Java 8.0 版本新增特性和类
java
孙启超1 小时前
【AI开发之Rust】第 13 课:async/await 与 tokio 异步运行时
开发语言·后端·rust
SimonKing1 小时前
SpringBoot 集成 SSE 实现服务端推送或可代替Websocket
java·后端·程序员
字节探索1 小时前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
程序员清风1 小时前
系统架构设计:模型服务、业务服务与知识库如何拆分
人工智能·ai·架构·aigc
不正经的码狗1 小时前
JAVA 、Eclipse 安装及配置 JAVA 项目(无需配置环境)
java·eclipse
xiaoqiMikko1 小时前
你升的是 jackson-databind,中招的是 jackson-core:一个超长 JSON key 就能吃掉 200MB 堆
java·安全
不正经的码狗1 小时前
Eclipse汉化(快速,推荐)
java·eclipse
极客互动API2 小时前
极客互动-企业微信基于外部API接口实现AI客服自动接管外部联系人消息收发
java·微信·企业微信·ai编程·rpa