SpringBoot + 缓存一致性双写策略 + 延迟双删:先更新 DB 再删缓存,防脏读实战方案
前言
在高并发系统中,缓存是提升性能的重要手段。然而,缓存与数据库之间的一致性问题一直是开发者面临的挑战。当数据发生变化时,如何确保缓存中的数据与数据库中的数据保持一致,是一个需要认真考虑的问题。
想象一下这样的场景:用户A更新了某个商品的价格,系统先更新了数据库,然后删除了缓存。此时,用户B刚好查询该商品的价格,系统发现缓存不存在,于是从数据库读取新价格并写入缓存。这看起来是正常的流程。但如果用户A更新数据时,系统先删除了缓存,然后更新数据库,此时用户B查询时可能会读取到旧数据并写入缓存,导致缓存与数据库不一致。
如何解决这个问题? 本文将详细介绍缓存一致性的双写策略和延迟双删方案,帮助你构建一个可靠的缓存一致性机制。
一、核心概念
1.1 缓存一致性
缓存一致性是指缓存中的数据与数据库中的数据保持一致的状态。在分布式系统中,由于网络延迟、并发操作等因素,缓存与数据库之间可能会出现数据不一致的情况。
1.2 双写策略
双写策略是指在更新数据时,同时更新数据库和缓存。常见的双写策略有两种:
- 先更新数据库,再更新缓存:这种策略可能会导致数据不一致,因为在更新数据库和更新缓存之间,可能有其他线程读取到旧数据。
- 先更新数据库,再删除缓存:这种策略相对安全,因为删除缓存后,后续的读取操作会从数据库重新加载数据到缓存。
1.3 延迟双删
延迟双删是指在更新数据时,先删除缓存,然后更新数据库,最后再延迟一段时间后再次删除缓存。这种策略可以解决并发更新时的缓存一致性问题。
1.4 脏读
脏读是指一个事务读取到了另一个事务未提交的数据。在缓存场景中,脏读通常是指读取到了缓存中过时的数据。
二、技术方案
2.1 架构设计
缓存一致性双写策略的架构设计主要包括以下几个部分:
- 数据层:数据库,存储业务数据
- 缓存层:Redis 或其他缓存系统,存储热点数据
- 服务层:Spring Boot 应用,负责业务逻辑和缓存管理
- 同步层:确保缓存与数据库之间的一致性
2.2 技术选型
- Spring Boot:作为基础框架,提供依赖注入、配置管理等功能
- Spring Data JPA:用于操作数据库
- Redis:作为缓存系统
- Spring Cache:提供缓存抽象
- Redisson:提供分布式锁和延迟队列
- Lombok:简化代码
2.3 核心流程
-
读取数据:
- 首先从缓存中读取数据
- 如果缓存命中,直接返回数据
- 如果缓存未命中,从数据库读取数据,然后写入缓存,最后返回数据
-
更新数据(双写策略):
- 先更新数据库
- 然后删除缓存
-
更新数据(延迟双删策略):
- 先删除缓存
- 然后更新数据库
- 延迟一段时间后,再次删除缓存
三、Spring Boot 缓存一致性双写策略实现
3.1 依赖配置
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.8</version>
</dependency>
<!-- Spring Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
3.2 配置文件
spring:
datasource:
url: jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=UTC
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate.format_sql: true
redis:
host: localhost
port: 6379
password:
database: 0
cache:
type: redis
redis:
time-to-live: 600000 # 10分钟
# 延迟双删配置
cache:
delay:
delete:
enabled: true
delay-time: 1000 # 1秒
thread-pool-size: 10
# 应用配置
server:
port: 8080
3.3 缓存配置
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(config)
.build();
}
}
3.4 双写策略实现
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 读取商品信息
*/
@Cacheable(value = "product", key = "#id")
public Product getProductById(Long id) {
return productRepository.findById(id).orElse(null);
}
/**
* 更新商品信息(双写策略:先更新DB,再删缓存)
*/
@Transactional
public Product updateProduct(Product product) {
// 1. 更新数据库
Product updatedProduct = productRepository.save(product);
// 2. 删除缓存
redisTemplate.delete("product::" + product.getId());
return updatedProduct;
}
}
3.5 延迟双删策略实现
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Value("${cache.delay.delete.delay-time:1000}")
private long delayTime;
/**
* 读取商品信息
*/
@Cacheable(value = "product", key = "#id")
public Product getProductById(Long id) {
return productRepository.findById(id).orElse(null);
}
/**
* 更新商品信息(延迟双删策略)
*/
@Transactional
public Product updateProductWithDelayDelete(Product product) {
// 1. 先删除缓存
redisTemplate.delete("product::" + product.getId());
// 2. 更新数据库
Product updatedProduct = productRepository.save(product);
// 3. 延迟一段时间后再次删除缓存
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(redissonClient.getBlockingQueue("delayDeleteQueue"));
delayedQueue.offer("product::" + product.getId(), delayTime, TimeUnit.MILLISECONDS);
return updatedProduct;
}
/**
* 处理延迟删除任务
*/
@PostConstruct
public void initDelayDeleteTask() {
RBlockingQueue<Object> blockingQueue = redissonClient.getBlockingQueue("delayDeleteQueue");
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
new Thread(() -> {
while (true) {
try {
String key = (String) blockingQueue.take();
redisTemplate.delete(key);
System.out.println("延迟删除缓存: " + key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
3.6 缓存一致性注解
为了简化代码,可以创建一个自定义注解来处理缓存一致性:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CacheConsistency {
String value() default "";
String key() default "";
boolean delayDelete() default false;
long delayTime() default 1000;
}
然后创建一个切面来处理这个注解:
@Aspect
@Component
public class CacheConsistencyAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Around("@annotation(cacheConsistency)")
public Object around(ProceedingJoinPoint joinPoint, CacheConsistency cacheConsistency) throws Throwable {
// 获取方法参数
Object[] args = joinPoint.getArgs();
// 解析缓存键
String key = parseKey(cacheConsistency.key(), args);
// 判断是否使用延迟双删
if (cacheConsistency.delayDelete()) {
// 1. 先删除缓存
redisTemplate.delete(key);
// 2. 执行目标方法
Object result = joinPoint.proceed();
// 3. 延迟一段时间后再次删除缓存
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(redissonClient.getBlockingQueue("delayDeleteQueue"));
delayedQueue.offer(key, cacheConsistency.delayTime(), TimeUnit.MILLISECONDS);
return result;
} else {
// 1. 执行目标方法
Object result = joinPoint.proceed();
// 2. 删除缓存
redisTemplate.delete(key);
return result;
}
}
/**
* 解析缓存键
*/
private String parseKey(String key, Object[] args) {
// 简单实现,实际项目中可以使用 SpEL 表达式解析
return key;
}
}
使用自定义注解:
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
/**
* 读取商品信息
*/
@Cacheable(value = "product", key = "#id")
public Product getProductById(Long id) {
return productRepository.findById(id).orElse(null);
}
/**
* 更新商品信息(使用双写策略)
*/
@Transactional
@CacheConsistency(value = "product", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
/**
* 更新商品信息(使用延迟双删策略)
*/
@Transactional
@CacheConsistency(value = "product", key = "#product.id", delayDelete = true, delayTime = 1000)
public Product updateProductWithDelayDelete(Product product) {
return productRepository.save(product);
}
}
四、实战案例
4.1 业务场景
假设我们有一个电商系统,需要处理商品信息的读写操作。商品信息经常被查询,所以我们使用缓存来提高性能。同时,商品信息也会被频繁更新,所以需要确保缓存与数据库之间的一致性。
4.2 实现方案
-
读取商品信息:
- 首先从缓存中读取商品信息
- 如果缓存命中,直接返回商品信息
- 如果缓存未命中,从数据库读取商品信息,然后写入缓存,最后返回商品信息
-
更新商品信息:
- 使用延迟双删策略:先删除缓存,然后更新数据库,最后延迟一段时间后再次删除缓存
4.3 代码实现
4.3.1 商品实体类
@Entity
@Table(name = "product")
@Data
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
private String description;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@PrePersist
public void prePersist() {
createTime = LocalDateTime.now();
updateTime = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
updateTime = LocalDateTime.now();
}
}
4.3.2 商品仓库
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
}
4.3.3 商品服务
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Value("${cache.delay.delete.delay-time:1000}")
private long delayTime;
/**
* 读取商品信息
*/
@Cacheable(value = "product", key = "#id")
public Product getProductById(Long id) {
System.out.println("从数据库读取商品信息: " + id);
return productRepository.findById(id).orElse(null);
}
/**
* 更新商品信息(使用延迟双删策略)
*/
@Transactional
public Product updateProduct(Product product) {
// 1. 先删除缓存
redisTemplate.delete("product::" + product.getId());
System.out.println("删除缓存: product::" + product.getId());
// 2. 更新数据库
Product updatedProduct = productRepository.save(product);
System.out.println("更新数据库: " + product.getId());
// 3. 延迟一段时间后再次删除缓存
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(redissonClient.getBlockingQueue("delayDeleteQueue"));
delayedQueue.offer("product::" + product.getId(), delayTime, TimeUnit.MILLISECONDS);
System.out.println("添加延迟删除任务: product::" + product.getId());
return updatedProduct;
}
/**
* 处理延迟删除任务
*/
@PostConstruct
public void initDelayDeleteTask() {
RBlockingQueue<Object> blockingQueue = redissonClient.getBlockingQueue("delayDeleteQueue");
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
new Thread(() -> {
while (true) {
try {
String key = (String) blockingQueue.take();
redisTemplate.delete(key);
System.out.println("延迟删除缓存: " + key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
4.3.4 商品控制器
@RestController
@RequestMapping("/products")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
return productService.getProductById(id);
}
@PutMapping
public Product updateProduct(@RequestBody Product product) {
return productService.updateProduct(product);
}
}
4.4 测试场景
4.4.1 测试读取商品信息
- 第一次请求:从数据库读取商品信息,然后写入缓存
- 第二次请求:从缓存读取商品信息
4.4.2 测试更新商品信息
- 请求更新商品信息
- 系统先删除缓存
- 系统更新数据库
- 系统延迟一段时间后再次删除缓存
- 请求读取商品信息:从数据库读取新的商品信息,然后写入缓存
五、最佳实践
5.1 缓存设计最佳实践
原则:
- 合理设置缓存过期时间:根据业务场景设置合适的缓存过期时间
- 使用缓存预热:在系统启动时,预先加载热点数据到缓存
- 使用缓存降级:当缓存不可用时,直接从数据库读取数据
- 监控缓存命中率:定期监控缓存命中率,优化缓存策略
建议:
- 对于读多写少的场景,使用缓存可以显著提高性能
- 对于写多读少的场景,缓存的效果可能不明显,甚至会增加系统复杂性
- 对于实时性要求高的数据,应该谨慎使用缓存
5.2 双写策略最佳实践
原则:
- 先更新数据库,再删除缓存:这种策略比先删除缓存再更新数据库更安全
- 使用事务:确保数据库更新操作的原子性
- 处理缓存删除失败:当缓存删除失败时,应该有相应的处理机制
建议:
- 对于重要的数据,应该使用事务来确保数据库更新和缓存删除的一致性
- 对于非重要的数据,可以容忍短暂的不一致
5.3 延迟双删最佳实践
原则:
- 合理设置延迟时间:延迟时间应该大于数据库事务的执行时间
- 使用可靠的延迟队列:使用 Redis 的延迟队列或其他可靠的消息队列
- 监控延迟队列:确保延迟队列的正常运行
建议:
- 延迟时间一般设置为 1-5 秒,具体根据数据库事务的执行时间来调整
- 对于高并发场景,应该使用分布式延迟队列
5.4 并发控制最佳实践
原则:
- 使用分布式锁:在更新数据时,使用分布式锁来避免并发更新
- 使用乐观锁:在数据库层面使用乐观锁来避免并发更新
- 使用版本号:在缓存中存储数据的版本号,避免脏读
建议:
- 对于高并发场景,应该使用分布式锁来确保数据的一致性
- 对于低并发场景,可以使用乐观锁或版本号来避免并发问题
六、总结
缓存一致性是高并发系统中一个重要的挑战。通过本文的实现方案,开发者可以构建一个可靠的缓存一致性机制,避免缓存与数据库之间的不一致问题。特别是延迟双删策略,可以有效解决并发更新时的缓存一致性问题,防止脏读。
互动话题:
- 你在实际项目中遇到过缓存一致性的问题吗?是如何解决的?
- 你认为哪种缓存一致性策略最适合你的业务场景?
- 你有使用过其他缓存一致性的解决方案吗?
欢迎在评论区留言讨论!更多技术文章,欢迎关注公众号:服务端技术精选
标题:SpringBoot + 缓存一致性双写策略 + 延迟双删:先更新 DB 再删缓存,防脏读实战方案
作者:jiangyi
地址:http://jiangyi.space/articles/2026/04/12/1775887832422.html
公众号:服务端技术精选
- 前言
- 一、核心概念
- 1.1 缓存一致性
- 1.2 双写策略
- 1.3 延迟双删
- 1.4 脏读
- 二、技术方案
- 2.1 架构设计
- 2.2 技术选型
- 2.3 核心流程
- 三、Spring Boot 缓存一致性双写策略实现
- 3.1 依赖配置
- 3.2 配置文件
- 3.3 缓存配置
- 3.4 双写策略实现
- 3.5 延迟双删策略实现
- 3.6 缓存一致性注解
- 四、实战案例
- 4.1 业务场景
- 4.2 实现方案
- 4.3 代码实现
- 4.3.1 商品实体类
- 4.3.2 商品仓库
- 4.3.3 商品服务
- 4.3.4 商品控制器
- 4.4 测试场景
- 4.4.1 测试读取商品信息
- 4.4.2 测试更新商品信息
- 五、最佳实践
- 5.1 缓存设计最佳实践
- 5.2 双写策略最佳实践
- 5.3 延迟双删最佳实践
- 5.4 并发控制最佳实践
- 六、总结
评论