
做了七八年后端开发缓存几乎是每天都要打交道的东西。一旦项目里的读接口多了流量稍微上来一点最先扛不住的一定是数据库。很多人一提缓存就想到Redis脑子里全是set、get这两个命令。但真正到了生产环境尤其是复杂key组装、多环境切换、多级缓存、缓存和DB一致性这些场景时直接用RedisTemplate散弹式写业务代码维护成本会迅速失控。Spring的Cache抽象配合Cacheable那一组常用注解再加上SpEL表达式来动态生成key和条件才是真正值得花时间搞明白的部分。今天就不绕弯子结合我实际踩过的坑把这三个东西从原理到实战拆开聊清楚。1. 为什么我建议你先理解Cache抽象再写注解很多培训班出身的同学上手Spring Cache特别快加一个Cacheable就能用运行也正常。但一旦遇到缓存不生效、缓存穿透、多数据源、Redis序列化报错这类问题就完全没有排查方向。根子在于你不理解Spring到底在“抽象”什么。先把这个地基打牢后面所有注解和SpEL都只是工具。1.1 从一段最原始的缓存代码说起最早的缓存逻辑长什么样大概是这样public OrderInfo getOrderById(String orderId) { String key order: orderId; Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return (OrderInfo) cached; } OrderInfo order orderMapper.selectByOrderId(orderId); if (order ! null) { redisTemplate.opsForValue().set(key, order, 30, TimeUnit.MINUTES); } return order; }这段代码的问题不是运行不了而是业务方法里混了大量非业务逻辑。每写一个需要缓存的方法你都要重复“查缓存、判断、查DB、写缓存”这一套模板代码。等接口多起来key规则不统一、TTL不统一、序列化方式不统一的问题全都会冒出来。更麻烦的是你很难在不改业务代码的前提下切换缓存组件——今天用Redis明天要换成本地Caffeine这个模板代码几乎得重写。Spring Cache抽象就是为了干掉这堆重复劳动。它把“缓存”这个动作提升为声明式能力你只需要在方法上加注解框架在调用方法之前自动去Cache里查查不到再执行方法体执行完后自动把结果放回Cache。业务代码里不再出现任何RedisTemplate的痕迹。1.2 Cache抽象到底抽象了什么要理解Spring Cache抽象就要抓住三个核心角色Cache一个缓存实例的抽象对应一个具体的缓存区域比如“用户缓存”“订单缓存”。它定义了对缓存的基本操作get、put、evict、clear。这就是一个通用的Map行为不关心底层是Redis还是ConcurrentHashMap。CacheManager管理所有Cache实例的工厂。它负责根据缓存名称创建并返回对应的Cache。一个应用里可以同时配置多个CacheManager比如本地缓存一个、Redis缓存一个然后用Cacheable(cacheManager ...)指定使用哪一个。注解与切面Cacheable、CacheEvict、CachePut这些注解只是声明真正执行逻辑的是Spring AOP拦截器。拦截器拿到注解上的value、key、condition这些属性后通过CacheManager拿到Cache实例再执行缓存读写。你可以把整个流程理解成一个“门卫”方法被调用时门卫先看这个方法的缓存key在不在Cache里在就直接返回不在就放你进方法执行执行完再把返回值存进Cache。门卫后面到底是Redis还是内存业务代码完全不需要关心。1.3 缓存抽象解决的真实痛点这套抽象最有价值的一点是让缓存策略与业务方法解耦。说个最常见的变化某天你发现某个缓存命中率太低想从Redis换成Caffeine本地缓存或者想改成二级缓存。如果之前用的是原生RedisTemplate这个改造会涉及几十个方法但如果用的Spring Cache抽象你只需要调整CacheManager的Bean配置注解代码一行不动。另一个痛点是key统一管理。在没有抽象之前订单缓存的key可能是order: orderId另一个同学写的用户缓存key可能是user_ userId分隔符、前缀风格完全随缘。用注解加SpEL之后你可以在配置层面统一设计前缀规范比如约定所有缓存key都以业务域:实体:字段格式生成后续排查和清理都方便得多。2. 四个核心注解的语义拆解与实战姿势注解本身不难记难的是搞清楚它们在什么时机执行、返回值如何影响缓存、异常怎么处理。很多人把Cacheable和CachePut当成同一个东西用结果缓存要么永远不更新要么方法每次都被执行性能和一致性双双崩掉。下面逐个拆。2.1 Cacheable读缓存的标准姿势Cacheable是使用频率最高的注解它表达的含义是“如果缓存里有就直接用没有就执行方法并把结果写入缓存”。Cacheable(value userCache, key user:id: #userId) public User getUserById(String userId) { return userMapper.selectByUserId(userId); }这里的value或cacheNames指定缓存区域key用SpEL表达式动态生成。不写key时Spring默认用方法参数生成key但如果参数有多个默认KeyGenerator的规则不直观我建议所有关键地方都显式写key。有几个细节容易踩坑方法内部自调用不生效。AOP代理只对外部调用生效同类内部this.getUserById()绕过了代理注解完全失效。解决办法是拆到另一个Bean或者自己注入代理对象但最推荐的是把缓存逻辑放到独立Service层。Cacheable的缓存写入发生在目标方法返回之后如果方法内部抛异常不会写缓存。如果目标方法返回null默认情况下缓存不会写入因为很多底层缓存不允许存空值。这个后面讲Redis配置时会展开。sync true可以开启底层缓存的同步锁让同一个key同时只有一个线程去执行方法是解决缓存击穿的一个重要手段。但要注意本地缓存和Redis对sync的实现机制不同RedisCacheManager底层用的是Redis分布式锁并发场景下会有额外网络开销不过通常可以接受。2.2 CacheEvict删缓存的三类选择缓存删除比写入更需要设计感。CacheEvict用在删除数据或更新数据后清理缓存。最简单的用法是CacheEvict(value userCache, key user:id: #userId) public void deleteUser(String userId) { userMapper.deleteByUserId(userId); }这个注解有两个关键属性allEntries true清空整个缓存区域而不是只删一个key。适合“批量修改导致大量key失效”的场景。但清空Redis缓存会对同一区域的所有key做批量删除如果key很多会有性能损耗而且生产环境直接清空某类缓存往往意味着缓存命中率瞬间归零要谨慎使用。beforeInvocation true默认情况下CacheEvict在目标方法执行完之后才清缓存。如果方法抛异常缓存不会被清除。设置beforeInvocation true可以在方法执行前先删缓存保证即使方法失败旧缓存也被清理掉了。这个属性在“更新DB但不确定一定能成功”的场景非常关键。实战中我更推荐一个模式更新数据库时不要把旧数据改写成新数据塞进缓存而是直接删除缓存等下一次读请求再回源加载。原因很简单删缓存是一个幂等操作而写缓存需要保证数据正确性和原子性一旦写缓存和更新DB之间出现并发很容易留下脏数据。2.3 CachePut写缓存与更新陷阱CachePut的作用是“方法总是执行然后把返回值写入缓存”。它和Cacheable最大的区别是Cacheable可能跳过方法执行而CachePut无论如何都会执行方法体。CachePut(value userCache, key user:id: #user.getUserId()) public User saveUser(User user) { userMapper.updateByPrimaryKey(user); return user; }很多人会在更新接口上用CachePut想着“更新完顺手把新值更新到缓存省一次读回源”。这个想法本身没错但有两个隐患CachePut里的key基于入参生成如果入参里没有完整的缓存标识或者方法返回值和缓存key不一致就会导致缓存里写入了一个对不上的数据。并发更新时两个线程同时执行更新方法返回顺序不确定最终缓存里写入的值可能是旧的那个。这就是典型的“后写覆盖先写”问题。相比之下直接删除缓存再等回源天然规避了这个并发覆盖问题。所以我的建议是对于读多写少的业务更新方法上优先使用CacheEvict删除而不是CachePut写入。CachePut更多用在那些“方法返回值必然代表最新状态、且写入过程可控”的场景。2.4 Caching和CacheConfig组合规则怎么落地有些场景一个注解不够用。比如一个更新操作需要删除两个不同区域的缓存同时还要更新第三个缓存这时可以用Caching把它们组合起来Caching( evict { CacheEvict(value orderCache, key order:id: #orderId), CacheEvict(value userCache, key user:id: #userId, beforeInvocation true) }, put { CachePut(value statCache, key total:order: #userId) } ) public Order updateOrder(String orderId, String userId) { ... }Caching只是聚合语义仍然是各自独立执行。这个注解最大的价值是让你不要在同一个方法上重复写多个同类型注解——Java里同注解本来就不能直接重复标注组合也是一种变通。CacheConfig是类级别的共享配置。它可以把cacheNames、keyGenerator、cacheManager这些公共属性提到类上方法注解里就不必重复写CacheConfig(cacheNames productCache, cacheManager redisCacheManager) Service public class ProductService { Cacheable(key product:id: #productId) public Product getById(String productId) { ... } }注意CacheConfig只是默认值方法上的显式属性会覆盖类级配置。别在这上面搞些花里胡哨的继承逻辑保持最简单直白的对应关系最好。3. SpEL表达式缓存key和条件的正确打开方式SpELSpring Expression Language是Spring里用来写表达式的一套语言玩得转它你的缓存key和条件控制会非常灵活。很多人只会#参数名拼个key遇到复杂对象、组合条件就卡住了。3.1 注解里SpEL的表达上下文在Cacheable、CacheEvict、CachePut里SpEL能访问的对象有一组内置根对象。搞懂这些根对象就知道表达式能写什么。属性名访问方式含义methodName#root.methodName当前被调用方法名method#root.method.name当前方法对象可以用.name拿方法名target#root.target当前目标对象实例targetClass#root.targetClass目标对象的Classargs#root.args方法参数数组caches#root.caches当前方法上命中的缓存数组方法参数名#userId、#user.id直接按参数名访问方法入参返回结果#result仅在Cacheable的unless和CachePut的condition/unless中可用注意方法参数名能否被识别取决于编译时的-parameters参数。如果IDE或构建工具没开启参数名保留Spring拿不到参数名这时必须以#args[0]、#args[1]的方式访问。我建议在Maven或Gradle里显式开启-parameters否则代码在本地能跑换个环境就报SpEL解析失败。3.2 高频key表达式示例代码写多了之后你会发现常用的key规律就那几种。我实际项目里用过的单参数固定前缀Cacheable(value userCache, key user:id: #userId)多个参数组合Cacheable(value pageCache, key page:list: #pageNum : #pageSize)对象属性作为keyCacheable(value orderCache, key order:userId: #query.getUserId())带条件拼接Cacheable(value productCache, key #type ! null ? product:type: #type : product:all)使用T表达式调用静态方法Cacheable(value skuCache, key sku:code: T(java.lang.String).valueOf(#code).toUpperCase())最后一招用静态方法处理字符串很实用能统一缓存key的格式比如把订单号变成大写、去空格。但别滥用表达式复杂度上去了排查问题时的脑负担也会增加。还有一个安全红线缓存key不要直接拼用户输入的大字符串。一旦某个参数值超长Redis key会占内存也容易被攻击。建议在key里只保留id、短编码这类可控字段。3.3 condition与unless的边界关系condition和unless都用来控制缓存动作但时机完全不同。很多人把这两个搞混导致缓存结果不符合预期。condition是在方法调用之前基于入参判断决定是否使用缓存。如果condition不成立则既不查缓存也不写缓存相当于注解完全失效。unless是在方法调用之后基于返回结果判断决定是否不写入缓存。它只影响写缓存不影响读取。一个典型例子低于100元的商品不走缓存。Cacheable(value productCache, key product:id: #productId, condition #price 100, unless #result null) public Product getProduct(String productId, BigDecimal price) { return productService.loadById(productId); }这里用condition过滤低价商品用unless防止结果为null时写入缓存。要注意unless里访问#result的前提是方法正常返回如果方法抛异常unless不会被执行。日常中还有个常用组合缓存空结果。Redis默认不能存null那么你可以配置allowNullValues支持空值缓存或者用unless #result null让它直接不缓存但这样空请求每次都会打DB。更好的方案是在缓存里存一个特定空对象或空标记后面讲穿透时会展开。3.4 自定义KeyGenerator的补充方案如果SpEL表达式写累了或者key规则要全局统一可以自定义KeyGenerator。比如默认的key生成方式是基于参数生成但我们想要“类名方法名参数”的唯一格式Component(customKeyGenerator) public class CustomKeyGenerator implements KeyGenerator { Override public Object generate(Object target, Method method, Object... params) { StringBuilder sb new StringBuilder(); sb.append(target.getClass().getSimpleName()) .append(:) .append(method.getName()); for (Object param : params) { sb.append(:).append(param); } return sb.toString(); } }然后在注解里指定Cacheable(value userCache, keyGenerator customKeyGenerator)这里有个要注意的点一旦指定了keyGenerator就不能同时指定key两者冲突。还有KeyGenerator生成的结果会被当作最终的缓存key如果你的缓存管理器需要对key做序列化请确保生成对象可序列化。我的经验是简单场景用SpEL直接写复杂场景或要全局统一规则时用KeyGenerator。不要每个方法一套key写法项目半年后你再回来看自己都想骂自己。4. CacheManager与集成Redis时需要避开的坑注解只是皮囊真正干活的是底层CacheManager。Spring默认提供ConcurrentMapCacheManager但那只适合本地调试。生产环境里用到最多的还是RedisCacheManager。切换和配置过程中坑非常多。4.1 内存缓存到Redis缓存的切换关键默认的ConcurrentMapCacheManager用本地Map存值应用重启缓存就没了多实例部署时每个节点缓存还不一致。要切换到Redis不是只换个CacheManagerBean那么简单你需要理解RedisCacheManager的构建参数。Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration defaultConfig RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations( Map.of( userCache, RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(2)), orderCache, RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) ) ) .build(); }这个配置里.entryTtl设置默认过期时间.disableCachingNullValues()表示默认不允许缓存空值。如果你用Cacheable缓存了nullRedis会抛异常。要支持空值必须改为.enableCachingNullValues()但这不是无代价的Redis里会存一个“空值”标记取出时要特殊处理且空值缓存时间过长会造成内存浪费。还有一个容易忽略的点withInitialCacheConfigurations里如果对userCache配置了单独的TTL那么该类缓存的所有注解都会走这个配置方法注解里无法再单独指定TTL。所以TTL策略最好在设计阶段就明确而不是每个方法各定各的。4.2 序列化导致的反序列化异常Spring Cache默认的Redis序列化方式可能是JDK序列化或JDK Serializer存到Redis里是一堆乱码字节。如果直接在注解缓存里放一个实体类再用字符串工具去redis-cli查看你基本看不懂里面是什么。更重要的是一旦实体类结构发生字段变更JDK反序列化直接抛异常缓存全部打挂。建议使用GenericJackson2JsonRedisSerializer。它可以存入对象类型信息取出时自动转换。但这个序列化器也有坑如果实体类里包含LocalDateTime、泛型对象可能序列化输出带class信息反序列化时可能因为类型不匹配报错。更稳的方式是定义统一的DTO而不是直接把DO丢到缓存里。比如把数据库实体和缓存对象隔离DO变更字段不会影响线上缓存结构。我踩过一次把某个DO直接缓存后来DO里加了一个字段老数据反序列化时因为缺少新字段直接抛异常整个查询接口不可用。从那以后我坚持缓存对象单独定义字段用基本类型和String不塞复杂嵌套结构。4.3 缓存穿透、击穿、雪崩的注解侧应对理论不多说直接讲注解层面怎么应对缓存穿透查询一个不存在的id缓存和DB都没有每次请求都穿透到数据库。最常见做法是缓存空值。使用Spring Cache时要么enableCachingNullValues()要么在unless #result null的基础上手动写一个空对象缓存。我更喜欢后者因为能精确控制空值缓存的TTL避免空值长时间堆积。缓存击穿热点key过期瞬间大量并发请求同时回源。Cacheable(sync true)是最直接的解法。它保证同一key只有一个线程执行方法其他线程等待结果。不过要注意本地CacheManager对sync true的支持有限Redis下才可靠。缓存雪崩大量key在同一时间过期。注解本身解决不了雪崩需要你在配置层设计TTL随机化。比如上面RedisCacheManager配置里不为每个缓存区域设置固定TTL而是加一个随机偏移量比如基础TTL 30分钟再加0到300秒的随机值。还有一个注解层容易被忽略的细节Cacheable方法执行时间长一旦缓存失效所有线程全去执行DB查询如果没有sync true就可能把DB打垮。所以对热点接口建议宁可让一个线程重建缓存也不要让所有线程同时回源。4.4 事务与缓存一致性的实操经验Spring Cache注解默认不感知事务。最常见的问题一个事务方法里先更新了DB又用CacheEvict清缓存但事务还没提交此时来了一个新请求正好读到旧缓存或者清缓存后其他线程读到DB的旧值。更麻烦的是事务方法里CacheEvict执行了删除但同一事务稍后回滚DB没变缓存已经被删了下次读回源拿到旧数据又会把旧数据写进缓存造成数据一致性问题。我推荐的方案是事务提交后清缓存。通过TransactionSynchronizationManager注册同步回调在afterCommit阶段再执行缓存删除public void updateOrder(String orderId) { // 业务数据库更新 orderMapper.updateByOrderId(orderId); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { cacheService.evict(orderCache, order:id: orderId); } }); }注意如果方法不是事务方法TransactionSynchronizationManager.getSynchronization()为null直接调用注册会空指针需要先判断当前是否存在事务同步。另外如果你的项目里同时用了事务切面和缓存切面一定要理清两个切面的顺序。缓存切面在事务外层时CacheEvict可能在事务提交前执行缓存切面在事务内层时上面手动注册同步的方式更可靠。5. 从能用走向好用缓存方案的工程化清单最后这部分不是讲具体的API而是讲讲把缓存体系沉淀成工程资产的经验。很多人注解用得很溜但线上缓存策略一塌糊涂出问题只能靠重启和清缓存来“续命”。5.1 缓存key和TTL的规划设计我在团队里推的是“缓存key三段式规范”业务域:实体:唯一标识。比如user:info:123456、order:detail:ORDER20250101。不要用没有业务含义的a:123这种key也不要只写123。前缀越清晰排查问题时用工具按前缀批量扫描越方便。TTL规划要区分数据性质数据特征建议TTL典型例子高频访问、变更极少2小时以上商品规格、系统配置高频访问、可能偶尔变更10-30分钟用户基础信息与DB写入强相关随业务事件清空而不是等TTL订单详情统计类结果5-15分钟首页报表、聚合数据TTL不要整齐划一加随机偏移来防雪崩。还要记得空值缓存和正常缓存的TTL分开设置空值缓存建议1~2分钟就过期。5.2 监控、日志与排查思路Spring Cache注解不像原生RedisTemplate可以很方便地埋点统计。你需要自己做一层切面或者直接利用Redis的监控指标。最简单的起手式是在Redis客户端统计每个缓存区域的hit和miss结合命中率判断key策略是否合理。对慢查询做日志Cacheable方法本身执行时间超过阈值时记录回源SQL和参数。上线前用测试脚本压一下热点key的并发回源验证sync true是否真的生效。有一个排查小技巧给缓存key统一加一个可辨识的版本号比如user:v2:info:123456。这样发新版逻辑时不会读旧版本缓存省得手动清理。等运行稳定后再定期清理旧版本key。5.3 常见问题速查表现象可能原因处理方案注解方法没有被缓存方法内部this自调用绕过代理拆分Bean或注入代理对象Redis反序列化异常JDK序列化或DTO结构变更换GenericJackson序列化器缓存DTO不与DO耦合缓存了null导致Redis异常默认禁用空值缓存开启空值缓存或手动缓存空标记key全部一样导致缓存互串SpEL写错或没带参数显式写key开启-parameters更新DB后缓存没清事务尚未提交就清缓存事务afterCommit后再evict缓存穿透DB被打垮空结果未缓存空值短TTL缓存 布隆过滤器热点key失效瞬间拖垮DB无锁回源Cacheable(sync true)多实例缓存不一致本地缓存未同步改用Redis或组件间做消息同步注解不生效但没报错切面没扫描到配置确认 EnableCaching 在当前配置类被加载这张表基本覆盖了我这两年在实际项目里见到的高频问题。遇到缓存相关故障先对照这个清单排查能省掉很多不必要的看代码时间。最后再分享一个我自己的小习惯每个业务模块的缓存key我都会抽到一个单独的常量类里集中管理比如CacheKeys.USER_INFO、CacheKeys.ORDER_DETAIL生成key的方法也统一封装。虽然用SpEL拼字符串看起来很灵活但分散在几十个方法里的魔法字符串迟早会变成事故现场。把key规则收拢到一处以后全局改格式、加版本号都只动一个文件。这个习惯谈不上高深但长期坚持下来缓存维护的幸福感会提升非常多。