资讯详情

Spring Cache实战复盘:Redis缓存改造的完整避坑指南

📅 2026/10/13 15:38:49 | 华诺云谱 👁 阅读
Spring Cache实战复盘:Redis缓存改造的完整避坑指南
我第一次把Spring Cache写进生产代码的时候是带着一点怀疑的。那会儿团队里的缓存逻辑已经用RedisTemplate手写了一大堆从key拼接到过期时间再到空值处理每个开发都有自己的习惯换个人看那些opsForValue().set(...)简直是灾难。真正推动我下决心统一改造成Spring Cache的是一次订单详情读接口的复盘——慢查询、重复查询、key风格混乱缓存一失效就是十几个线程同时打到MySQL。后面我们花了一个迭代把所有缓存操作收敛到Spring Cache上过程中踩了不少文档里根本不会写的坑。这篇文章就是那次改造的完整复盘适合正在用Spring Boot Redis做缓存的Java开发者尤其是打算系统性引入Spring Cache但还没碰到深水区的人。1. Spring Cache解决的是散落的缓存代码不是缓存性能本身1.1 缓存抽象层到底做了什么很多人第一次接触Spring Cache会觉得它就是“几个注解把Redis封装了一下”。这话对了一半但容易产生两个误解一是以为引入Spring Cache系统就会变快二是以为有了注解就不需要再关心缓存设计。Spring Cache的本质是一个抽象层它把“往缓存里读写数据”这件事从业务代码里剥离出去。三层结构各司其职CacheManager负责创建和管理一批Cache实例对应不同的存储介质Redis、本地ConcurrentMap、Caffeine。Cache单个缓存实例提供get、put、evict这些基础操作。CacheInterceptorSpring AOP的拦截器在带缓存注解的方法执行前后完成“先查缓存、未命中再执行方法并回填、方法成功后清理缓存”的整套逻辑。也就是说当你在方法上写了Cacheable真正生效的并不是注解本身而是Spring容器里那个代理对象。外部调用进来先经过拦截器判断缓存再决定要不要执行你的业务方法。这套抽象最直接的收益是缓存代码从“散落在每个业务方法里”变成了“由统一机制接管”。团队里不再需要讨论“这个缓存key怎么拼”“过期时间写多少”这些问题上升到配置层和策略层解决。1.2 Spring Cache和直接用RedisTemplate的核心差异我整理过一张对比表挺能说明问题对比维度手写RedisTemplateSpring Cache注解方式缓存代码位置散落在业务方法内肉眼可读但难以治理注解配置统一收敛代码本身只关心业务key管理每个开发一种风格拼接容易出错统一KeyGenerator或spEL表达式缓存类型切换要改业务代码换CacheManager即可业务无感知排错成本直观但重复劳动多有一定学习成本需要理解代理机制可监控性取决于个人实现结合Actuator/Micrometer有标准指标但有一点必须说透Spring Cache并不会让系统变快。它不会帮你提高命中率也不会替你处理缓存穿透。它改变的是代码组织方式不是缓存策略本身。如果原来RedisTemplate方案命中率只有50%改成Spring Cache命中率还是50%。我在踩坑之后的认识是Spring Cache的价值在于“把缓存策略从约定变成可审计的配置”。每个人的写法被强制统一了review代码的时候只需要看注解和cacheName不需要逐行去读get/set逻辑。1.3 它不负责的事决定了你后续要补多少东西这句话可能有点反直觉但很重要Spring Cache默认情况下不解决缓存穿透、缓存击穿、缓存雪崩也不保证缓存与数据库的一致性。它只提供最基本的“读写缓存”能力以及几个注解带来的语义控制。穿透查询一个必然不存在的key缓存永远无法命中请求直接打到DB。Spring Cache默认不会帮你缓存null值RedisCacheManager默认禁止存null。击穿某个热点key到了过期时间瞬间大量并发请求同时发现缓存失效一起跑到DB加载数据。雪崩大量key在同一时间窗口过期DB负载出现尖峰。一致性数据库更新之后缓存何时失效如果更新DB成功但删缓存失败怎么办如果并发读写交叉怎么办Spring Cache完全不关心。提前把这四条边界说清楚是为了让你在引入之前就建立正确的预期Spring Cache是个好工具但它不是缓存方案只是缓存方案里的一层实现。后面你会看到我写的每个配置和兜底代码实际上都是在给这层抽象“打补丁”。2. 跑通一个带Redis的Spring Cache三件套先搞定2.1 依赖与启动配置在Spring Boot项目里引入Spring Cache非常简单两个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后在启动类或配置类上加上EnableCaching。application.yml里指定缓存类型spring: cache: type: redis大多数人做到这一步就去写Cacheable了。如果你是小项目、本地缓存都够用那这样确实可以跑。但一旦接上Redis第一个坑立刻出现Spring Boot自动配置的RedisCacheManagervalue默认用JDK序列化器。什么意思你缓存的任何对象都必须实现Serializable接口存进Redis的内容是一坨二进制乱码。打开Redis客户端一看key倒是能认出来value全是\xAC\xED\x00\x05t\x00...这种。排查问题的时候根本不知道里面存了什么而且JDK序列化体积大、效率低和其它语言的服务基本无法互通。2.2 序列化器配置的坑我的建议很直接key用StringRedisSerializervalue用GenericJackson2JsonRedisSerializer。前者保证key在Redis里人类可读后者保证value是JSON格式方便排查和跨语言读取。这里有个必须处理的细节GenericJackson2JsonRedisSerializer会在序列化结果里写入class类型信息。如果你直接用它的默认构造器对LocalDateTime这类Java 8时间类型支持不完整反序列化时会报错。我当时的做法是自定义一个ObjectMapper塞进去Configuration EnableCaching public class RedisCacheConfig { Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { ObjectMapper om new ObjectMapper(); om.registerModule(new JavaTimeModule()); om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(om); RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(serializer)) .entryTtl(Duration.ofMinutes(10)); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); } }有几个点容易翻车DefaultTyping不能乱开。早期很多人用om.enableDefaultTyping()这在Jackson 2.10以后被标记为不安全存在反序列化漏洞风险要用activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ...)这种显式校验的方式。缓存对象尽量用DTO不要直接把JPA实体扔进去。实体类字段多、继承关系复杂每次版本升级都可能加字段、改结构JSON里积累的class信息一旦对不上就是大面积反序列化失败。我们上线时遇到过服务升级后缓存里残留旧类名反序列化直接抛异常最后只能清缓存代价很大。如果Redis里的数据要被别的语言服务读取就不要依赖Jackson的类型信息存纯JSON的结构会更稳。但那样Java侧反序列化泛型会比较麻烦需要自己控制类型。这个取舍看团队实际需求。2.3 TTL配置与随机过期entryTtl统一设置了所有缓存10分钟过期这带来第二个问题同一批数据往往是同时写入的过期时间也几乎一样表现就是周期性的缓存雪崩。比如定时任务批量刷新商品信息所有key在同一时间点失效下一秒所有请求同时打到DB。我习惯按业务维度拆分TTL并且加一个随机偏移。RedisCacheManager支持给不同cacheName设置不同的RedisCacheConfigurationMapString, RedisCacheConfiguration configs new HashMap(); RedisCacheConfiguration base RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(...) .serializeValuesWith(...); configs.put(product:info, base.entryTtl(Duration.ofMinutes(5))); configs.put(user:profile, base.entryTtl(Duration.ofMinutes(30))); configs.put(order:page, base.entryTtl(Duration.ofMinutes(2))); return RedisCacheManager.builder(factory) .cacheDefaults(base) .withInitialCacheConfigurations(configs) .build();至于随机偏移Spring Data Redis 3.x提供了TtlFunction可以基于缓存key和value动态返回TTL实现真正的“基础TTL随机抖动”。如果你用的版本支持建议直接用早期版本只能在业务侧通过缓存一个随机时间标记或者干脆订阅一个缓存更新事件来绕。3. 缓存注解的业务语义用错一个缓存就成数据事故3.1 Cacheable不是万能的Cacheable的语义是先查缓存命中直接返回未命中执行方法并回填。很多人一条注解走天下但它在业务上有明确的前提假设方法返回的数据在TTL内是可以接受的快照读多写少并且允许短时间不一致。用户昵称、商品标题、系统配置项这类数据适合Cacheable。但如果你拿它去缓存库存数量、账户余额就是在给自己埋雷——缓存里的值和数据库一旦出现偏差用户看到的就是过期数据。我在实际复盘时发现团队里最容易出问题的不是不知道Cacheable的含义而是太习惯把所有查询方法都加上缓存。一个查询接口要不要加缓存判断标准不是“查得慢”而是“数据变化频率容忍不一致时长”。我以前参与过一个模块开发图省事把所有字典查询都缓存了1小时结果后台改了配置前台永远显示旧值排查了很久才发是缓存。3.2 CacheEvict与CachePut之间我为什么总选Evict更新数据的方法上通常有两种写法CacheEvict(cacheNames product:info, key #id) public void updateProduct(Long id) { // 更新数据库 }CachePut(cacheNames product:info, key #id) public Product updateProduct(Long id) { // 更新数据库并返回最新Product }我几乎无条件倾向用CacheEvict而不是CachePut。原因很实际CachePut要求方法返回值就是数据库最终状态的最新值但并发场景下这个假设很脆弱。假设线程A和线程B同时更新同一条记录A先更新数据库值为XB再更新为Y。如果A的CachePut晚于B执行缓存里可能被写回X而数据库已经是Y这个不一致会一直持续到缓存过期。CacheEvict简单粗暴数据库更新成功后直接把缓存删掉下次读取时天然回填最新值。它虽然会在删缓存到回填缓存之间造成一次额外的DB查询但对“读多写少”的系统来说这点成本完全值得。与其保证“缓存永远是新的”不如保证“缓存里永远是有效的数据源快照”。代价是CacheEvict在高并发下有一个小的性能损耗窗口但从一致性角度看这是最务实的取舍。3.3 自调用与代理失效一个能埋一天的坑Spring Cache基于Spring AOP而AOP基于代理对象。外部调用Bean方法时调用的是代理对象所以注解生效同一个类的内部方法直接调用走的是this引用不会经过代理注解等于白写。这个坑我印象太深了。当时写了一个订单服务Service public class OrderService { public void processOrder(Order order) { // 业务逻辑... this.calcOrderPrice(order); // 内部调用缓存不生效 } Cacheable(cacheNames order:price, key #order.id) public BigDecimal calcOrderPrice(Order order) { // 复杂计算 } }processOrder里调用calcOrderPrice注解完全失效每次都会执行一遍全量计算压测时发现接口响应时间完全没改善还以为是Redis配置问题。解决方案有三个拆类把需要缓存的方法放到独立的Service Bean里两个Bean相互调用必经代理。这是最清晰、最推荐的方式。注入自身代理在类里Resource注入自己用self.calcOrderPrice(order)调用。AopContext.currentProxy()需要配置EnableAspectJAutoProxy(exposeProxy true)然后强转调用但代码可读性略差。3.4 缓存操作和事务提交的先后顺序另一个高频翻车点是事务和缓存的配合。看这段代码Transactional CacheEvict(cacheNames product:info, key #id) public void updateProduct(Long id) { productRepository.update(id); }你以为的执行顺序是方法执行 → 事务提交 → 删缓存。实际可能是方法执行 → 删缓存 → 事务提交。因为在默认AOP顺序下缓存拦截器和事务拦截器的执行顺序不保证如果缓存清理发生在事务提交之前那么另一个线程在事务还没提交时去查DB读到的还是旧数据它把旧数据回填到缓存事务提交后缓存里留下的是旧值新的读请求拿到脏数据。这个竞态窗口很小本地测试基本复现不出来但线上偶发一致性问题时特别难查。我的做法是显式把缓存失效放到事务提交后Transactional public void updateProduct(Long id) { productRepository.update(id); TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { cacheManager.getCache(product:info).evict(id); } }); }这样语义明确事务成功后再删缓存。如果确实需要事务回滚后也保留缓存那就不该用这种方案。另一种选择是Spring自带的TransactionAwareCacheManagerProxy它能让缓存操作感知事务但理解成本比上面这段代码高一点。我更推荐afterCommit的方式直观、可调试。4. key生成策略实战命中率和串数据都藏在key里4.1 默认KeyGenerator的三个坑Spring Cache默认的key生成规则是SimpleKeyGenerator无参数时key是SimpleKey.EMPTY单参数时key就是参数本身多参数时key是SimpleKey对象。这套规则看起来省事实际有三个大坑。坑一同一cacheName下不同方法相同参数会互相覆盖。假设两个方法Cacheable(cacheNames user:profile) public UserProfile getUserProfile(Long userId) { ... } Cacheable(cacheNames user:profile) public UserPermission getPermission(Long userId) { ... }两个方法都接收Long userIdkey完全相同。当getUserProfile(1)执行完写入缓存后getPermission(1)再来执行读取到的是UserProfile对象反序列化直接失败或者返回错误数据。要么给两个方法配不同的cacheName要么在key里加上方法标识。坑二单参数是对象时key不可控。如果入参是个业务对象默认直接拿对象本身当key那就要看对象的equals/hashCode实现是否稳定。若对象里有个字段没参与equals即使内容变了key也可能不变容易命中旧数据反之如果toString被重写得花里胡哨key里会出现一堆不可读的内容。坑三无参方法全局只有一条缓存记录。如果方法没有任何参数所有调用共用一个缓存key整个应用所有数据都挤在一个桶里根本没有隔离性。结论项目初期就应该自定义KeyGenerator或者至少在注解里显式指定key。晚改不如早改等缓存key已经散落在Redis里再改策略就是一次全量缓存迁移。4.2 自定义KeyGenerator的一个稳定模板我常用的自定义KeyGenerator大致长这样Component(bizKeyGenerator) public class BizKeyGenerator implements KeyGenerator { Override public Object generate(Object target, Method method, Object... params) { String className target.getClass().getSimpleName(); String methodName method.getName(); String paramPart Arrays.stream(params) .map(this::safeToString) .collect(Collectors.joining(_)); return className : methodName : paramPart; } private String safeToString(Object obj) { if (obj null) { return null; } if (obj instanceof Number || obj instanceof String) { return obj.toString(); } // 只取业务主键 return String.valueOf(ReflectionUtils.findField(obj.getClass(), id)); } }使用的时候在注解里指定Cacheable(cacheNames user:profile, keyGenerator bizKeyGenerator) public UserProfile getUserProfile(Long userId) { ... }不过随着经验增加我更倾向于直接用spEL表达式拼key因为更精确、可读性更好Cacheable(cacheNames user:profile, key #tenantId : #userId)尤其多租户系统key里必须带租户ID否则不同租户之间会互相读到对方的数据这是事故级别的错误。spEL里#参数名对应方法参数名如果编译时没有加-parameters参数记得用Parameter检查或者退而求其次用#p0、#a0这种下标方式。4.3 condition与unless让我重新理解了缓存判断时机Cacheable里有两个容易混的属性condition和unless。condition在方法调用前判断。为false时这次调用完全不查缓存直接执行方法也不会把结果写入缓存。unless在方法调用后判断。为true时方法正常执行但结果不写入缓存。一个经典误用是想根据入参过滤某些请求不走缓存结果错误地写成了unless #id 1。这样实际上方法照样执行只是结果不入缓存每次都会查DB和预期的“不走缓存”看起来一样但语义完全不同——前者是绕过缓存直接查后者是查完发现条件不满足再放弃缓存写入。我之前的排查经验是condition适合控制“哪些请求参与缓存”unless适合控制“哪些返回值不值得缓存”。比如Cacheable(cacheNames product:info, key #id, condition #id 0, unless #result null) public Product getProduct(Long id) { ... }含义是id大于0才参与缓存查出来是null不缓存。这两行各管各的逻辑才清楚。5. 一致性维护删缓存为什么比更新缓存更稳以及删不掉怎么办5.1 Cache Aside并发竞态的根源缓存与数据库的一致性业界最常用的是Cache Aside模式读的时候先读缓存未命中读DB再回填写的时候更新DB再删缓存。这个模式本身很简单但并发场景下总有一个窗口期线程A读缓存未命中正打算查DB 线程B更新DB删除缓存 线程A查完DB拿到旧数据回填缓存。结果就是缓存里留了一个旧值。这个竞态没有办法通过简单的“先删缓存再更新DB”解决因为窗口只是换了位置。我在生产环境的选择是读多写少且允许秒级延迟的场景用Cache Aside 短TTL不允许任何不一致的场景老老实实走实时查询不碰缓存。没有银弹能明确画出一条“不做缓存”的线本身也是架构能力。5.2 事务提交后再失效缓存上一章提到的afterCommit方案解决了“事务未提交就删缓存”的窗口我再展开一下完整代码Service public class ProductService { Autowired private CacheManager cacheManager; Transactional public void updateProduct(ProductUpdateCmd cmd) { productRepository.update(cmd); TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { Cache cache cacheManager.getCache(product:info); String key cmd.getTenantId() : cmd.getProductId(); cache.evict(key); } }); } }这个模式可以再封装一下写一个工具方法方便复用核心思想就一个把submit同步回调放到事务提交之后执行。另外如果用的是Spring的TransactionAwareCacheManagerProxy它的put不会在事务回滚后残留但evict是否也遵循同一语义需要看具体实现。我更推荐自己写同步回调逻辑透明。5.3 删除缓存失败后的兜底手段删缓存失败是分布式系统里一定会遇到的事。Redis网络抖动、超时、主从切换都可能让一次evict静默失败。如果业务允许最基础手段是三个层次日志告警evict后再次检查缓存是否存在如果还在就打WARN日志接告警。延迟重试用一个带重试的队列或本地定时任务对删除失败的key做补偿。binlog异步刷新监听数据库binlog变更后异步清缓存。这是成本最高但最彻底的手段适合一致性要求较高的核心链路。Spring Cache本身不提供这些能力所以如果你在核心链路上用了它就得在缓存层外面自己接上兜底策略。我在一个订单模块项目里就是用了“事务提交后删缓存删除失败写本地日志表离线任务重试”的组合线上没再出过缓存长期脏数据的问题。5.4 穿透和击穿的工程对策这一块虽然Spring Cache不直接负责但RedisCacheManager给了两个重要开关。空值缓存防穿透前面提到默认情况下RedisCacheManager不允许缓存null。如果你希望把查询为null的结果也缓存起来需要在配置里显式开启RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .allowNullValues(true) .entryTtl(Duration.ofMinutes(5));这时方法返回null会被缓存成一个特殊占位符。注意这只是降低穿透频率并不能根治恶意key攻击——如果有人一直构造不存在的id缓存里会堆积大量null记录。更彻底的方案是配合布隆过滤器但Spring Cache没有内置需要自己实现一个CacheWrapper。synctrue防击穿Cacheable(sync true)会在缓存未命中时针对同一个key加锁保证只有一条线程进入方法加载数据其它线程等待。这个属性对热点key瞬间失效的场景很有效Cacheable(cacheNames hot:product, key #id, sync true) public Product getHotProduct(Long id) { ... }有两点注意sync true时unless不再被支持如果缓存系统是Redis且多个实例并发加载默认的锁只存在于本地跨实例的击穿可能需要额外引入分布式锁。简单场景下sync能解决大部分问题。6. 从“能用”到“好用”命中率监控、缓存预热和二级缓存6.1 命中率监控怎么看切换到Spring Cache之后如果缺少可见性你很难说清楚它到底带来了多少价值。我一般会从两个维度看维度一Redis自身统计。Redis的INFO stats里有keyspace_hits和keyspace_misses两个值相除就是Redis整体读命中率。这个方法简单但分不清具体业务。维度二Spring Boot Actuator指标。引入spring-boot-starter-actuator后Micrometer会自动暴露cache.gets、cache.puts等指标/actuator/metrics/cache.gets?tagresult:hittagcacheName:product:info /actuator/metrics/cache.gets?tagresult:misstagcacheName:product:info把这两个指标接入Prometheus就能按cacheName看命中率趋势。我平时定的及格线是长期低于50%的缓存说明key设计或业务场景本身不适合缓存考虑移除命中率95%以上的要确认是不是缓存时间过长导致数据更新长期不可见。维度三自定义Cache实现。如果公司没有标准监控组件也可以包一层RedisCache在get方法里自己记录命中日志。代价是会侵入缓存层但排查问题时最直接。6.2 启动预热缓存冷启动的体验优化有些热点数据在系统刚启动时是一定会被大量请求击穿的例如权限配置、商品分类。与其等用户来“消耗”第一次查询不如启动时主动加载。Component public class CacheWarmer implements ApplicationRunner { Autowired private PermissionService permissionService; Override public void run(ApplicationArguments args) { ListLong ids permissionService.loadAllActiveIds(); for (Long id : ids) { // 注意这里调用的是代理Bean的方法缓存注解才生效 permissionService.getPermission(id); } } }这里有个容易忽视的点ApplicationRunner里注入的PermissionService必须是经过代理的Bean调用才走缓存。如果在一个类内部写个PostConstruct方法直接this.getPermission()缓存一定不会生效就是前面提到的自调用问题。预热的数据量也要控制量太大的话就成了启动时把DB打一遍需要分批限量加延迟。6.3 Caffeine Redis二级缓存的一个注意点本地缓存Caffeine的速度远快于Redis远程Redis则可以共享缓存数据。理想情况下我们希望本地一级缓存和Redis二级缓存配合但Spring Cache的CompositeCacheManager在这个场景下有坑它按顺序组合多个CacheManager读取时先查第一个管理器命中就返回未命中再查第二个但写入时只写入到第一个Cache上不会同时写入所有管理器。换句话说直接拿CompositeCacheManager组合Caffeine和Redis结果是只有Caffeine在写Redis永远读不到数据二级共享基本失效。要做多级缓存你需要自己写一个包装Cache实现put时同时写L1和L2evict时同时清理两级。或者直接用现成的第三方库。这也是为什么我建议新手先别碰多级缓存——把Redis单层缓存的一致性做好已经够考验架构能力了再加一层本地缓存只会让问题复杂化。回到我那段改造经历Spring Cache真正让我感到顺手的时刻不是它帮我少写了几行代码而是它把缓存策略从一个“口头约定”变成了“可审计的配置”。后来团队新同学接手看一个类上的CacheConfig、几个注解和Redis配置基本就能知道这个模块的缓存行为再配合TTL、key策略、事务提交后的清理逻辑线上一旦有问题也能顺着配置快速定位。最后分享一个我自己的体会有段时间我们把商品详情的缓存TTL从10分钟调成2分钟问题反而变少了。系统本来就不需要那么长的缓存周期短TTL让一致性问题自动收敛。缓存不是越多越好也不是越久越好容忍时长才是真正要花心思设计的参数。这个经验不一定适用于所有团队但至少值得你下次上线前重新审视一遍自己的TTL。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑