Spring Cache:代理调用、缓存键与提交后的更新
同一个商品编号,在两个租户下可能对应不同价格;同一个查询方法,外部调用可以命中缓存,类内部调用却可能再次查询数据库。Spring Cache 把缓存操作接到方法调用前后,缓存结果是否正确,取决于调用入口、键的构成和实际使用的存储实现。
方法结果怎样进入缓存
cacheName、key 和 value 分别决定什么
调用 products.find("north", 42)
├─ CacheResolver / CacheManager:选择缓存实现
├─ cacheName:products-v1,定位一组缓存
├─ key:north + 42,定位这一组中的条目
└─ value:该租户下商品 42 的查询结果
Caffeine
→ 当前 JVM 中的对象
RedisCache
→ 键转换与序列化
→ products-v1:: + 转换后的 key
→ Redis 中的字节与剩余 TTLCacheManager 管理命名缓存,Cache 提供查找、写入和移除接口;业务方法负责取得原始结果。Spring Cache 本身不提供 Redis 服务器,也不替数据库决定数据何时发生变化。它的作用是让应用用统一的调用模型接入不同缓存实现。缓存抽象说明给出了这些接口的分工。
同名缓存放在两个 CacheManager 中,可以指向完全不同的位置。接入多个 Provider 时,应通过 cacheManager 或 CacheResolver 明确选择,避免测试使用本地缓存、生产意外使用另一套默认实现。
多个 cacheNames 也不能直接当作完整的 L1/L2 管理方案。跨层过期、更新通知、断线恢复和异步查找仍需要设计;两级缓存的具体读写顺序见 容量与多级缓存。
缓存注解接在 Spring 代理上
默认的 proxy 模式下,调用者先进入代理,再进入实际对象。命中时,代理可以直接返回缓存值;未命中时才调用方法。
对象由 new 创建、方法通过 this 调用,都会绕过上述代理入口。把 public 方法加上 @Cacheable 后再从同一个类调用它,并不会使这次内部调用重新经过代理。
需要复用缓存查询时,可以把查询职责拆到另一个 Bean,由调用方注入使用。这样调用关系本身就经过代理,业务代码也不需要取得 currentProxy。JDK 接口代理与 CGLIB 子类代理的限制不同;使用子类代理时还要注意 final 类、final 方法和 private 方法不能按普通可覆盖方法织入增强。详见 Spring 代理机制。
用输入条件定义结果的可复用范围
默认键生成规则适用于参数能够稳定表达查询条件的情况:无参数使用空键,单参数使用该参数,多参数组合为 SimpleKey。对象的 equals 和 hashCode 因而会影响查找结果。
@Cacheable(cacheNames = "products-v1")
public Product find(String tenant, long id) {
return repository.find(tenant, id);
}上面的两个参数都进入默认键。如果改为 key="#id",租户会被排除。同一编号在 north 查询后,south 可能得到 north 的缓存结果。这是数据隔离错误,应在缓存层之前确认当前主体有权访问该租户,并使租户进入键。
| 影响结果的条件 | 键或缓存设计 |
|---|---|
| 租户、语言、区域价格 | 纳入稳定键;不要只保留资源 ID |
| 用户权限影响字段可见性 | 缓存共享原始数据后另做授权投影,或纳入明确的可见性维度 |
| 查询分页、排序、筛选 | 纳入查询条件;控制组合数量,避免无限增长 |
| 返回对象格式升级 | 使用新格式前缀或新的 cacheName |
| 与结果无关的 traceId | 不进入键,避免每次请求都生成新条目 |
不同方法如果共享 cacheName,也要考虑参数相同而返回含义不同的情况。默认键不自动包含方法名。跨语言或长期存储的 Redis 键,宜使用有版本、可解释的字符串编码;分隔符需要转义或长度编码,不能靠任意字符串相连来保证唯一。
默认规则与自定义 KeyGenerator、SpEL 的细节见 缓存注解。
按读写方式选择注解与配置
查找、更新和删除的调用顺序
| 操作 | 是否执行目标方法 | 缓存动作 |
|---|---|---|
| @Cacheable | 命中且条件适用时可跳过 | 未命中后取得结果,再按规则保存 |
| @CachePut | 执行 | 用方法结果更新条目 |
| @CacheEvict | 执行 | 默认在方法成功完成后删除;可改为调用前删除 |
| @Caching | 随内部操作组合 | 将多个查找、写入或删除声明放在一起 |
| @CacheConfig | 不触发操作 | 在类级共享缓存名称等设置 |
@CachePut 适合方法每次都需要执行、同时要刷新某个条目的情况。不要把它和 @Cacheable 随意放在同一方法上:一个要求执行,另一个可能跳过,读者和维护者很难确定期望顺序。若确实组合,需证明两组条件互斥。
@CacheEvict 的 beforeInvocation=true 会在调用目标方法之前执行移除。方法随后失败,被移除的条目仍然会消失。默认调用后删除适用于成功后才失效的情况,但“方法返回”和“数据库事务提交”还可能处在不同阶段,不能只从注解出现的位置判断提交顺序。
allEntries=true 作用于整个命名缓存;大缓存的扫描和删除成本随条目增加。接口上一个轻量注解,可能触发服务端大量命令。
condition 在调用前判断,unless 根据结果决定
@Cacheable(cacheNames = "rules",
key = "#id",
condition = "#id > 0",
unless = "#result == null")
public String find(long id) {
return repository.findTitle(id);
}condition 为 false 时,按普通方法执行,不进行这次缓存复用;unless 在已经取得结果后决定是否放入缓存。因此不存在的结果被 unless 排除后,下一次仍会回源。
空结果是否应该缓存,需要结合业务频率和新数据出现后的可见时间选择。商品确实不存在可以使用短期负缓存;数据库超时应保留为异常,避免把故障转换为“商品不存在”。Spring 对 Optional 会处理其内部业务值,表达式中的 #result 也按业务对象理解;Provider 是否允许 null 需要单独配置。
sync、异步返回与加载资源
sync=true 把同键加载协调交给 Provider。是否跨进程协调、锁定范围多大以及等待多久,都由实际实现决定。Caffeine 的同键加载发生在单个 JVM;RedisCacheWriter 的缓存锁配置也不能被理解为数据库事务锁。
异步返回值同样需要检查 Provider 支持。Spring 可以在 CompletableFuture 完成后缓存结果;Mono 对应单个结果,Flux 的注解适配可能把完成后的元素集合收集为 List。对无限流或很大的流使用这种方式,会改变内存和完成条件。需要异步查找时,CaffeineCacheManager 可开启 asyncCacheMode;具体限制见前面的缓存注解说明。
加载逻辑仍要设置回源超时和并发预算。一次方法调用被其他线程共同等待后,慢查询的影响也会被共同承担。
Boot 自动配置与显式 CacheManager
Spring Boot 根据依赖与已有 Bean 选择缓存配置。项目引入多个缓存库后,应检查实际创建的 CacheManager 类型,必要时使用 spring.cache.type 或直接提供配置 Bean。相关属性、Provider 条件和自定义入口见 Boot 缓存配置。
实验固定 Boot 4.1.1 管理的 Spring Framework 7.0.9、Spring Data Redis 4.1.1、Caffeine 3.2.4 和 Lettuce 7.5.2.RELEASE。缓存注解实验使用真实 Spring ApplicationContext 显式注册 Bean,不依赖自动配置猜测。
RedisCacheManager 的显式配置可以同时确定 TTL、值编码、清理批次和写入完成时机:
var defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(30))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(RedisSerializer.string()))
.disableCachingNullValues();
var writer = RedisCacheWriter.create(connectionFactory, options -> options
.batchStrategy(BatchStrategies.scan(10))
.immediateWrites(true));
var manager = RedisCacheManager.builder(writer)
.cacheDefaults(defaults)
.build();这个片段对应工程中的字符串实验。返回复杂对象时,应选择固定的字段结构与兼容策略;更换序列化器后,旧字节可能无法解析,需要考虑新前缀、双读迁移或受控失效。不能只替换 Java 类名就假设 Redis 中的数据已经升级。
Spring Data Redis 默认配置包含缓存名前缀和默认值序列化方式,默认 TTL 也需要核对。显式配置让应用采用的格式可见。完整选项见 Redis Cache 配置。
TTL、TTI 与清理完成时机
TTL 控制写入后的存活时间。开启 enableTimeToIdle 后,Spring Data Redis 可以通过 GETEX 在读取时更新过期时间,实现近似的闲置过期行为。这个效果要求读取路径配合;另一个服务直接 GET 同一个键,不会自动续期。GETEX说明了带过期参数的读取操作。
清理也有完成时间。在 Spring Data Redis 4.1.1 与支持响应式连接的 Lettuce 组合下,默认 Writer 可以异步完成部分写入和移除操作。clear() 返回后,紧随其后的读取仍可能看到条目;Cache 接口允许这种延迟语义。
如果业务需要调用返回前完成当前删除,可以使用对应的即时操作,例如 invalidate(),或为 Writer 显式配置 immediateWrites(true)。这是调用完成约定,不会阻止另一个写入者随后重新填入同一键。实现分别见 RedisCache 4.1.1、Writer 配置入口和 默认 Writer 实现。
SCAN 批量清理可以减少单次全键枚举的阻塞,但整个清理过程仍有网络往返和删除开销。应保留缓存名前缀,避免一组缓存的清空波及其他业务键。
用真实调用验证代理、租户与事务
准备并执行工程
使用 Linux Bash、Docker 和 unzip,操作者为具有 Docker 权限的普通用户。下载 缓存实验工程,按 本地缓存准备步骤取得 PROJECT_DIR、LAB_DIR,并在普通构建网络完成依赖下载。再按 Redis 隔离网络步骤启动 ca11-redis,确认 PING 返回 PONG。
Redis 为无宿主端口发布、无认证、无持久化的一次性实验实例;不连接生产。应用测试在普通 UID 下运行,H2 事务使用进程内临时数据库。
test "$(id -u)" -ne 0 || exit 1
test -f "$PROJECT_DIR/pom.xml" || exit 1
test -d "$LAB_DIR/m2" || exit 1
docker exec ca11-redis redis-cli PING
docker run --rm --name ca11-spring-build \
--network ca11-lab --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-e REDIS_URI=redis://ca11-redis:6379 \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -o -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=SpringCacheTest test预期 8 个测试、0 失败、0 错误、0 跳过和 BUILD SUCCESS。离线依赖缺失时,先回到普通网络完成依赖下载,再恢复 internal 网络执行;Redis 不可达则检查容器状态和 ca11-lab 网络,不能把集成测试改成跳过后继续判断结果。
切换 Java 17 时,先取得对应的 Maven 镜像,再将镜像标签改成 maven:3.9.12-eclipse-temurin-17,执行相同测试。工程以 Java 17 为编译目标。
相同方法调用为何产生不同加载次数
测试从 ApplicationContext 取得 Products Bean,依次执行两次外部 safe 调用和两次 self 调用。加载计数来自目标方法实际执行时的 AtomicInteger:
externalCalls=2 externalLoads=1 afterTwoSelfCallsLoads=3两次外部调用中只有一次进入加载方法。随后两次内部调用各执行一次,累计变成 3。
租户测试使用两个独立命名缓存,一组故意只用 id,另一组使用 tenant 与 id:
unsafeSouth=north:42 safeSouth=south:42unsafeSouth 的值就是错误结果。测试明确断言这个反例,再验证包含租户的键返回正确数据。实际项目中的回归测试应要求跨租户永不串值,不应把错误配置保留到业务服务。
条件、更新与失败删除
conditionFalseLoads=2 unlessNullLoads=2 hitSkipsMethod=true putAlwaysInvokes=true
afterInvocationFailureRetains=true beforeInvocationFailureRemoves=true第一行对应四段实际调用:条件不满足重复执行,null 结果不保存,普通结果第二次命中,CachePut 仍调用方法并刷新缓存。第二行在删除方法中主动抛出异常,区分默认调用后删除与调用前删除。
出现不符结果时,先检查拿到的是不是 Spring Bean、方法调用是否越过代理,以及 key 的实际类型。数字 1、字符串 "1" 和另一个封装键,在本地缓存中可能是不同对象;看到日志中的文字一样,还需要检查 equals 和序列化后的字节。
数据库提交之后才移除条目
TransactionAwareCacheDecorator 可以把普通 put、evict、clear 延迟到 Spring 事务成功提交后的回调。事务回滚时不执行这些待处理动作。即时操作 putIfAbsent、evictIfPresent 和 invalidate 则直接委托到底层缓存,不能等待提交后再返回原本需要的即时结果。精确行为见 事务感知缓存实现。
实验创建真实 H2 表,使用 DataSourceTransactionManager 和 TransactionTemplate 更新 revision,并观察底层 Caffeine 条目:
事务开始:数据库 revision=1,缓存=v1
→ UPDATE revision=2
→ aware.evict("item")
→ 事务内查看底层缓存:仍为 v1
分支一:rollback
→ 数据库仍为 1,缓存仍为 v1
分支二:commit
→ 数据库变为 2,缓存条目被移除实际输出:
rollbackDatabase=1 rollbackCache=v1 committedDatabase=2 committedCache=absent
rolledBackPutIfAbsent=kept rolledBackEvictIfPresent=absent最后一行来自另一个回滚事务:即时插入的条目保留,即时删除的条目没有恢复。该实验使用 H2 JDBC 的真实提交和回滚,数据库行为入口见 H2 事务说明。
配置 RedisCacheManager.transactionAware() 可采用同类提交后协调。数据库和 Redis 仍是两个系统:SQL 已提交后 Redis 删除失败,无法通过当前缓存回调撤回已完成的 SQL。跨进程补偿和旧读回填的时序见 数据库与缓存一致性。
Redis 中的真实字节、TTL 与异步删除
字符串序列化实验使用随机测试前缀,写入后由独立 Lettuce 连接读取实际值与 TTL。随后清理该缓存,验证另一个业务前缀的键仍存在:
redisCacheWireValue=v1 ttlPresent=true scopedClearPreservedOtherKey=true这部分配置 immediateWrites(true),因此测试在操作返回后检查服务端结果。另一项测试专门保留默认 Writer,调用 clear 后记录第一次 GET,再在两秒内等待删除完成。可能看到:
defaultClearImmediate=v1 eventual=absent
invalidateImmediate=absent第一行的即时值也允许为 null:异步删除可能已经完成,即时观察取决于调度。测试要求在限时内最终删除;随后重新写入值并调用 invalidate,验证返回后立即读取为空。
测试结束会删除自己创建的随机键,并关闭连接和 Spring 容器;不会执行 FLUSHALL。若整个缓存组实验已结束,确认 ca11-redis 和 ca11-lab 是自己创建的资源后,按 Redis 篇的清理步骤停止它们。
从异常现象定位到具体环节
注解没有命中时检查调用与键
| 观察 | 优先检查 | 下一步 |
|---|---|---|
| 每次都进入目标方法 | Bean 是否受 Spring 管理、是否 self-invocation | 从另一个 Bean 调用同一方法并比较加载计数 |
| 某些参数组合命中错误结果 | tenant、语言、分页等是否遗漏 | 用两组产生不同结果的参数做对照测试 |
| 方法返回 null 后一直回源 | unless 与 Provider 的 null 策略 | 决定是否需要短期负缓存,保留源异常 |
| 一个环境正常,另一个无条目 | 实际 CacheManager 和缓存名称 | 读取启动配置,检查依赖和显式 Provider |
| 更新后仍短暂读到旧值 | 删除完成方式、事务阶段、并发回填 | 分别观察提交点、删除回复和之后的写入 |
缓存命中意味着目标方法可能被跳过。方法中如果还做审计计数、发送事件或必须每次执行的权限检查,这些副作用也可能一起被跳过。应把每次请求必做的操作与可缓存查询分开。
Redis 失败要区分读取、写入和失效
默认错误处理会把到达拦截器的缓存操作异常传播给调用方,见 SimpleCacheErrorHandler。异步操作在调用返回后发生的错误,还需由 Provider 的异步处理路径观察。自定义 CacheErrorHandler 可以改变拦截器收到异常后的行为,但读失败后回源、写失败后返回结果、失效失败后保留旧值,造成的业务影响不同。不要用一个空 catch 覆盖所有操作。
例如商品说明读取失败,可以在数据库预算允许时回源;库存扣减后的失效失败,则需要重试或可靠补偿,并防止读者长期取得旧库存。授权和会话查询还要按安全要求决定是否拒绝服务。受限回源的实现见 缓存降级与恢复。
指标按实际存储层观察
Caffeine 的命中和加载计数属于当前 JVM。RedisCache 的统计也可能是当前缓存实例收集的本地统计;Redis INFO 中的 keyspace_hits 则包含服务端所见的其他客户端操作。三者分母不同,不宜直接相除或拼接为一个命中率。
诊断时把 cacheName、操作类型、耗时和异常类别作为低基数维度。key 的完整内容可能包含用户信息,且会造成标签数量膨胀;需要调查单条记录时使用受控日志和脱敏标识。
先确认查询结果能够在指定条件下复用,再选择保存位置和 TTL。调用次数下降之后,还要检查更新路径是否能让读者及时取得新数据。
