Spring Boot 缓存一致性怎么保证?@Cacheable、@CacheEvict 与事务提交时机实战

环境:Java 17、Spring Boot 3.x、Spring Framework 6.x、Spring Cache、Caffeine

一、问题:更新数据库成功了,接口为什么还返回旧值?

商品详情接口经常用缓存降低数据库查询量:

  1. 首次请求从数据库查商品,结果写入缓存。
  2. 后续请求直接读缓存。
  3. 后台修改商品价格,数据库已经变更,但接口仍返回旧价格。

这不一定是 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,也不能简单假设所有操作都会被延迟。

官方接口:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/transaction/TransactionAwareCacheManagerProxy.html

五、还有一个并发窗口:删缓存后仍可能回填旧值

即使做到提交后删除,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 会清理整个命名缓存区域,适用于批量导入后失效,但高频调用可能引发大面积缓存重建和数据库流量激增。

七、怎么测试才算验证了缓存一致性?

至少写四类集成测试:

  1. 缓存命中:两次查询同一 ID,数据库查询只发生一次。
  2. 更新成功:先预热旧值,提交更新后下一次读取返回新值。
  3. 更新回滚:先预热旧值,更新中抛出运行时异常,数据库回滚且不会错误失效。
  4. 并发竞态 :用 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 + 提交后删除开始;严格一致性场景需要额外的版本控制或架构设计。把缓存当成性能优化层,而不是数据库的第二个事实来源,才能少踩坑。

参考资料

相关推荐
Rain的Java大神之路2 小时前
国家数据集团校招、社招都开了:总部雄安,这些技术岗值得认真看看
java·后端·面试
吴名氏.2 小时前
Java转AI Agent教程(第九周)错误矩阵
java·人工智能·矩阵·ai agent
文丑颜不良啊2 小时前
LeetCode1901~1920题目解析
java·leetcode
软件技术NINI2 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
tealcwu2 小时前
【Unity基础】为什么游戏需要物理按键映射?从 Unity Input Manager 到跨引擎输入设计
java·游戏·unity
vipxieliang2 小时前
Spring Boot 性能优化实战:从启动加速到运行时调优
java·spring boot
AI全栈学习助手3 小时前
告别“转圈等待”:Spring Boot + SSE 让大模型像 ChatGPT 一样流式输出
java·人工智能·spring boot·后端·aigc
天天被压力3 小时前
【A股分钟级高频数据完全手册 #02】Python调用股票1分钟数据接口:Pro版最新分时取数与字段解析
java·人工智能·python