news 2026/4/21 4:06:04

SpringBoot 缓存一致性:缓存与数据库双写策略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot 缓存一致性:缓存与数据库双写策略

在SpringBoot企业开发中,为了提升系统性能,我们都会给高频查询接口加上缓存(比如Redis、Caffeine),把热点数据缓存起来,减少数据库查询压力,让接口响应速度从几十毫秒提升到几毫秒。

但缓存的引入,也带来了一个核心难题——缓存一致性:当数据库中的数据发生修改(新增、更新、删除)时,缓存中的数据如果没有及时同步,就会出现“缓存数据与数据库数据不一致”的问题,导致用户查询到旧数据、错误数据,引发业务异常。

举个真实场景:用户修改了自己的昵称,数据库中的昵称已经更新,但缓存中还是旧昵称,用户再次查询个人信息时,看到的还是旧昵称,体验极差;更严重的是,订单状态更新后缓存未同步,可能导致运营人员误判订单状态,造成损失。

很多同学一开始处理缓存,只懂“查询时查缓存,没有就查数据库再存缓存”(即Cache-Aside策略),但忽略了数据修改时的缓存同步,导致缓存一致性问题频发。

一、缓存一致性的核心问题

想要解决缓存一致性问题,首先要明白:问题的根源不是“缓存”或“数据库”本身,而是数据修改时,缓存与数据库的操作顺序、同步时机,以及“并发场景下的竞态条件”。

1. 双写顺序与并发竞态

当数据发生修改时,我们需要同时操作“数据库”和“缓存”,但这两个操作无法做到“原子性”(要么同时成功,要么同时失败),因此会出现两种核心问题:

  • 双写顺序错误:比如先更新缓存、再更新数据库,若更新数据库失败,缓存中是新数据,数据库中是旧数据,导致不一致;

  • 并发竞态问题:比如一个更新操作(改数据库+删缓存)和一个查询操作(查缓存+查数据库)并发执行,查询操作可能在更新操作删除缓存后、更新数据库前,查询到旧数据并重新写入缓存,导致缓存一直是旧数据。

2. 缓存一致性的目标

我们追求的缓存一致性,不是“绝对一致性”(成本极高,没必要),而是最终一致性:在合理的时间范围内(比如1秒内),缓存数据能同步为数据库的最新数据,满足业务需求即可。

比如用户修改昵称后,100毫秒内缓存同步更新,用户再次查询就能看到新昵称,这种“最终一致性”完全能满足绝大多数业务场景,且实现成本低、性能影响小。

面试必背总结:缓存一致性的核心是“解决双写顺序和并发竞态问题”,企业级落地优先追求“最终一致性”,而非“绝对一致性”,平衡性能与数据准确性。

二、三大主流双写策略

目前业界解决缓存一致性的双写策略主要有3种,各有优缺点和适用场景,没有最优方案,只有最适合业务的方案,下面逐一拆解,包含实现代码、细节说明,直接复制就能用。

前置准备:SpringBoot 2.7.x + Redis + Spring Cache(简化缓存操作),核心依赖如下(已包含Spring Cache和Redis整合):

<!-- SpringBoot Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency><!-- Spring Cache 核心依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <!-- Redis 依赖(分布式缓存) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- Caffeine 依赖(单机缓存,可选) --> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.2</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

基础配置(application.yml):

spring: # Redis 配置(分布式缓存) redis: host: localhost port: 6379 password: 123456 database: 0 lettuce: pool: maximum-pool-size: 10 minimum-idle: 2 # 缓存配置 cache: type: redis # 默认使用Redis缓存(单机可改为caffeine) redis: time-to-live: 3600000 # 缓存过期时间(1小时,根据业务调整) cache-null-values: false # 不缓存null值,避免缓存穿透 caffeine: time-to-live: 3600000 # 单机缓存过期时间 initial-capacity: 100 # 初始缓存容量 maximum-size: 1000 # 最大缓存数量(避免内存溢出) # 开启Spring Cache注解支持 spring.cache.type: redis

策略1:Cache-Aside(旁路缓存)

Cache-Aside 是最主流、最易落地的双写策略,核心逻辑:查询走缓存,更新走数据库+删除缓存,不直接更新缓存,避免双写顺序错误。

很多人也称其为“Cache-Aside Pattern”,是企业开发中最常用的缓存策略,兼顾性能和一致性,实现简单。

1. 核心流程
  • 查询操作:先查缓存 → 缓存有数据,直接返回;缓存无数据,查数据库 → 将数据库数据写入缓存 → 返回数据;

  • 更新操作:先更新数据库 → 再删除缓存(而非更新缓存);

  • 删除操作:先删除数据库 → 再删除缓存。

2. 为什么是“删除缓存”,而非“更新缓存”?

这是很多同学最常问的问题,核心原因有2点:

  • • 避免双写顺序错误:如果先更新缓存、再更新数据库,数据库更新失败,缓存是新数据、数据库是旧数据,直接不一致;

  • • 减少冗余操作:如果多条更新操作连续执行,每次都更新缓存,会造成不必要的性能开销;而删除缓存,只需在最后一次更新后删除一次,后续查询再重新写入缓存,更高效。

3. 完整代码

使用Spring Cache的@Cacheable(查询缓存)、@CacheEvict(删除缓存)注解,无需手动操作Redis,简化开发。

import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Optional; /** * 商品服务(Cache-Aside策略实现) */ @Service public class ProductService { @Resource private ProductMapper productMapper; /** * 查询商品:先查缓存,无则查数据库,再写入缓存 * value:缓存名称(自定义) * key:缓存key(用商品ID,确保唯一) */ @Cacheable(value = "product", key = "#id") public Product getProductById(Long id) { // 缓存没有时,查询数据库(实际项目可加日志) Optional<Product> product = productMapper.selectById(id); return product.orElse(null); } /** * 更新商品:先更新数据库,再删除缓存 * @CacheEvict:删除缓存,allEntries=false表示只删除当前key的缓存 */ @CacheEvict(value = "product", key = "#product.id") public void updateProduct(Product product) { // 1. 先更新数据库 productMapper.updateById(product); // 2. 注解自动删除缓存(无需手动操作Redis) } /** * 删除商品:先删除数据库,再删除缓存 */ @CacheEvict(value = "product", key = "#id") public void deleteProduct(Long id) { // 1. 先删除数据库 productMapper.deleteById(id); // 2. 注解自动删除缓存 } }
4. 优缺点与适用场景

优点:实现简单、无侵入(依赖Spring Cache注解)、性能好(查询走缓存,更新仅多一次删除缓存操作)、一致性有保障(最终一致性);

缺点:存在轻微的并发竞态问题(下文会讲解决方案);

适用场景:绝大多数业务场景,尤其是查询频率高、更新频率中等的场景(比如商品详情、用户信息、订单列表),是企业级落地的首选。

策略2:Write-Through

Write-Through 策略的核心逻辑:更新操作时,先更新数据库,再同步更新缓存;查询操作和Cache-Aside一致(先查缓存,无则查数据库)。

这种策略的特点是“写入即同步”,缓存和数据库的数据几乎是一致的(接近绝对一致性),但性能稍弱(多一次缓存更新操作)。

1. 核心流程
  • 查询操作:和Cache-Aside一致(先缓存 → 再数据库 → 写缓存);

  • 更新操作:先更新数据库 → 再更新缓存(覆盖旧缓存);

  • 删除操作:先删除数据库 → 再删除缓存(和Cache-Aside一致)。

2. 完整代码

Write-Through 不适合用Spring Cache注解(注解无法实现“更新数据库后同步更新缓存”的逻辑),需手动操作RedisTemplate。

import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Optional; import java.util.concurrent.TimeUnit; @Service public class ProductService { @Resource private ProductMapper productMapper; @Resource private RedisTemplate<String, Object> redisTemplate; // 缓存key前缀(避免key冲突) private static final String CACHE_KEY_PREFIX = "product:"; /** * 查询商品(和Cache-Aside一致) */ public Product getProductById(Long id) { String cacheKey = CACHE_KEY_PREFIX + id; // 1. 先查缓存 Product product = (Product) redisTemplate.opsForValue().get(cacheKey); if (product != null) { return product; } // 2. 缓存无,查数据库 Optional<Product> dbProduct = productMapper.selectById(id); if (dbProduct.isPresent()) { // 3. 写入缓存(设置过期时间,避免缓存雪崩) redisTemplate.opsForValue().set(cacheKey, dbProduct.get(), 1, TimeUnit.HOURS); return dbProduct.get(); } return null; } /** * 更新商品:先更数据库,再更缓存(Write-Through策略核心) */ public void updateProduct(Product product) { // 1. 先更新数据库 productMapper.updateById(product); // 2. 同步更新缓存(覆盖旧数据) String cacheKey = CACHE_KEY_PREFIX + product.getId(); redisTemplate.opsForValue().set(cacheKey, product, 1, TimeUnit.HOURS); } /** * 删除商品:先删数据库,再删缓存 */ public void deleteProduct(Long id) { // 1. 先删除数据库 productMapper.deleteById(id); // 2. 再删除缓存 String cacheKey = CACHE_KEY_PREFIX + id; redisTemplate.delete(cacheKey); } }
3. 优缺点与适用场景

优点:缓存与数据库一致性强(接近绝对一致),查询时不会出现旧数据,适合对数据一致性要求高的场景;

缺点:性能稍弱(更新操作多一次缓存写入),存在双写顺序错误风险(若更新缓存失败,数据库是新数据、缓存是旧数据);

适用场景:对数据一致性要求高、更新频率低的场景(比如金融数据、核心配置数据),不适合高频更新场景。

策略3:Write-Back(写回)

Write-Back 策略的核心逻辑:更新操作时,先更新缓存,不立即更新数据库,而是将缓存标记为“脏数据”,在一定时机(比如缓存过期、缓存满了、定时任务)再批量同步到数据库。

这种策略的特点是“写入性能极高”(只需更新缓存,无需立即操作数据库),但一致性最弱(缓存更新后,数据库可能还是旧数据),实现复杂,很少在业务系统中使用。

1. 核心流程
  • 查询操作:和前两种策略一致(先缓存 → 再数据库 → 写缓存);

  • 更新操作:先更新缓存 → 标记缓存为“脏数据” → 异步/定时同步到数据库;

  • 删除操作:先删除缓存 → 标记为“脏数据” → 异步/定时删除数据库数据。

2. 简化实现代码

Write-Back 实现复杂,需结合定时任务、脏数据标记,以下是简化版核心逻辑(实际落地需完善异常处理、重试机制):

import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; @Service public class ProductService { @Resource private ProductMapper productMapper; @Resource private RedisTemplate<String, Object> redisTemplate; private static final String CACHE_KEY_PREFIX = "product:"; // 存储脏数据(key:缓存key,value:商品对象) private final Map<String, Product> dirtyDataMap = new HashMap<>(); /** * 查询商品 */ public Product getProductById(Long id) { String cacheKey = CACHE_KEY_PREFIX + id; Product product = (Product) redisTemplate.opsForValue().get(cacheKey); if (product != null) { return product; } Optional<Product> dbProduct = productMapper.selectById(id); if (dbProduct.isPresent()) { redisTemplate.opsForValue().set(cacheKey, dbProduct.get(), 1, TimeUnit.HOURS); return dbProduct.get(); } return null; } /** * 更新商品:先更缓存,标记脏数据(Write-Back核心) */ public void updateProduct(Product product) { String cacheKey = CACHE_KEY_PREFIX + product.getId(); // 1. 更新缓存 redisTemplate.opsForValue().set(cacheKey, product, 1, TimeUnit.HOURS); // 2. 标记为脏数据 dirtyDataMap.put(cacheKey, product); } /** * 定时同步脏数据到数据库(每5分钟执行一次,可调整) */ @Scheduled(cron = "0 0/5 * * * ?") public void syncDirtyDataToDb() { if (dirtyDataMap.isEmpty()) { return; } // 批量同步脏数据到数据库 for (Product product : dirtyDataMap.values()) { productMapper.updateById(product); } // 清空脏数据 dirtyDataMap.clear(); } }
3. 优缺点与适用场景

优点:写入性能极高(无需立即操作数据库),适合高频写入、对一致性要求低的场景;

缺点:一致性最弱(缓存更新后,数据库可能延迟同步,若系统崩溃,脏数据会丢失),实现复杂(需处理脏数据、定时同步、异常重试);

适用场景:高频写入、对数据一致性要求低的场景(比如日志缓存、浏览记录、临时统计数据),业务系统核心数据不推荐使用。

三、解决双写策略的并发竞态问题

前面提到,Cache-Aside 策略存在轻微的并发竞态问题,这是新手落地时最容易踩的坑,也是面试常问的点,下面拆解问题场景,并给出两种企业级解决方案。

1. 并发竞态问题场景

假设两个线程同时执行:线程A(更新操作)、线程B(查询操作),执行顺序如下:

  1. 1. 线程A:更新数据库(成功);

  2. 2. 线程A:准备删除缓存(还未执行);

  3. 3. 线程B:查询缓存(缓存中还有旧数据?不,此时缓存还未删除,线程B查到旧数据,准备返回);

  4. 4. 线程A:删除缓存(成功);

  5. 5. 线程B:将查到的旧数据,重新写入缓存;

最终结果:数据库是新数据,缓存是旧数据,出现一致性问题,且后续查询都会拿到旧数据(直到缓存过期)。

2. 解决方案1:延迟删除缓存

核心逻辑:更新数据库后,延迟一段时间(比如100毫秒)再删除缓存,确保线程B在查询时,能查到数据库的新数据,而不是旧数据后写入缓存。

实现方式:使用线程池异步延迟删除,不影响主线程性能。

import org.springframework.cache.annotation.Cacheable; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Optional; import java.util.concurrent.TimeUnit; @Service public class ProductService { @Resource private ProductMapper productMapper; @Resource private ThreadPoolTaskExecutor taskExecutor; /** * 查询商品(不变) */ @Cacheable(value = "product", key = "#id") public Product getProductById(Long id) { Optional<Product> product = productMapper.selectById(id); return product.orElse(null); } /** * 更新商品:延迟删除缓存,解决并发竞态 */ public void updateProduct(Product product) { // 1. 先更新数据库 productMapper.updateById(product); // 2. 异步延迟100毫秒删除缓存(延迟时间可调整) Long productId = product.getId(); taskExecutor.schedule(() -> { // 手动删除缓存(替代@CacheEvict注解) redisTemplate.delete("product:" + productId); }, 100, TimeUnit.MILLISECONDS); } }

✅ 关键说明:延迟时间建议设置为“业务接口的最大响应时间”(比如100-500毫秒),确保线程B的查询操作能在缓存删除前完成数据库查询,避免旧数据写入缓存。

3. 解决方案2:分布式锁

核心逻辑:在查询和更新操作中,给“缓存key”加分布式锁(比如Redis分布式锁),确保同一时间,只有一个线程能执行“查询+写缓存”或“更新+删缓存”操作,彻底解决竞态问题。

实现方式:使用Redisson分布式锁(简化锁的操作,避免死锁),适合分布式系统场景。

import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Optional; import java.util.concurrent.TimeUnit; @Service public class ProductService { @Resource private ProductMapper productMapper; @Resource private RedisTemplate<String, Object> redisTemplate; @Resource private RedissonClient redissonClient; private static final String CACHE_KEY_PREFIX = "product:"; private static final String LOCK_KEY_PREFIX = "product:lock:"; /** * 查询商品:加分布式锁,避免竞态 */ public Product getProductById(Long id) { String cacheKey = CACHE_KEY_PREFIX + id; String lockKey = LOCK_KEY_PREFIX + id; RLock lock = redissonClient.getLock(lockKey); try { // 加锁(10秒自动释放,避免死锁) lock.lock(10, TimeUnit.SECONDS); // 1. 先查缓存 Product product = (Product) redisTemplate.opsForValue().get(cacheKey); if (product != null) { return product; } // 2. 查数据库,写缓存 Optional<Product> dbProduct = productMapper.selectById(id); if (dbProduct.isPresent()) { redisTemplate.opsForValue().set(cacheKey, dbProduct.get(), 1, TimeUnit.HOURS); return dbProduct.get(); } return null; } finally { // 释放锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } /** * 更新商品:加分布式锁,避免竞态 */ public void updateProduct(Product product) { String cacheKey = CACHE_KEY_PREFIX + product.getId(); String lockKey = LOCK_KEY_PREFIX + product.getId(); RLock lock = redissonClient.getLock(lockKey); try { lock.lock(10, TimeUnit.SECONDS); // 1. 更新数据库 productMapper.updateById(product); // 2. 删除缓存 redisTemplate.delete(cacheKey); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }

✅ 关键说明:分布式锁会增加一定的性能开销,适合对一致性要求高的分布式系统;如果是单机系统,可用本地锁(synchronized)替代,更高效。

四、文末小结

重点:优先掌握 Cache-Aside 策略(最易落地、最常用),先实现“查询查缓存、更新删缓存”的基础逻辑,再添加延迟删除缓存解决竞态问题,配合缓存过期时间、异常重试,就能满足绝大多数业务场景的缓存一致性需求。

实际项目中,无需过度追求复杂的策略,根据业务场景选择合适的双写方案:查询高频、更新中等 → Cache-Aside;一致性要求高 → Write-Through;高频写入、一致性要求低 → Write-Back。

收藏本文,无论是日常开发中的缓存一致性问题,还是面试突击,都能随时查阅,轻松拿捏SpringBoot缓存双写策略,彻底解决缓存与数据库不一致的痛点!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/21 4:03:40

终极BigImageViewer快速入门:5分钟掌握高效大图浏览解决方案

终极BigImageViewer快速入门&#xff1a;5分钟掌握高效大图浏览解决方案 【免费下载链接】BigImageViewer Big image viewer supporting pan and zoom, with very little memory usage and full featured image loading choices. Powered by Subsampling Scale Image View, Fre…

作者头像 李华
网站建设 2026/4/21 4:03:36

# 发散创新:用Python构建高保真虚拟原型——从概念到可运行代码的全流程实践在嵌入式开发、物联网设备设计与工业仿真中,**

发散创新&#xff1a;用Python构建高保真虚拟原型——从概念到可运行代码的全流程实践 在嵌入式开发、物联网设备设计与工业仿真中&#xff0c;虚拟原型&#xff08;Virtual Prototype&#xff09; 正逐步取代传统硬件搭建流程。它不仅大幅降低试错成本&#xff0c;还能实现早期…

作者头像 李华
网站建设 2026/4/21 3:58:44

Spytify音频质量优化:从160kbps到320kbps的完整设置教程

Spytify音频质量优化&#xff1a;从160kbps到320kbps的完整设置教程 【免费下载链接】spy-spotify &#x1f3a4; Records Spotify to mp3 without ads and adds media tags to the files &#x1f3b5; 项目地址: https://gitcode.com/gh_mirrors/sp/spy-spotify Spyti…

作者头像 李华
网站建设 2026/4/21 3:57:16

从零封装企业微信AI助手插件:Dify 2026正式版首个GA级案例(含OAuth2.1动态权限、审计日志埋点、SLO达标报告)

第一章&#xff1a;从零封装企业微信AI助手插件&#xff1a;Dify 2026正式版首个GA级案例&#xff08;含OAuth2.1动态权限、审计日志埋点、SLO达标报告&#xff09;核心架构设计原则 本插件严格遵循企业微信官方插件规范与Dify 2026 GA版扩展框架契约&#xff0c;采用声明式能力…

作者头像 李华
网站建设 2026/4/21 3:51:34

【深度解析】Claude Opus 4.7 发布背后的信号:编程智能体、自主工作流与 AI 安全边界重构

摘要 Claude Opus 4.7 的发布并不只是一次常规模型升级&#xff0c;更像是面向“长链路软件工程智能体”的一次架构信号释放。本文将从性能跃迁、Agent 工作流、自我校验机制与安全治理四个层面&#xff0c;解析这次发布对 AI 编程和企业级落地的真实意义&#xff0c;并给出可直…

作者头像 李华