Spring Cache实战:注解使用、多级缓存整合与常见坑全复盘
2026/9/15 1:57:36 网站建设 项目流程

干过几年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-through,key怎么命名,过期时间分别多长,删除缓存和更新缓存的时机是否一致——全靠程序员自觉。代码评审时最痛苦的就是一遍遍提醒“缓存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。这里的风险巨大:对象如果没有重写equalshashCode,每次请求的同一个用户数据都是不同对象,缓存命中率会变成零。不是数据库压力没减轻,而是缓存完全形同虚设。通常我们只建议取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) { ... }

这段代码的意思是:当userIdnull时,直接执行方法,不碰缓存;执行完后,如果结果对象里的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管理的缓存,如果你做了Caffeine+Redis多级缓存,只删除一级是不够的。Spring Cache 4.x版本在多级缓存支持上已经好一些,但早期版本还是需要自己扩展CacheManager,或者统一在Cache实现里同时清除两级缓存。

3. 缓存管理器选型:本地缓存、分布式缓存与多级缓存

注解只是入口,真正决定缓存行为的底层容器是CacheManager。不同的CacheManager决定了缓存数据存哪儿、会不会过期、能不能跨节点共享。

3.1 ConcurrentMapCacheManager为什么只适合开发环境

Spring Boot默认用的是ConcurrentMapCacheManager,它在应用内存里用一个ConcurrentMap存数据,配置几乎为零。开发环境联调时确实方便,但它是本地缓存,有几个硬伤:每个节点各自维护一份缓存,节点之间完全不一致;缓存没有淘汰策略;内存占用不受控。一旦上生产,几乎没人会用它当主力缓存,尤其是多实例部署时,不同节点的用户收到的数据可能完全不一样。所以,本地缓存仅适合单机、小规模、或者临时演示使用。

3.2 用Caffeine替换本地缓存

如果你决定用本地缓存,我会直接建议上Caffeine,而不是自己写ConcurrentMap工具类。Caffeine是当前Java生态里性能很强的本地缓存库,支持基于容量、过期时间、引用类型等多种淘汰策略,还自带命中率统计。

接入方式很简单:

<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency>

配置:

@Bean public CacheManager caffeineCacheManager() { Caffeine<Object, Object> builder = Caffeine.newBuilder() .initialCapacity(100) .maximumSize(10_000) .expireAfterWrite(30, TimeUnit.MINUTES) .recordStats(); return new CaffeineCacheManager("user", "order") {{ setCaffeine(builder); }}; }

这里的关键参数是maximumSizeexpireAfterWrite。如果缓存的是大对象,更合理的是maximumWeight+weigher,让缓存总内存占用可控。还有一个我后来才注意到的点:Caffeine的recordStats()开启后,可以通过cache.stats()拿到命中率、加载耗时,这个数据对后续的缓存治理非常有用,后面专门讲。

3.3 Redis缓存与序列化问题

分布式场景下,缓存一定要放到Redis,否则多实例各玩各的,一致性没法保证。但要小心,Spring Cache默认接入Redis时,序列化方式很关键。如果直接用JdkSerializationRedisSerializer,Redis里存的是一堆\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 Caffeine+Redis多级缓存的整合方案

单用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.Cache<Object, Object> localCache; private final RedisCache redisCache; public CompositeCache(String name, com.github.benmanes.caffeine.cache.Cache<Object, 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注解本身并不提供分布式锁能力,所以我通常会额外封装一层。

一个精简做法是:在缓存重建方法上加锁,锁粒度到key:

public 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; } }

解决方式有三种:

  1. 把这个方法拆到另一个Bean里,通过注入的Bean去调用。
  2. getUserAndDetail里使用AopContext.currentProxy()获取代理对象再调用(需要开启@EnableAspectJAutoProxy(exposeProxy = true))。
  3. 最简单,内部就不调用带缓存注解的方法,改成直接调缓存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.Cache<Object, 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时要特别注意返回值类型。如果异步方法返回CompletableFuture,Spring Cache默认缓存的是这个Wrapper对象,而不是真正的业务结果。这个坑我见过不止一次,深究起来其实是代理和Spring Cache对异步返回值的边界处理问题,实际使用中还是要让异步方法返回真实业务对象再加入缓存逻辑。

这套组合拳打下来,Spring Cache给我的感觉就是:它能帮你省掉80%的重复缓存代码,但剩下那20%的架构取舍,比如缓存一致性、多级缓存、命中率治理,恰恰是缓存系统能不能扛住线上流量的关键。建议新项目里用Spring Cache时,先把CacheManager选型和缓存区域的边界规划清楚,再写注解,否则后期调整成本会非常高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询