Spring Cache 与读写模式:代理抽象如何改变缓存边界
注解加在方法上却没有命中,换个租户却读到同一值,数据库回滚后缓存已经删除——这些问题不是注解记错,而是代理边界、key 契约和数据库事务没有被一起建模。
Spring Framework 的 Cache Abstraction 是统一 SPI 和拦截模型,不提供实际存储;默认 proxy 模式只处理经过代理的调用,TTL、驱逐和一致性仍由 provider 与应用负责。
注解命中的不是方法,而是代理调用
Spring Cache 抽象由 CacheManager 选择 cache,由 KeyGenerator 或 SpEL 生成 key,由 CacheInterceptor 围绕方法调用执行查找、加载、写入或删除。默认 proxy 模式只拦截穿过代理的外部调用,同类 self-invocation 不会触发缓存;这与 Spring 事务的代理边界相同。
默认 SimpleKey 由参数构成,但“相等参数”未必等于“相同业务视图”。租户、权限、语言、Schema 版本、分页与特性开关只要影响结果,就必须进入 key 或拆分 cache name。把可变 DTO 直接作为 key 会让 hashCode 改变后无法删除;稳定的规范化 key 更适合审计与迁移。
@Cacheable 在 miss 时调用目标方法,@CachePut 总是调用并写入,@CacheEvict 删除。beforeInvocation=true 会在方法执行前删除,即使业务随后失败;默认 after-invocation 则只在正常返回后删除。数据库更新和缓存操作不共享原子事务,TransactionAwareCacheDecorator 只能把部分操作延后到事务完成,不能让远端缓存与数据库两阶段提交。
sync=true 表示让 provider 尝试同 key 同步加载,具体能力依赖实现;它不是跨 JVM single-flight。unless 在方法返回后判断,condition 在调用前判断。缓存 null 可以抵御穿透,但必须区分“确定不存在”和“下游失败没有结果”,错误不能当 null 长期缓存。
Cache Aside 由应用读 miss 回源、写提交后删缓存,最容易看见一致性窗口;Read Through/Write Through 把加载或写入交给 provider,却没有消除权威数据与缓存的失败组合。模式选择要围绕谁拥有写入、失败如何恢复和是否允许陈旧,而不是注解数量。
Cache Aside:多数业务的默认缓存模式
最常见、也最可控的模式是 Cache Aside。
读写路径可以合在一张图里看:
Cache Aside 的核心不是“先查缓存再查库”这么简单,而是读路径、写路径、空值、TTL 和删除失败补偿必须成套出现。少一块,线上就会变成穿透、旧值或回源风暴。
读路径:
先查 Redis。命中直接返回。未命中查数据库。
数据存在则写入缓存。数据不存在则写短 TTL 空值。
写路径:
先更新数据库。再删除缓存。删除失败进入补偿。
伪代码:
public OrderDTO getOrder(String orderNo) {
String key = "shop:prod:order:detail:" + orderNo + ":v1";
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if ("__NULL__".equals(cached)) {
return null;
}
return json.readValue(cached, OrderDTO.class);
}
OrderDTO order = orderRepository.findByOrderNo(orderNo);
if (order == null) {
redisTemplate.opsForValue().set(key, "__NULL__", Duration.ofMinutes(1));
return null;
}
redisTemplate.opsForValue().set(key, json.writeValueAsString(order), randomTtl());
return order;
}
public void updateOrderStatus(String orderNo, int status) {
orderRepository.updateStatus(orderNo, status);
redisTemplate.delete("shop:prod:order:detail:" + orderNo + ":v1");
}验证方式:
GET shop:prod:order:detail:ORD001:v1
TTL shop:prod:order:detail:ORD001:v1常见坑:
只写读路径,不写删除缓存。更新数据库后直接更新缓存,旧请求晚到时把缓存覆盖回旧值。删除缓存失败没有重试、消息或巡检补偿。
空值缓存没有区分真实空字符串和不存在标记。
生产建议:缓存写路径要和数据库写路径一起评审。凡是更新数据库但没有缓存失效动作的需求,都不能直接上线。
Spring Data Redis:不要把默认配置直接带进核心链路
很多 Spring Boot 项目会从 RedisTemplate 或 @Cacheable 开始接入 Redis。入口简单,但默认行为必须看清楚:
RedisCacheManager 默认缓存条目没有过期时间。默认 value 序列化可能不是你想要的 JSON 协议,跨服务和长期兼容要显式配置。清理缓存时默认策略可能使用 KEYS + DEL,大 keyspace 下要改成基于 SCAN 的批处理策略。
Spring Data Redis 支持固定 TTL,也支持基于 key/value 动态计算 TTL 的 TtlFunction。Spring Data Redis 的 TTI 行为依赖 Redis GETEX,要求 Redis 6.2+,并且读写路径要一致使用能刷新 TTL 的访问方式。
一个更稳的基础配置长这样:
@Bean
RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
RedisCacheWriter writer = RedisCacheWriter.nonLockingRedisCacheWriter(
connectionFactory,
BatchStrategies.scan(1000)
);
return RedisCacheManager.builder(writer)
.cacheDefaults(defaults)
.enableStatistics()
.build();
}验证方式:
SCAN 0 MATCH "orderCache::*" COUNT 100
TTL "orderCache::100001"
MEMORY USAGE "orderCache::100001"常见坑:
@Cacheable 写得很快,但没有按缓存名配置 TTL,最后变成大量无过期 key。代码里同时混用 @Cacheable、RedisTemplate 和 Repository,TTL 语义不一致。缓存 null 值没有统一标记和短 TTL,穿透治理变成反序列化异常。
clear() 在大 keyspace 下触发大范围扫描或删除,业务高峰抖动。
生产建议:核心缓存不要只靠注解默认值。每个 cacheName 都要映射到一份缓存契约,写清 TTL、序列化、是否允许 null、清理策略和统计指标。
用状态模型验证缓存窗口
两个 Java 17 模型不连接真实 Redis,也不复刻 Caffeine 内部结构。它们把本篇必须守住的状态、版本和容量不变量变成确定输出;真实客户端与 Redis 集成测试继续验证协议、超时、拓扑和服务器行为。
javac --release 17 -Xlint:all -Werror examples/backend-development/cache/spring-cache-patterns/CacheProxyKeyDemo.java examples/backend-development/cache/spring-cache-patterns/CacheAsideDemo.java
java -cp examples/backend-development/cache/spring-cache-patterns CacheProxyKeyDemo
java -cp examples/backend-development/cache/spring-cache-patterns CacheAsideDemoexternalProxyCall=true selfInvocationIntercepted=false key=tenant=north|order=42|schema=v2
database=v8 cache=v8 deleteAfterCommit=true缓存正确性不是“命中返回值”,而是命中值仍在允许的陈旧窗口内、加载不会放大到权威存储、旧版本不能覆盖新版本、失败能够在预算内退化。模型输出任一项改变,都应触发对应集成用例,而不是只调整命中率告警。
命中率之外还要看放大率和陈旧年龄
指标至少拆成 lookup、hit、miss、load、load failure、eviction、expiration、invalidation、rejected、stale served 和 backend request。命中率高但每次 miss 都并发回源一千次,系统仍然危险;命中率低但回源便宜且有界,也未必需要缓存。用 backend requests / cache misses 表达放大率,用 value version 或 authority commit 到缓存可见的间隔表达陈旧年龄。
Key、租户、用户和原始 URL 不能成为常驻指标标签。指标按 cache name、result、tier、operation 与 failure class 聚合,具体 key 只进入采样且脱敏的 trace。日志要关联一次重建的 leader、waiter 数、回源耗时、发布版本与失效原因,才能区分穿透、击穿和客户端超时。
