You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Cache @Cacheable缓存对象被修改导致缓存污染解决方案

问题根因

你遇到的缓存污染本质是Spring默认ConcurrentMapCache的存储/读取逻辑没有做对象隔离:缓存中存储的是堆内对象引用,所有调用方拿到的都是同一个对象实例,任意调用方修改对象(比如裁剪节点、修改授权属性)都会直接改动缓存里的原始值。
你之前在@Cacheable标注的方法内部做克隆无效的原因非常直接:@Cacheable的执行逻辑是方法首次运行完成后,把返回值直接存入缓存,你在方法里返回克隆对象,存进缓存的就是这个克隆对象,后续调用拿的还是同一个克隆实例,修改后照样污染缓存。

可行解决方案

方案1:自定义支持读时拷贝的Cache实现(最推荐,无业务侵入)

不用修改原有缓存注解和业务逻辑,只需要替换默认的ConcurrentMapCache,重写缓存值的读取逻辑,每次从缓存拿到原始值后先做深拷贝再返回给调用方,保证缓存里始终存的是未修改的原始全量数据。
代码示例:

  1. 实现支持读时拷贝的自定义Cache类:
public class CopyOnReadConcurrentMapCache extends ConcurrentMapCache {
    private final HierarchyCloner cloner;

    public CopyOnReadConcurrentMapCache(String name, HierarchyCloner cloner) {
        super(name);
        this.cloner = cloner;
    }

    @Override
    protected Object lookup(Object key) {
        Object cachedValue = super.lookup(key);
        if (cachedValue == null) {
            return null;
        }
        // 每次读取缓存时返回独立深拷贝副本,调用方任意修改都不会影响缓存内的原始值
        return cloner.deepCopy((List<ProductHierarchyBean>) cachedValue);
    }
}

注意必须做全量深拷贝,不要做列表浅拷贝——如果只复制List容器不复制内部的ProductHierarchyBean节点,修改节点属性、增删子节点时依然会污染缓存原始值。深拷贝实现可以按需选择:如果ProductHierarchyBean实现了序列化接口,直接用序列化/反序列化做通用深拷贝最省事;如果追求性能,可以手动实现树结构的专属拷贝逻辑,比通用序列化方案快很多。

  1. 替换缓存配置里的默认Cache实现:
@Configuration
@EnableCaching
@EnableScheduling
@Log4j2
public class CacheConfiguration {
  public static final String HIERARCHY = "hierarchy";
  @Autowired
  private HierarchyCloner hierarchyCloner;

  @Bean
  public CacheManager cacheManager() {
    SimpleCacheManager cacheManager = new SimpleCacheManager();
    cacheManager.setCaches(
        List.of(
            // 用自定义的读时拷贝缓存替换原有实现
            new CopyOnReadConcurrentMapCache(HIERARCHY, hierarchyCloner)));
    return cacheManager;
  }

  @CacheEvict(
      allEntries = true,
      value = {HIERARCHY})
  @Scheduled(fixedDelayString = "${products.hierarchy.cacheMsDelay:60000}")
  public void reportCacheEvict() {
    log.debug("Flush Cache {}", LocalDateTime.now());
  }
}

方案2:拆分内部缓存方法和对外返回逻辑(改动小,逻辑直观)

不要直接把对外暴露的接口方法加上@Cacheable,单独写一个内部调用的缓存方法存原始全量数据,对外暴露的getHierarchy()方法每次从内部缓存拿到原始数据后,拷贝一份新副本再返回。
代码示例:

@Service
@RequiredArgsConstructor
@Log4j2
public class ProductHierarchyServiceImpl implements ProductHierarchyService {
  private final ProductGroupRepository repository;
  private final ProductTreeMapper mapper;
  private final HierarchyCloner cloner;

  // 内部缓存方法,不要对外暴露,保证只有当前类能访问缓存里的原始值
  @Cacheable(value = HIERARCHY)
  @Trace(dispatcher = true)
  private List<ProductHierarchyBean> getCachedRawHierarchy() {
    var productGroups = repository.findAll();
    return mapper.productGroupsToProductHierarchyBeans(productGroups);
  }

  @Override
  public List<ProductHierarchyBean> getHierarchy() {
    List<ProductHierarchyBean> rawData = getCachedRawHierarchy();
    // 对外返回独立副本,调用方修改副本不影响缓存原始值
    return cloner.deepCopy(rawData);
  }
}

使用这个方案需要注意:避免同类调用导致Spring AOP缓存失效,必要时可以把内部缓存方法拆到单独的组件中,同时严格控制缓存原始方法的访问权限,防止其他类直接拿到原始引用修改。

方案3:下游业务侧自行拷贝后修改(不推荐)

如果不想调整缓存配置,可以在所有调用层级数据的业务位置,拿到返回值后先做深拷贝再修改:

@Override
public List<ProductHierarchyBean> getFullHierarchy(Integer licenceId) {
  List<ProductHierarchyBean> hierarchy = cloner.deepCopy(productHierarchyService.getHierarchy());
  // 后续所有修改都针对副本,不会影响缓存
  // ... 授权属性转换逻辑
  return hierarchy;
}

这个方案的缺陷是完全依赖所有调用方的编码规范,只要有一个调用方忘记做拷贝,就会复现缓存污染问题,多人协作的项目维护成本极高,不建议使用。

避坑提醒

不要尝试通过更换本地缓存实现(比如换Caffeine、Guava Cache)解决这个问题:只要是堆内缓存存储对象引用,不做读时拷贝就始终存在缓存污染风险。如果用Redis等远程缓存,每次序列化/反序列化拿到的天然是新对象不会有污染问题,但会引入网络IO和序列化开销,性能不如本地缓存+读时拷贝的方案。

内容的提问来源于stack exchange,提问作者Nestor Milyaev

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 09:39:57