Spring Cache @Cacheable缓存对象被修改导致缓存污染解决方案
你遇到的缓存污染本质是Spring默认ConcurrentMapCache的存储/读取逻辑没有做对象隔离:缓存中存储的是堆内对象引用,所有调用方拿到的都是同一个对象实例,任意调用方修改对象(比如裁剪节点、修改授权属性)都会直接改动缓存里的原始值。
你之前在@Cacheable标注的方法内部做克隆无效的原因非常直接:@Cacheable的执行逻辑是方法首次运行完成后,把返回值直接存入缓存,你在方法里返回克隆对象,存进缓存的就是这个克隆对象,后续调用拿的还是同一个克隆实例,修改后照样污染缓存。
方案1:自定义支持读时拷贝的Cache实现(最推荐,无业务侵入)
不用修改原有缓存注解和业务逻辑,只需要替换默认的ConcurrentMapCache,重写缓存值的读取逻辑,每次从缓存拿到原始值后先做深拷贝再返回给调用方,保证缓存里始终存的是未修改的原始全量数据。
代码示例:
- 实现支持读时拷贝的自定义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实现了序列化接口,直接用序列化/反序列化做通用深拷贝最省事;如果追求性能,可以手动实现树结构的专属拷贝逻辑,比通用序列化方案快很多。
- 替换缓存配置里的默认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

