环境:Java 17、Spring Boot 3.x、Spring Framework 6.x、Spring Cache、Caffeine
一、问题:更新数据库成功了,接口为什么还返回旧值?
商品详情接口经常用缓存降低数据库查询量:
- 首次请求从数据库查商品,结果写入缓存。
- 后续请求直接读缓存。
- 后台修改商品价格,数据库已经变更,但接口仍返回旧价格。
这不一定是 Redis 出问题。更多时候是缓存键不一致、失效时机不对,或者更新事务和缓存操作没有对齐。本文用 Spring Cache 的基本能力解释可复现的行为,再讨论生产环境边界。
先给结论:对读多写少、允许极短暂不一致的业务,通常优先采用 Cache Aside(旁路缓存):查询时先查缓存,更新时先修改数据库,事务成功提交后再删除缓存;并配合 TTL、幂等与监控。它不是严格一致性方案。
二、Spring Cache 三个注解分别干什么?
| 注解 | 执行语义 | 常见用途 |
|---|---|---|
@Cacheable |
命中缓存时跳过方法;未命中执行并缓存结果 | 查询详情 |
@CachePut |
每次都执行方法,并尝试把结果放进缓存 | 明确需要更新缓存的操作 |
@CacheEvict |
删除指定缓存项或全部项 | 数据变更后失效 |
Spring 官方不建议在同一个方法上随意叠加 @Cacheable 和 @CachePut,两者是否执行目标方法的语义不同。
@CacheEvict 默认在方法成功执行之后 触发;设置 beforeInvocation=true 则在方法执行之前触发,不管后续是否抛异常。这些都不等于"数据库事务提交之后"。
官方文档:https://docs.spring.io/spring-framework/reference/integration/cache/annotations.html
三、最小示例:查询缓存 + 更新后失效
引入 spring-boot-starter-cache 和 Caffeine 依赖,使用 Spring Boot 的依赖管理统一版本:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
启用缓存并指定本地缓存实现:
java
@Configuration
@EnableCaching
public class CacheConfiguration {
@Bean
CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("products");
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5)));
return manager;
}
}
需要的 import 包括 org.springframework.cache.CacheManager、org.springframework.cache.caffeine.CaffeineCacheManager、com.github.benmanes.caffeine.cache.Caffeine、java.time.Duration 和 Spring 配置注解。
查询逻辑:
java
@Service
public class ProductQueryService {
private final ProductRepository repository;
public ProductQueryService(ProductRepository repository) {
this.repository = repository;
}
@Cacheable(cacheNames = "products", key = "#id")
public ProductDto findById(Long id) {
return repository.findById(id)
.map(ProductDto::from)
.orElseThrow(() -> new IllegalArgumentException("商品不存在"));
}
}
这里 ProductRepository、ProductDto 是业务接口和 DTO 占位,需按自己的项目实现。缓存 DTO 比直接缓存可变的 JPA 实体更易控制边界,也能避免懒加载对象在缓存中产生意外行为。
最简单的写操作:
java
@Service
public class ProductCommandService {
private final ProductRepository repository;
public ProductCommandService(ProductRepository repository) {
this.repository = repository;
}
@Transactional
@CacheEvict(cacheNames = "products", key = "#id")
public void updatePrice(Long id, BigDecimal price) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(price);
repository.save(product);
}
}
它演示了最常见的写法,但不能把它当作已经解决事务提交竞态的生产级方案。原因如下。
四、关键坑:方法结束 ≠ 事务已经提交
考虑缓存拦截器和事务拦截器共同包裹一个方法的情况:
text
调用代理
↓
事务拦截器 / 缓存拦截器(顺序取决于配置)
↓
执行 updatePrice()
↓
缓存失效
↓
事务提交
上图是可能出现的一种顺序,不是所有 Spring 项目固定的拦截器顺序。
若缓存先被删除,数据库事务还没有提交,另一个请求可能立刻查询数据库旧值,再把旧值写回缓存。即使更新最终成功,缓存也可能短时间继续返回旧数据。
更稳妥的策略是:把缓存失效绑定到事务成功提交之后。这样至少可以避免"事务尚未提交就删缓存"的窗口。
方案 A:事务提交后删除(推荐的清晰实现)
查询仍使用 @Cacheable,更新逻辑改为显式注册提交后回调:
java
@Service
public class ProductCommandService {
private final ProductRepository repository;
private final CacheManager cacheManager;
public ProductCommandService(ProductRepository repository,
CacheManager cacheManager) {
this.repository = repository;
this.cacheManager = cacheManager;
}
@Transactional
public void updatePrice(Long id, BigDecimal price) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(price);
repository.save(product);
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new IllegalStateException("必须在受管理事务中执行");
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
Cache cache = cacheManager.getCache("products");
if (cache != null) {
cache.evict(id);
}
}
});
}
}
相关 import:org.springframework.transaction.support.TransactionSynchronizationManager、TransactionSynchronization、org.springframework.cache.Cache、CacheManager。
这个方案的语义非常明确:事务回滚不删缓存,成功提交后再删缓存。但有两点必须诚实说明:
- 事务提交后、删除缓存前,仍有一个很短的旧数据窗口。
- 提交成功后进程突然崩溃,回调可能根本没执行;跨进程可靠失效不能只靠内存回调。
对重要业务可用事务 Outbox、变更事件、消息重试、定期校正等机制提高可靠性。不要把普通缓存回调误认为"事务性消息"。
方案 B:事务感知 CacheManager
Spring 提供 TransactionAwareCacheManagerProxy,可把部分缓存 put/evict 操作延迟到事务成功提交后:
java
@Bean
CacheManager cacheManager() {
CaffeineCacheManager target = new CaffeineCacheManager("products");
target.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5)));
return new TransactionAwareCacheManagerProxy(target);
}
这有助于避免默认失效时机早于事务提交的问题,但仍需要结合缓存 API 具体操作、代理配置、异常路径进行测试;不是跨数据库与缓存的原子提交保证 。如果使用 beforeInvocation=true 或其他立即失效 API,也不能简单假设所有操作都会被延迟。
五、还有一个并发窗口:删缓存后仍可能回填旧值
即使做到提交后删除,Cache Aside 仍可能发生以下竞态:
text
线程 A:缓存未命中 → 开始查询旧数据 ──────┐
线程 B:更新数据库并提交 → 删除缓存 │
线程 A:把此前查到的旧数据写入缓存 ←──────┘
这个窗口的出现取决于查询耗时、数据库隔离级别与执行时序。仅仅"先更新数据库再删缓存"并不能从数学上保证所有并发请求严格一致。
工程上可以组合使用:
- 设置合理 TTL,限制旧值长期滞留。
- 热点数据使用版本号或更新序列防止旧版本覆盖新版本。
- 极高一致性要求时,将读写放在同一一致性系统或使用更严格的串行化机制。
- 多实例本地 Caffeine 缓存需要广播失效;仅删除当前节点缓存不会影响其他节点。
对于一般商品展示类接口,允许有限陈旧 + TTL + 监控可能比全局锁更经济;库存扣减、账户余额等关键写路径则不应该把缓存值当成唯一事实来源。
六、Spring Cache 常见四个误区
误区 1:@Cacheable 同类调用也能命中
默认代理模式下,this.findById(id) 绕过代理,缓存拦截不会触发。建议把查询服务与调用服务拆成不同 Spring Bean;别依赖初始化阶段的 @PostConstruct 调用建立缓存。
误区 2:sync=true 就能跨集群防击穿
java
@Cacheable(cacheNames = "products", key = "#id", sync = true)
public ProductDto findById(Long id) { ... }
sync=true 请求底层缓存提供者对同一 key 的并发加载进行同步,但实际支持程度由 Cache 实现决定 ;本地 Caffeine 的同步不等于跨节点分布式锁。并且 sync=true 有特定约束,例如不支持 unless。
误区 3:更新时用 @CachePut 一定更快
更新后写缓存需要处理返回值、序列化、事务回滚与并发顺序;当多个写入同时发生,旧值仍可能覆盖新值。对于读多写少场景,删除缓存、让下一次读取重建通常更简单。
误区 4:@CacheEvict(allEntries=true) 只是删除当前 key
allEntries=true 会清理整个命名缓存区域,适用于批量导入后失效,但高频调用可能引发大面积缓存重建和数据库流量激增。
七、怎么测试才算验证了缓存一致性?
至少写四类集成测试:
- 缓存命中:两次查询同一 ID,数据库查询只发生一次。
- 更新成功:先预热旧值,提交更新后下一次读取返回新值。
- 更新回滚:先预热旧值,更新中抛出运行时异常,数据库回滚且不会错误失效。
- 并发竞态 :用
CountDownLatch控制查询和更新时序,观察旧值是否可能回填。
不要在需要观察真实提交时机的测试方法上随手加 @Transactional,否则测试事务可能改变缓存失效的时机。跨实例测试必须启动多个独立节点,不能用同一 JVM 的两个 Bean 冒充多实例。
排查时可临时开启:
yaml
logging:
level:
org.springframework.cache: TRACE
org.springframework.transaction: DEBUG
八、常见问题
Q:Spring Cache 和 Redis 是一回事吗?
不是。Spring Cache 是缓存抽象;Caffeine、Redis 等是具体实现。是否支持 TTL、跨节点共享、原子操作取决于底层提供者。
Q:缓存更新失败要回滚数据库吗?
一般的"数据库为准、缓存可重建"设计中,缓存失效失败应告警并重试,不应假装能把已提交数据库事务回滚。若业务要求强一致,应重新设计存储与读写链路。
Q:为什么不建议所有接口都缓存?
缓存增加了失效、监控和容量成本。优先缓存查询代价高、访问集中、更新频率低且允许一定陈旧的数据。
总结
Spring Boot 缓存一致性不是"加两个注解"就结束了。最值得检查的是代理是否生效、缓存键是否一致、失效是否发生在事务提交后、并发回填和多实例传播是否可控。
普通读多写少业务可以从 Cache Aside + TTL + 提交后删除开始;严格一致性场景需要额外的版本控制或架构设计。把缓存当成性能优化层,而不是数据库的第二个事实来源,才能少踩坑。