发布于2026-07-16 阅读(0)
扫一扫,手机访问
在SpringBoot企业级开发里,给高频查询接口加缓存——比如Redis、Caffeine——几乎成了性能优化的标配操作。把热点数据缓存起来,数据库的查询压力降下来了,接口响应速度也从几十毫秒提升到了几毫秒,这效果确实立竿见影。

但缓存的引入,也顺手带来一个核心难题——缓存一致性。简单来说就是:数据库里的数据改了,缓存里的老数据却没跟上,结果用户查到的全是旧数据,业务逻辑自然就乱了。这可不是小事。
举个例子:用户刚改了自己的昵称,数据库里已经更新了,但缓存里存着的还是旧昵称。用户一刷新个人信息页面,看到的是自己改名前的老名字,体验瞬间崩塌。更糟糕的是,如果订单状态更新了,缓存没同步,运营同学盯着旧数据做判断,很可能造成实际损失。
不少新手一开始处理缓存,只知道“查询时先走缓存,缓存没有就去查数据库,查完之后再写回缓存”这一招(也就是Cache-Aside策略),但一到数据修改场景,就忘了同步缓存,一致性漏洞频发。
要解决缓存一致性,首先得搞清楚问题的根源。它不是缓存这个组件的问题,也不是数据库的问题,而是数据修改时,缓存与数据库的操作顺序、同步时机,以及“并发场景下的竞态条件”在作祟。
当你需要同时操作数据库和缓存时,这两个操作从根本上就做不到“原子性”——要么一起成功,要么一起失败。于是会冒出两种典型的问题:
需要明确一个认知:我们追求的从来不是“绝对一致性”——那个成本太高,而且对大多数业务来说没必要。我们要的是最终一致性:在合理的时间窗口内(比如1秒以内),缓存数据能够同步为数据库的最新数据,这就足够了。
比如用户改昵称,100毫秒后缓存更新了,用户再次查询就能看到新名字,这种延迟完全不影响体验。这才是成本和性能的最佳平衡。
面试时如果被问到,你可以这样总结:缓存一致性的核心在于“解决双写顺序和并发竞态问题”,企业级落地优先追求“最终一致性”,而非“绝对一致性”,在性能和数据准确性之间找到那个平衡点。
目前业界主流的双写策略有三种,它们各有长短,没有哪个是绝对的“最优解”,只有最适合你业务的方案。下面逐一拆解,附上完整代码和细节说明,拿到项目里直接就能用。
先做一点前置准备:SpringBoot 2.7.x + Redis + Spring Cache,核心依赖如下:
org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-cache org.springframework.boot spring-boot-starter-data-redis com.github.ben-manes.caffeine caffeine 3.1.2 org.projectlombok lombok true
基础配置(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
Cache-Aside 是目前最主流、最容易被团队接受的双写策略。它的核心逻辑可以用两句话概括:查询走缓存,更新走数据库+删除缓存。注意,是删除缓存,而不是更新缓存,这是关键。
很多人也管它叫“Cache-Aside Pattern”,企业开发中最常用的缓存策略,兼顾性能与一致性,实现起来非常清爽。
查询操作:先查缓存 → 缓存有数据,直接返回;缓存没有,查数据库 → 把数据写入缓存 → 返回数据。
更新操作:先更新数据库 → 再删除缓存(不是更新缓存)。
删除操作:先删除数据库 → 再删除缓存。
这是一个高频问题,原因其实有两层:
使用Spring Cache的@Cacheable(查询缓存)、@CacheEvict(删除缓存)注解,不用手动操作Redis,代码非常简洁。
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import ja vax.annotation.Resource;
import ja va.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 = 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. 注解自动删除缓存
}
}
优点:实现简单、无侵入(依赖Spring Cache注解)、性能好(查询走缓存,更新仅多一次删除缓存操作)、一致性有保障(最终一致性)。
缺点:存在轻微的并发竞态问题(下文会讲解决方案)。
适用场景:绝大多数业务场景,尤其是查询频率高、更新频率中等的场景(比如商品详情、用户信息、订单列表),是企业级落地的首选。
Write-Through 策略的核心逻辑是:更新操作时,先更新数据库,再同步更新缓存。查询操作和Cache-Aside一致(先查缓存,无则查数据库)。
这种策略的特点是“写入即同步”,缓存和数据库的数据非常接近(接近绝对一致性),但性能会稍弱一些(因为多了一次缓存更新操作)。
Write-Through 不太适合用Spring Cache注解来实现(因为注解无法做到“更新数据库后同步更新缓存”的逻辑),需要手动操作RedisTemplate。
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import ja vax.annotation.Resource;
import ja va.util.Optional;
import ja va.util.concurrent.TimeUnit;
@Service
public class ProductService {
@Resource
private ProductMapper productMapper;
@Resource
private RedisTemplate 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 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);
}
}
优点:缓存与数据库的一致性强(接近绝对一致),查询时不会出现旧数据,适合对数据一致性要求高的场景。
缺点:性能稍弱(更新操作多一次缓存写入),存在双写顺序错误风险(如果更新缓存失败,数据库是新数据,缓存是旧数据)。
适用场景:对数据一致性要求高、更新频率低的场景(比如金融数据、核心配置数据),不适合高频更新场景。
Write-Back 策略的核心逻辑是:更新操作时,先更新缓存,不立即更新数据库,而是把缓存标记为“脏数据”,等到一定时机(比如缓存过期、缓存满了、通过定时任务)再批量同步到数据库。
这种策略的特点是“写入性能极高”(只需要更新缓存,不用立刻操作数据库),但一致性最弱(缓存更新了,数据库里可能还是旧数据),实现起来也比较复杂。
Write-Back 实现起来确实比较复杂,需要结合定时任务和脏数据标记机制。下面是一个简化版的核心逻辑(实际落地时需要考虑异常处理和重试机制):
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import ja vax.annotation.Resource;
import ja va.util.HashMap;
import ja va.util.Map;
import ja va.util.Optional;
import ja va.util.concurrent.TimeUnit;
@Service
public class ProductService {
@Resource
private ProductMapper productMapper;
@Resource
private RedisTemplate redisTemplate;
private static final String CACHE_KEY_PREFIX = "product:";
// 存储脏数据(key:缓存key,value:商品对象)
private final Map 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 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();
}
}
优点:写入性能极高(不需要立即操作数据库),适合高频写入、对一致性要求低的场景。
缺点:一致性最弱(缓存更新后,数据库可能延迟同步,如果系统崩溃,脏数据会丢失),实现复杂(需要处理脏数据、定时同步、异常重试)。
适用场景:高频写入、对数据一致性要求低的场景(比如日志缓存、浏览记录、临时统计数据),业务系统的核心数据不推荐使用。
前面提到,Cache-Aside 策略存在一个轻微的并发竞态问题,这是新手落地时最容易踩的坑,也是面试常问的点。下面拆解问题场景,并给出两种企业级解决方案。
假设有两个线程同时执行:线程A(更新操作)、线程B(查询操作),执行顺序如下:
1. 线程A:更新数据库(成功)。
2. 线程A:准备删除缓存(还没执行)。
3. 线程B:查询缓存(缓存中还有旧数据,此时缓存还没被删除,线程B查到旧数据,准备返回)。
4. 线程A:删除缓存(成功)。
5. 线程B:把查到的旧数据,重新写入缓存。
最终的结果就是:数据库里是新数据,缓存里是旧数据。而且因为缓存被重新写入了,后续的所有查询都会拿到旧数据,直到缓存过期。这就是经典的并发竞态问题。
核心思路:更新数据库后,延迟一段时间(比如100毫秒)再删除缓存,确保线程B在查询时,能查到数据库的新数据,而不是把旧数据写入缓存。
实现方式:使用线程池异步延迟删除,不阻塞主线程的性能。
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import ja vax.annotation.Resource;
import ja va.util.Optional;
import ja va.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 = 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的查询操作能在缓存删除前完成数据库查询,避免旧数据写入缓存。
核心思路:在查询和更新操作中,给“缓存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 ja vax.annotation.Resource;
import ja va.util.Optional;
import ja va.util.concurrent.TimeUnit;
@Service
public class ProductService {
@Resource
private ProductMapper productMapper;
@Resource
private RedisTemplate 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 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实现缓存与数据库双写策略的详细代码。
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8