SpringCache实战:注解缓存、Redis多级缓存与穿透击穿雪崩治理 📅 发布时间:2026/9/9 20:27:25 👁 浏览次数: 上周排查一个订单查询接口的慢请求每次查询都直接打数据库表数据量已经上千万索引优化做到极致还是扛不住。后来给方法套上SpringCache接口RT从300ms降到了20ms以内。如果你也在为接口响应慢、数据库压力大发愁这篇文章应该能帮到你。SpringCache是Spring提供的一套缓存抽象层它不负责真正的数据存储而是把「缓存操作」这件事统一封装成注解和接口。底层可以换成Redis、Caffeine、Ehcache甚至本地ConcurrentHashMap业务代码几乎不用改。适合谁看呢正在写Spring Boot接口、天天被DB查询瓶颈虐、想用注解缓存又搞不清key怎么做、缓存穿透怎么处理的人这篇就是给你准备的。1. 缓存抽象的核心思路与选型1.1 SpringCache不存数据它只是「门面」很多新手会把SpringCache和Redis混为一谈其实它们是完全不同的两层东西。举个生活例子SpringCache就像插座上的USB口Redis、Caffeine这些才是真正的充电头。插座只定义了充电的规范具体怎么变换电流、怎么握手协议由插进去的充电头决定。SpringCache的核心只有三个角色CacheManager缓存管理器负责创建和管理各种Cache一个应用可以同时配置多个CacheManager对应不同的缓存实现。Cache真正对缓存数据的读写接口比如RedisCache、CaffeineCache封装了put、get、evict等方法。CacheManager与Cache的对应关系一个CacheManager管理多个Cache每个Cache通过name区分。平时我们写Cacheable(cacheNames order)本质上是让Spring根据cacheNames去对应的CacheManager里找一个叫order的Cache然后执行读、写、删操作。如果我们同时引入Redis和Caffeine就可以通过配置让不同的缓存名走不同CacheManager这就是后面要讲的多级缓存基础。要说清楚的是SpringCache本身不解决分布式缓存、缓存一致性、高可用这些问题它只负责把你从繁琐的样板代码里解放出来。就像JDBC定义了一套访问数据库的标准接口具体能不能抗住高并发、怎么优化SQL那是MySQL和连接池的事。1.2 为什么我会优先选SpringCache而不是自己写工具类早期我也习惯写一个RedisUtil里面封装setByKey、getByKey、delByKey然后业务代码里到处是if (cache.get ! null) ... else ...。改了几轮之后发现痛点非常明显缓存逻辑和业务逻辑混在一起方法体越来越长看一会就迷糊。切换缓存中间件时要改至少N个调用点牵一发动全身。忘记删缓存、key拼写不一致这类问题代码评审时很难查出来。SpringCache用声明式注解解决了这些问题。你只需要在一个方法上标注缓存动作Spring通过AOP动态代理在方法执行前查缓存、执行后写缓存业务类里干干净净。缓存的管理粒度达到了方法级别天然适合查询类接口。选型时我还对比过自己写AOP切面注解切面确实能做但缓存过期、空值、key生成、条件判断这些细节你都要自己实现很容易写出半吊子轮子。SpringCache是官方生态的一部分长期演进有保障踩坑也有大把社区案例。所以我的结论是单体应用、架构也没到需要自研中间件那一步直接用SpringCache是性价比最高的选择。2. 核心注解与Key生成策略拆解2.1 Cacheable / CachePut / CacheEvict 的适用场景SpringCache提供三个最基本的操作注解外加一个组合注解和总开关看起来简单但很多人用错。注解作用典型使用场景注意事项Cacheable查缓存没有则执行方法并写入查询接口、低频变更数据读取内部调用时不生效需通过代理CachePut不查缓存直接执行方法并更新缓存新增/更新后主动写缓存如果方法异常缓存不更新CacheEvict删除缓存删除、修改、失效操作可以通过beforeInvocation控制时机Caching同时组合多个缓存操作复杂写入场景不建议滥用会让逻辑难追踪CacheConfig类级别缓存配置统一指定cacheNames简化代码但隐式依赖类最常用的还是Cacheable。比如一个getOrderById方法Cacheable(cacheNames order, key #orderId) public Order getOrder(Long orderId) { return orderMapper.selectById(orderId); }第一次进来没有缓存Spring会执行这个方法把返回结果放到名为order的Cache里key是#orderId的实际值。第二次用同样的orderId请求方法体直接不走从缓存返回结果。CachePut适合那种「方法必须执行但执行完要把最新数据刷新到缓存」的场景。比如价格调整接口CachePut(cacheNames order, key #order.getId()) public Order updateOrderPrice(Order order) { orderMapper.updatePrice(order); return order; }这个注解不会绕过方法它保证缓存里的值一定是最新一次方法的返回值。要注意的是如果方法中途抛异常整个写入都会回滚缓存也不会被更新。CacheEvict是删缓存。为了避免脏数据更新数据时先删缓存下次查询再重建这种模式后面讲一致性时再细说。CacheEvict(cacheNames order, key #orderId) public void deleteOrder(Long orderId) { orderMapper.deleteById(orderId); }CacheEvict有一个容易忽略的参数allEntries true意思是删除整个缓存分区而不是单个key。当update操作影响多条数据、无法精确指定key时比如批量修改订单状态直接清空缓存分区可能是最简单粗暴的思路但要注意瞬时缓存雪崩的代价。2.2 Key生成你不得不注意的细节缓存没有命中时方法会执行然后结果存进缓存。这里key怎么算至关重要。如果key计算规则不对轻则缓存不生效重则取到脏数据。默认情况下SpringCache使用SimpleKeyGenerator生成key无参数方法SimpleKey.EMPTY一个参数直接以该参数作为key多个参数组合成一个SimpleKey问题在于如果方法有两个参数且其中一个参数意义不大比如分页查询getOrders(Long userId, int page)默认key会同时包含userId和page这样的话不同page之间的缓存是隔离的看起来没问题。但如果有一个参数是当前时间或者随机数会导致缓存永远不会命中。所以更多时候我们会显式指定key。显式key支持Spring表达式语言SpEL常用的写法有key #userId直接取参数值key #user.getId()取对象属性key #root.methodName取方法名key #userId : #type拼接多个参数key T(java.util.Objects).hash(#userId, #type)取哈希值我在项目里最常用的还是多参数拼接。比如按用户和时间段查订单Cacheable(cacheNames orderList, key #userId _ #startTime.toEpochDay()) public ListOrder listOrders(Long userId, LocalDate startTime, LocalDate endTime) { return orderMapper.selectByUserIdAndRange(userId, startTime, endTime); }注意endTime故意不放进key里因为实际业务中同一个时间段查询endTime即使不同命中的缓存数据大概率也是同一份的话可以这么优化。但严谨来说如果范围不同会导致结果不同就一定把所有影响结果的字段都加进key。不要为了追求命中率而丢掉正确性。另外如果方法接收一个DTO对象可以直接用key #dto.someField这种写法比key #dto.toString()要可控得多。3. 在Spring Boot中落地一套RedisCaffeine多级缓存3.1 环境准备与依赖先明确一下SpringCache本身不需要额外引包spring-context里已经带了。但在Spring Boot项目里我们通常还要引入具体缓存中间件的适配包。如果只用CaffeineMaven依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId /dependency如果要用Redis则继续加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency引入之后在启动类或者任意配置类上加上EnableCaching这一步很多人会漏掉。漏掉之后注解全都不生效而且不报错排查起来特别迷惑。spring-boot-starter-cache会自动配置一个CacheAutoConfiguration根据classpath里是否有Caffeine、Redis等决定注册哪种CacheManager。如果不做任何自定义默认会用一个ConcurrentMapCacheManager也就是本地ConcurrentHashMap这只能用来做单体环境下的临时缓存不能跨实例同步。3.2 缓存管理器配置与TTL默认的缓存管理器没有过期时间缓存会一直占内存这显然不行。尤其是Redis如果Key没有TTL日积月累会撑爆内存。我通常会在项目里配一个RedisCacheManager让每个缓存分区有自己的TTLConfiguration public class CacheConfig { Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration defaultConfig RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.json())) .disableCachingNullValues(); // 针对不同缓存名设置不同过期时间 MapString, RedisCacheConfiguration cacheConfigurations new HashMap(); cacheConfigurations.put(order, defaultConfig.entryTtl(Duration.ofMinutes(60))); cacheConfigurations.put(user, defaultConfig.entryTtl(Duration.ofHours(24))); cacheConfigurations.put(productList, defaultConfig.entryTtl(Duration.ofMinutes(5))); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(cacheConfigurations) .build(); } }这里的.serializeValuesWith(RedisSerializer.json())很关键后面将详细讲。.disableCachingNullValues()意味着不允许缓存null这样缓存的value永远是真正对象查询DB为null时会触发方法执行而不是记录null。这个策略有什么问题后面讲缓存穿透时再说。如果还要同时用Caffeine做本地一级缓存可以再定义另一个CacheManager然后在需要用的缓存分区上指定对应的manager。例如CacheManager getOrderCacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(order, user); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(Duration.ofMinutes(5))); cacheManager.setAllowNullValues(false); return cacheManager; }Cacheable注解可以指定cacheManagerCacheable(cacheNames order, cacheManager orderCacheManager, key #orderId)这样order相关的缓存就走Caffeine本地缓存。但要注意多级缓存的一致性问题更复杂。通常做法是配置两级CacheManager读的时候先查本地再查Redis写的时候同时失效两边。Spring官方没有提供开箱即用的“先查本地再查Redis”组合需要自己实现或使用第三方库。如果项目没有特别极致的性能要求我个人建议先只上Redis等压测确实本地缓存带来的收益大于复杂度再上Caffeine。3.3 序列化策略与常见坑RedisCacheManager默认使用的value序列化器是JdkSerializationRedisSerializer也就是JDK原生序列化。它会直接把你缓存的对象序列化成二进制字节流存在Redis里是一长串\xAC\xED...开头的东西不优雅而且对阅读和排查非常不友好。还有一个更隐蔽的问题JDK序列化要求对象实现Serializable接口一旦对象的内部结构变更旧缓存数据反序列化很容易抛异常。所以务必把value序列化器改成JSONRedisCacheConfiguration.defaultCacheConfig() .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.json()));RedisSerializer.json()实际是GenericJackson2JsonRedisSerializer它会把对象类型信息也写进JSON保证反序列化时能还原成正确的Class。缺点是JSON会带着class字段字节数稍大。如果担心流量和存储可以自行实现一个Jackson2JsonRedisSerializerObject去掉类型信息然后反序列化时指定具体类型。这个需要一些自定义代码但可控性更强。另一个坑是LocalDateTime、LocalDate这些Java 8时间类型。如果项目中使用了Jackson序列化默认情况下需要引入jackson-datatype-jsr310模块并注册JavaTimeModule。Spring Boot的RedisSerializer.json()默认的是GenericJackson2JsonRedisSerializer它内部会配置一个ObjectMapper大多数情况下已经包含了jsr310支持。但如果你的应用自己定制了ObjectMapper就可能在序列化LocalDateTime时出现InvalidDefinitionException。我的建议是给ObjectMapper做统一配置并加上objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);然后再把GenericJackson2JsonRedisSerializer换成自定义的实例这样可以确保缓存数据与项目其他接口的JSON风格一致。4. 缓存穿透、击穿、雪崩的治理实践4.1 SpringCache中的“三兄弟”现象用上SpringCache之后很多人以为高枕无忧了但线上该出问题还是出问题原因就是没对付好经典三兄弟穿透、击穿、雪崩。缓存穿透请求一个一定不存在的key比如查询一个不存在的订单号缓存里没有DB里也没有请求直接打到数据库如果大量这样的恶意请求数据库会被打傻。缓存击穿某个热点key刚好过期瞬间有大量线程同时去查这个key缓存没命中全部穿透到数据库。缓存雪崩同一时间大批量的key过期或者Redis实例宕机导致所有请求打到数据库DB压力瞬间爆掉。SpringCache原生不是为处理这些极端场景设计的但我们可以通过配置和代码策略来缓释。4.2 如何做空值缓存和Hot Key兜底对于缓存穿透最简单的方案是允许缓存null值。刚才在配置里我们用了.disableCachingNullValues()现在需要改掉RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(5)) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.json())) .enableCachingNullValues();这样当方法返回null时SpringCache会把一个空对象写到Redis里。注意Caffeine默认是允许null的但Redis的value如果要存null需要开启enableCachingNullValues。开启之后下次再查同一个不存在的key缓存能命中但拿到的是nullSpringCache会直接返回null而不会再执行方法从而挡住DB穿透。不过null缓存也是有代价的如果查询条件千奇百怪null key的数量巨大会占大量内存。比较稳妥的做法是同时在方法内部对可疑参数做前置校验比如订单ID明显非法就直接抛异常不要走查询。对于热点key的击穿常用手段是「互斥锁」和「逻辑过期」。SpringCache里虽然没有内置的锁但可以通过给方法加sync true来启用内置的缓存同步机制。Cacheable(cacheNames hotProduct, key #productId, sync true) public Product getProduct(Long productId) { return productMapper.selectById(productId); }sync true意味着SpringCache在缓存未命中时会加锁让同一个key只有一个线程去查DB其他线程等待结果。这是最简单有效的击穿控制手段代价是缓存未命中时会有短暂阻塞但比让所有请求都打DB要好太多了。雪崩的应对主要在全局TTL做随机化。比如设置TTL为20分钟 随机1到5分钟避免同一秒内大量key同时过期。SpringCache的RedisCacheConfiguration可以设置entryTtl但它是针对缓存分区全局的不支持每个key随机TTL。如果要做到key级别随机TTL一般要在缓存工具层封装一个方法在缓存value里带上真实过期时间查询时动态判断。这里就不展开了。4.3 缓存一致性与失效策略缓存一致性是分布式缓存里最让人头疼的问题。我先梳理一个事实SpringCache本身不保证缓存和数据库的强一致它只提供缓存操作的注解。真正的一致性方案要靠业务代码控制。常见的方案有两种先更新数据库再删除缓存。先删除缓存再更新数据库。第二种有一个经典的大坑如果两个并发线程线程A删除缓存后还没有更新数据库线程B查询发现缓存为空去DB查到的是旧数据并把旧数据写进缓存等线程A更新完数据库缓存里依然是线程B写入的旧数据出现永久性脏读。所以主流推荐的是「先更新数据库再删除缓存」。即使短暂的缓存旧数据在下次请求时会因为缓存被删而重新加载新数据最终一致。示例Transactional public Order updateOrder(Order order) { orderMapper.updateById(order); cacheManager.getCache(order).evict(order.getId()); return order; }如果使用CacheEvict要确保它在事务提交之后执行。默认情况下注解方法执行顺序是在方法返回前执行如果事务还没提交就删掉缓存而且删完后事务提交失败缓存已经被删了下次请求会查库加载最新数据其实反而可以接受。更稳妥的做法是使用Spring的TransactionalEventListener在事务提交后删除缓存TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT) public void handleOrderUpdate(OrderUpdateEvent event) { cacheManager.getCache(order).evict(event.getOrderId()); }这种方式能够避免事务内删除缓存后事务回滚却导致缓存丢失的问题。项目里我倾向直接用事件监听代码可读性也好不过要注意事件的调整逻辑。5. 实测踩坑与排查记录5.1 明明加了Cacheable缓存却不生效有一天同事跑过来问为什么加了注解Redis里一直看不到key排查思路依次是检查是否加了EnableCaching没有的话缓存肯定没启动这是最基础的一步。检查方法是否被调用了两次确认是不是代理失效。SpringCache基于Spring AOP只有通过Spring代理调用的方法才会被拦截。同一个类里写getOrder()方法内部直接this.getOrderFromDB()时Cacheable那种内部调用是不走代理的因此缓存不会被拦截。很多人会踩到这个坑。检查是否方法被private修饰。私有方法不可以通过代理方式拦截Spring不会为私有方法生成代理因此Cacheable写在private方法上没有效果。检查key是否每次都在变化。如果用于计算key的参数包含了一个随机数缓存当然永远不命中。检查缓存名的拼写、CacheManager是否配置正确。有一个排查技巧是在日志级别中开启Spring Cache的DEBUG这样能看到命中和未命中的具体日志。logging: level: org.springframework.cache: TRACE org.springframework.data.redis.cache: TRACE看到Cache operation failed或者No cache entry for key这类日志就能快速定位到问题。5.2 序列化报错之前有个同事用默认的JdkSerializationRedisSerializer缓存了一个数据结构较复杂的DTO结果上线第二天出现大量SerializationException。排查后是对象里嵌了一个EnumSetJDK序列化不支持才暴露出来的。我的建议是项目初始化时就统一把RedisCacheManager的序列化器改成JSON。如果项目已经跑了一段时间切换序列化器会导致已存在的旧key读取时反序列化报错。稳妥做法是变更缓存分区名称比如从order变成orderV2强制旧的key自然过期或者上线前先清空线上缓存。还有一次报了TypeDefinitionException: Cannot construct instance of java.time.LocalDateTime。这个是因为GenericJackson2JsonRedisSerializer本身的ObjectMapper没有注册JavaTimeModule。解决办法前面讲过了直接定制一个序列化器实例并注册到CacheManager。5.3 事务与缓存一起用的顺序问题CacheEvict还有两个重要参数beforeInvocation和afterInvocation。默认是afterInvocation即方法执行成功后才删除缓存。如果方法抛异常缓存不会被删除。如果业务逻辑需要「无论方法是否成功先清缓存」可以设置beforeInvocation true。这个场景比较少见但可以用来解决缓存里的旧数据在多线程并发下被重复读到的问题。注意CachePut与事务联用时容易产生脏数据。比如方法既有Transactional又有CachePut方法执行过程中业务逻辑还没提交事务但缓存已经被写入了新值。如果事务最终回滚缓存里就是一条不存在的「假数据」。为了避免这种问题不要在一个事务方法上直接使用CachePut而是把更新DB和写缓存拆成两个环节或者用TransactionalEventListener延迟写缓存。5.4 缓存监控与统计Spring Boot Actuator提供了cache端点可以查看应用中所有CacheManager、Cache名称和命中情况。management: endpoints: web: exposure: include: cache,health,info访问/actuator/cache能看到缓存分区列表/actuator/cache/{cacheName}能看到某个缓存的统计信息。不过Actuator自带的统计是比较概略的如果要精确到每次操作可以在Redis侧开慢日志。Redis自身提供了SLOWLOG命令可以用来查看执行较慢的Redis操作。SLOWLOG GET 5 SLOWLOG LEN如果缓存操作本身成为瓶颈还可以考虑在业务侧做本地缓存减少对Redis的网络往返。我当时压测一个查询接口从纯DB到Redis缓存QPS提升了近6倍从Redis单级缓存到RedisCaffeine多级QPS又提升了将近3倍。但多级缓存带来的缓存一致性、内存管理等复杂度也随之上升要看项目实际情况做权衡。根据我个人踩过这些坑之后的体会SpringCache学习曲线不长但它真正的难点都在「缓存和业务如何正确协同」上。你先要理解注解的执行时机、key怎么设计、序列化怎么配然后还要有穿透、击穿、雪崩、一致性这些预案。这篇文章里提到的习惯和方案都是我一步步从线上报警里总结出来的。大家在引入SpringCache时建议先小范围试水把基础配置和缓存治理思路跑通再铺开到核心链路会比一下子全部接入要稳得多。