
干过几年Java后端的人多少都有过一段手写缓存的经历——业务代码里塞满if (redis.get(key) ! null)的判断key散落各处清理时生怕漏掉一条。后来用上Spring Cache几张注解就能写缓存读写代码确实干净了不少。但Spring Cache不是装上就能用好的东西它把缓存逻辑从业务代码里抽走也把很多暗坑埋在了注解底下等你在生产环境踩上去。这篇文章是我对Spring Cache缓存使用的一次完整复盘覆盖核心注解语义、CacheManager选型、Caffeine与Redis多级缓存整合、缓存穿透和击穿处理、注解失效的排查链路以及命中率监控。适合已经在项目里用Spring Cache但总感觉“哪里不对劲”的人也适合刚准备引入缓存注解、想避开常见坑的新手。1. 从手写缓存到Spring Cache业务代码里少操了多少心先回到最原始的场景一个查询用户信息的接口每天被调用几十万次数据库压力很大。大多数人的第一个念头是加Redis缓存于是代码变成这样public UserVO getUser(String userId) { String key user:info: userId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSONUtil.toBean(cached, UserVO.class); } User user userMapper.selectById(userId); UserVO vo convertToVO(user); redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(vo), 30, TimeUnit.MINUTES); return vo; }这段逻辑看起来没毛病但项目里类似的接口稍微一多问题就暴露了每个方法是read-through还是write-throughkey怎么命名过期时间分别多长删除缓存和更新缓存的时机是否一致——全靠程序员自觉。代码评审时最痛苦的就是一遍遍提醒“缓存key要带版本号”“更新数据库之后记得删缓存”。说白了缓存的核心逻辑没有被沉淀下来而是散落在业务代码里维护成本越来越高。Spring Cache做的事情其实是把“从缓存取数据、存储数据、删除数据”这些流程抽象成了统一的基础能力业务代码只需要关心“这个方法的返回值可以被缓存”或者“执行这个方法后要清理哪些缓存”。以外观模式来理解也通缓存对业务透明业务不感知底层是Caffeine还是Redis也不自己拼key、管序列化。我的感受是它最大的价值不是省那几行代码而是让缓存策略在项目里形成了统一规范。1.1 三个核心注解就能覆盖90%的场景日常项目里我用得最多的注解就三个注解作用典型场景Cacheable先查缓存缓存有就直接返回没有则执行方法再写入缓存读多写少的查询接口CachePut只写入缓存不查缓存方法每次都执行更新数据后同步刷新缓存CacheEvict删除缓存支持按key删除或清空整个缓存区域删除数据后移除缓存有人可能会问CachePut和Cacheable同时标注一个方法会怎样实际业务里确实有这种需求比如“新增或更新数据时如果缓存里有旧值就覆盖没有旧值就新增”。此时直接把两个注解叠在一起就行Spring Cache会分别执行两条语义Cacheable尝试读CachePut永远写。但要注意如果同一个方法上Cacheable因为key生成规则不同可能会读到别的数据所以这种用法必须确保key的生成规则完全一致。1.2 缓存区域cacheNames才是组织缓存的第一级维度很多人刚开始用Spring Cache时只关心key忽略了cacheNames。实际上Spring Cache里的缓存并不是一个扁平的key-value桶而是按缓存区域也可以理解为缓存分区来组织的。例如Cacheable(cacheNames user, key #userId) public UserVO getUser(String userId) { ... }这个user区域在Redis中的表现通常是user::12345这样的key前缀。好处是清晰按业务域隔离后续清理也方便。更实用的是在配置CacheManager时可以按区域设置不同的过期时间Bean public CacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheManager.Builder builder RedisCacheManager.builder(factory) .cacheDefaults(redisCacheConfiguration(30, TimeUnit.MINUTES)); builder.withCacheConfiguration(user, redisCacheConfiguration(2, TimeUnit.HOURS)); builder.withCacheConfiguration(order, redisCacheConfiguration(1, TimeUnit.HOURS)); return builder.build(); } private RedisCacheConfiguration redisCacheConfiguration(long duration, TimeUnit unit) { return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(unit.toMinutes(duration))) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); }按业务域设定差异化的过期时间比所有key一刀切要合理得多。我看到有些项目把全公司的缓存key都放在一个cacheNames里然后靠复杂的key字符串区分等到排查问题时发现缓存数据互相污染那就非常被动了。2. 核心注解会写Cacheable不代表会用Cacheable的常见写法是Cacheable(cacheNames user, key #userId)很简单对吧但真正让它发挥价值的是key生成、condition和unless这几个细节很多人踩坑就踩在这几个参数上。2.1 key生成策略与SpEL表达式的坑key参数支持Spring的SpEL表达式这是Spring Cache最强大也最容易被误用的地方。常见写法有key #userId取参数userId。key #user.id取User对象的id属性。key #root.methodName取方法名。key #root.targetClass.name : #userId手动拼接。我见过最“壮观”的坑是有人写key #user直接把整个对象作为key。这里的风险巨大对象如果没有重写equals和hashCode每次请求的同一个用户数据都是不同对象缓存命中率会变成零。不是数据库压力没减轻而是缓存完全形同虚设。通常我们只建议取ID、类型、状态这类值对象做key不建议整个对象做key。有一个细节尤其需要注意如果方法上有多个参数Spring Cache默认的key是“所有参数经过hashCode计算后拼接的结果”。看起来挺智能但它的问题是key可读性极差。比如getUser(String userId, String tenantId)默认key可能是一长串数字排查缓存数据时完全对不上业务含义。所以项目里我统一要求使用keyGenerator或显式指定key禁止依赖默认生成规则。KeyGenerator可以自己实现一个全局的Bean public KeyGenerator cacheKeyGenerator() { return (target, method, params) - { StringBuilder sb new StringBuilder(); sb.append(target.getClass().getSimpleName()).append(:); sb.append(method.getName()).append(:); for (Object param : params) { sb.append(String.valueOf(param)).append(;); } return sb.toString(); }; }使用Cacheable(cacheNames user, keyGenerator cacheKeyGenerator) public UserVO getUser(String userId) { ... }这样生成的key是UserServiceImpl:getUser:12345;排查问题的时候一眼就能看出是哪个类的哪个方法。当然这种方式要求参数不包含敏感信息如果参数里有大对象还是要显式指定关键字段。2.2 condition和unless条件缓存的边界到底在哪这两个参数都能控制是否缓存但语义完全不同condition在方法调用前判断如果为false整个方法都不走缓存逻辑既不查缓存也不写缓存。unless在方法调用后判断如果为true方法的结果不会被放入缓存但依然会查缓存。这么说有点抽象我直接举例Cacheable(cacheNames user, key #userId, condition #userId ! null, unless #result.code ! 0) public UserVO getUser(String userId) { ... }这段代码的意思是当userId为null时直接执行方法不碰缓存执行完后如果结果对象里的code不是0也不把结果写入缓存。这两个条件配合可以很方便地过滤掉查询失败或者异常返回的数据避免缓存被脏数据污染。我曾经犯过一个错误想实现“根据状态位决定是否缓存”把状态判断放在了unless里结果状态发生变化后缓存里的旧值被反复命中把业务坑惨了。后来才反应过来unless是基于返回结果的适合用来过滤结果condition是基于入参和上下文环境的适合用来切换策略。2.3 CacheEvict的allEntries和多级删除删除缓存时如果只知道单个key直接用CacheEvict(cacheNames user, key #userId)即可。但业务上经常要清除“整个区域”的缓存比如用户角色变更后该用户所有维度的缓存都失效了这时候可以用CacheEvict(cacheNames user, allEntries true) public void refreshUserCache() { ... }allEntries true会把user区域下的所有key清空。对本地缓存Caffeine来说这个操作是清掉整个缓存实例对Redis来说是按user*前缀批量删除数据量大的时候要注意性能。这里有个大坑在高并发场景下allEntries清空整个区域会导致瞬间大量缓存miss数据库压力暴增。我的做法是尽量把缓存区域拆细让每个区域的key数量可控避免动不动allEntries。多级缓存场景下还要注意一个点CacheEvict默认只能删除当前CacheManager管理的缓存如果你做了CaffeineRedis多级缓存只删除一级是不够的。Spring Cache 4.x版本在多级缓存支持上已经好一些但早期版本还是需要自己扩展CacheManager或者统一在Cache实现里同时清除两级缓存。3. 缓存管理器选型本地缓存、分布式缓存与多级缓存注解只是入口真正决定缓存行为的底层容器是CacheManager。不同的CacheManager决定了缓存数据存哪儿、会不会过期、能不能跨节点共享。3.1 ConcurrentMapCacheManager为什么只适合开发环境Spring Boot默认用的是ConcurrentMapCacheManager它在应用内存里用一个ConcurrentMap存数据配置几乎为零。开发环境联调时确实方便但它是本地缓存有几个硬伤每个节点各自维护一份缓存节点之间完全不一致缓存没有淘汰策略内存占用不受控。一旦上生产几乎没人会用它当主力缓存尤其是多实例部署时不同节点的用户收到的数据可能完全不一样。所以本地缓存仅适合单机、小规模、或者临时演示使用。3.2 用Caffeine替换本地缓存如果你决定用本地缓存我会直接建议上Caffeine而不是自己写ConcurrentMap工具类。Caffeine是当前Java生态里性能很强的本地缓存库支持基于容量、过期时间、引用类型等多种淘汰策略还自带命中率统计。接入方式很简单dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId /dependency配置Bean public CacheManager caffeineCacheManager() { CaffeineObject, Object builder Caffeine.newBuilder() .initialCapacity(100) .maximumSize(10_000) .expireAfterWrite(30, TimeUnit.MINUTES) .recordStats(); return new CaffeineCacheManager(user, order) {{ setCaffeine(builder); }}; }这里的关键参数是maximumSize和expireAfterWrite。如果缓存的是大对象更合理的是maximumWeightweigher让缓存总内存占用可控。还有一个我后来才注意到的点Caffeine的recordStats()开启后可以通过cache.stats()拿到命中率、加载耗时这个数据对后续的缓存治理非常有用后面专门讲。3.3 Redis缓存与序列化问题分布式场景下缓存一定要放到Redis否则多实例各玩各的一致性没法保证。但要小心Spring Cache默认接入Redis时序列化方式很关键。如果直接用JdkSerializationRedisSerializerRedis里存的是一堆\xAC\xED\x00\x05开头的二进制数据虽然能反序列化但排查问题极不方便而且跨语言调用基本无望。我更推荐用GenericJackson2JsonRedisSerializer配置方法Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(config) .transactionAware() .build(); }一个比较隐蔽的坑是GenericJackson2JsonRedisSerializer反序列化时如果目标对象没有无参构造器或者类型信息在后端应用中类名变了会直接抛异常。所以用Redis做Spring Cache缓存一定要保证被缓存对象的结构稳定最好是轻量的VO或DTO尽量不要把DO直接丢进缓存也不能在缓存类里加一些无关的杂项字段。还有一个点默认情况下disableCachingNullValues不建议取消否则缓存null值会导致Redis里堆积大量空key当然这个问题留到“穿透”部分再展开。3.4 CaffeineRedis多级缓存的整合方案单用Redis每次读缓存都要走一次网络单用Caffeine多实例数据不一致。生产上最常见的方案是Caffeine做一级本地缓存Redis做二级分布式缓存读请求先查本地、本地没有查Redis、Redis没有查数据库再逐级回填。Spring Cache本身并不直接支持多级缓存但可以通过自定义CacheManager把两级缓存串起来。下面是我实际项目里验证过的一段简化实现思路public class CompositeCache implements Cache { private final String name; private final com.github.benmanes.caffeine.cache.CacheObject, Object localCache; private final RedisCache redisCache; public CompositeCache(String name, com.github.benmanes.caffeine.cache.CacheObject, Object localCache, RedisCache redisCache) { this.name name; this.localCache localCache; this.redisCache redisCache; } Override public ValueWrapper get(Object key) { ValueWrapper value localCache.getIfPresent(key); if (value ! null) { return value; } value redisCache.get(key); if (value ! null) { localCache.put(key, value.get()); } return value; } Override public void put(Object key, Object value) { localCache.put(key, value); redisCache.put(key, value); } Override public void evict(Object key) { localCache.invalidate(key); redisCache.evict(key); } Override public void clear() { localCache.invalidateAll(); redisCache.clear(); } }注意上面这个CompositeCache是示意代码生产环境还要考虑本地缓存的过期和Redis的过期如何对齐、缓存穿透时两级如何同步放行等。我踩过的一个坑是本地缓存和Redis的过期时间不一致导致脏数据残留时间比预期长得多。现在我的做法是把本地缓存过期时间设置成Redis的一半保证脏数据最多在半个过期周期内被淘汰。当然这取决于业务对一致性的容忍度具体值可以根据实际压测来定。多级缓存确实能显著降低Redis压力但也带来了缓存一致性风险所以写缓存的时候本地和Redis要同时写失效的时候要同时失效读请求串行回填时尽量加一个简单锁防止同一时刻大量请求同时打到数据库——这部分就是后面要说的缓存击穿问题了。4. 缓存失效、穿透与击穿Spring Cache管不到的地方很多初学者以为Spring Cache只要配好注解就万事大吉了其实它帮你搞定的是“存取删”的流程但缓存底下的三个经典问题——缓存穿透、击穿、雪崩——都需要额外方案处理。4.1 固定过期时间引发雪崩entryTtl(Duration.ofMinutes(30))这种统一30分钟的写法在数据量一大就会暴露问题。假设上万条缓存数据都在同一时间点写入那它们也会在同一时间点集体过期。过期那一刻所有请求发现缓存里没有key瞬间全部打到数据库数据库实际扛不住。这就是缓存雪崩的常见形态。解决做法给过期时间加一个随机扰动。基于RedisCacheManager做二次封装或者在写入缓存时按业务域指定一个随机TLR范围public class RandomTtlRedisCacheManager extends RedisCacheManager { Override protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) { RedisCacheConfiguration config cacheConfig.entryTtl(randomTtl()); return super.createRedisCache(name, config); } private Duration randomTtl() { long base Duration.ofMinutes(30).toMillis(); long offset ThreadLocalRandom.current().nextLong(0, 5 * 60 * 1000L); return Duration.ofMillis(base offset); } }效果就是缓存key不再同一秒集体失效数据库压力被削峰。实现不复杂但很多项目往往是等线上出问题之后才补这个逻辑。4.2 缓存空值解决穿透缓存穿透是指查一个肯定不存在的key比如用一个不存在的用户ID查用户信息每次都会穿过缓存打到数据库。一旦有人恶意刷不存在的ID数据库容易被拖垮。Spring Cache在RedisCacheConfiguration里默认禁用null缓存disableCachingNullValues()这意味着当方法返回null时缓存不会写入穿透问题也就会一直被放大。要解决穿透就得在方法返回null时也把空值缓存下来并设置一个较短的过期时间。但Spring Cache默认禁用了null缓存需要手动开启RedisCacheConfiguration.defaultCacheConfig() .enableCachingNullValues() .entryTtl(Duration.ofSeconds(60));开启后返回null的值也会被缓存。短TTL保证数据在短时间内能自动过期避免缓存里堆积太多无意义key。还有一个前置过滤方案更好在业务接口入口做一个“不存在ID的预校验”比如布隆过滤器。只是布隆过滤器的维护成本高一些小型项目直接缓存空值性价比更高。4.3 缓存击穿与锁的配合击穿和穿透的区别是击穿是某个key在缓存过期瞬间大量请求并发访问数据库。解决思路的核心在于“单线程回源”或者“限流回源”。但Spring Cache注解本身并不提供分布式锁能力所以我通常会额外封装一层。一个精简做法是在缓存重建方法上加锁锁粒度到keypublic UserVO getUserWithLock(String userId) { UserVO vo cacheService.get(userId); if (vo ! null) { return vo; } String lockKey lock:user: userId; boolean locked distributedLock.tryLock(lockKey, 3, TimeUnit.SECONDS); if (!locked) { // 拿不到锁的请求休眠一小段时间后重试 Thread.sleep(50); return getUserWithLock(userId); } try { vo cacheService.get(userId); if (vo ! null) { return vo; } vo loadAndCache(userId); return vo; } finally { distributedLock.unlock(lockKey); } }这里体现了“双重检查”的思路拿锁之前先查一次缓存拿锁之后再查一次防止重复查询数据库。Spring Cache本身不帮你做这层控制所以需要自己在业务方法里再包一层。如果你的团队已经引入了Redisson可以直接用RLock实现成本不高对高流量系统非常推荐。5. 注解失效的常见陷阱与排查链路Spring Cache的注解都是通过AOP代理来拦截的。既然是代理就必然有失效的边界。我在项目中踩过几次坑梳理了一个排查套路基本覆盖了大多数情况。5.1 this调用导致注解失效最常见的一种在同一个类内部直接调用被Cacheable标注的方法注解不会生效。原因很简单当你从外部注入Bean调用时Spring给你的是代理对象但内部this.method()调用是走原生对象没有代理拦截那一层缓存逻辑压根不会执行。看个反例Service public class UserServiceImpl { Cacheable(cacheNames user, key #userId) public UserVO getUser(String userId) { return loadFromDb(userId); } public UserVO getUserAndDetail(String userId) { UserVO vo getUser(userId); // 这里走 this.getUser()缓存不生效 // 其他逻辑 return vo; } }解决方式有三种把这个方法拆到另一个Bean里通过注入的Bean去调用。在getUserAndDetail里使用AopContext.currentProxy()获取代理对象再调用需要开启EnableAspectJAutoProxy(exposeProxy true)。最简单内部就不调用带缓存注解的方法改成直接调缓存Service。这三种里我觉得第2种虽然代码短但引入了AOP相关魔法可读性一般第1种更符合职责单一原则是团队内推的方案。5.2 缓存与事务放在一起时的顺序问题Spring Cache和Transactional一起用时有时候会出现缓存里存了数据库事务尚未提交的数据。这是因为二者的拦截器顺序不同默认情况下事务拦截器的优先级较高事务提交后执行缓存操作按理说问题不大但如果拦截器顺序被改或者在事务内先查后插数据就可能读到一个尚不可见的状态。我的建议很简单缓存操作尽量放在事务方法的外层先提交事务再更新缓存。如果确实需要在事务内部更新缓存可以考虑TransactionSynchronizationManager.registerSynchronization()在事务提交后同步缓存。这个细节在写更新类接口时特别容易忽略尤其是那种“删除缓存后再更新数据库”的顺序一旦顺序反了在极端并发下会出现缓存里是旧数据的问题。5.3 一套完整的排查链路如果线上缓存不生效可以按下面的顺序逐层排查步骤检查项常见原因1是否走代理类内部this调用、方法不是public、类未被Spring管理2key是否符合预期默认key可读性差或者参数对象equals/hashCode异常3condition/unless是否误伤condition在方法进入前判断unless在返回后判断理解反了会误判4CacheManager是否配置正确使用的是哪个缓存管理器、缓存区域是否存在、TTL是否设置5Redis序列化方式对象没有无参构造器、类型信息丢失反序列化失败6缓存是否被提前清空是否有其他定时任务或服务调了evict/allEntries有一次我们线上用户缓存不更新排查到最后发现是一个定时任务每5分钟调用了userCacheManager.clear()导致缓存频繁全清命中率只剩个位数。这类问题不打印缓存命中日志光靠“感觉”很难发现。所以排查的第一步永远要先把缓存命中率数据拉出来看。6. 缓存命中率监控与日常治理前面讲了很多写法和坑现在说说缓存上线之后怎么让它长期稳定地跑下去。很多人把缓存加完就认为任务结束了实际上缓存是需要在运行期持续观察的“活系统”关键指标就是命中率。6.1 开启命中率统计Caffeine自带recordStats()RedisCacheManager没有直接暴露统计接口但你可以通过Redis的INFO stats命令拿到keyspace_hits和keyspace_misses。我习惯在每个CacheManager初始化时记录一下起点然后通过定时任务或监控平台如Prometheus Grafana持续采集。对Caffeine来说最简单的是定期输出每个缓存的命中率Scheduled(fixedDelay 60_000) public void logCacheStats() { caffeineCacheManager.getCacheNames().forEach(name - { com.github.benmanes.caffeine.cache.CacheObject, Object nativeCache ((CaffeineCache) caffeineCacheManager.getCache(name)).getNativeCache(); CacheStats stats nativeCache.stats(); log.info(cache{}, hitRate{}, loadCount{}, evictCount{}, name, stats.hitRate(), stats.loadCount(), stats.evictionCount()); }); }这里的hitRate是核心指标。如果某个业务的命中率长期低于50%就要回过头看看缓存key设计是不是有效、过期时间是不是太短、缓存内容是不是频繁变更。缓存本身的收益在命中率上体现得最直观。6.2 缓存治理的两个实用技巧第一给缓存区域加版本号或者前缀。一旦程序升级、缓存结构变化可以用发布脚本统一删除旧前缀的key而不是等到用户访问时才一个个淘汰。我习惯在cacheNames里带上业务版本比如userCache还是user:2这种应对缓存结构变更非常方便。第二写清理脚本和审计日志。生产环境总会有手滑或者临时调缓存的需求所以我会保留一张表记录缓存操作的操作人、时间和key范围。这不是Spring Cache的特性但算是我做缓存治理时沉淀的一个习惯关键时候能帮你快速定位“谁动了我的缓存”。6.3 定时任务场景下怎么用Spring Cache最后补充一点在定时任务或异步任务里使用Spring Cache的注意点。定时任务往往是批量执行容易一次性写入大量缓存导致Redis内存飙升。所以我的原则是批量任务绝不直接用CachePut循环写缓存而是先批量查数据库再一次性回填到缓存且要考虑分片写入避免大key和热key。另外异步方法上使用Cacheable时要特别注意返回值类型。如果异步方法返回CompletableFutureSpring Cache默认缓存的是这个Wrapper对象而不是真正的业务结果。这个坑我见过不止一次深究起来其实是代理和Spring Cache对异步返回值的边界处理问题实际使用中还是要让异步方法返回真实业务对象再加入缓存逻辑。这套组合拳打下来Spring Cache给我的感觉就是它能帮你省掉80%的重复缓存代码但剩下那20%的架构取舍比如缓存一致性、多级缓存、命中率治理恰恰是缓存系统能不能扛住线上流量的关键。建议新项目里用Spring Cache时先把CacheManager选型和缓存区域的边界规划清楚再写注解否则后期调整成本会非常高。