SpringBoot + 缓存一致性双写策略 + 延迟双删:先更新 DB 再删缓存,防脏读实战方案

前言

在高并发系统中,缓存是提升性能的重要手段。然而,缓存与数据库之间的一致性问题一直是开发者面临的挑战。当数据发生变化时,如何确保缓存中的数据与数据库中的数据保持一致,是一个需要认真考虑的问题。

想象一下这样的场景:用户A更新了某个商品的价格,系统先更新了数据库,然后删除了缓存。此时,用户B刚好查询该商品的价格,系统发现缓存不存在,于是从数据库读取新价格并写入缓存。这看起来是正常的流程。但如果用户A更新数据时,系统先删除了缓存,然后更新数据库,此时用户B查询时可能会读取到旧数据并写入缓存,导致缓存与数据库不一致。

如何解决这个问题? 本文将详细介绍缓存一致性的双写策略和延迟双删方案,帮助你构建一个可靠的缓存一致性机制。

一、核心概念

1.1 缓存一致性

缓存一致性是指缓存中的数据与数据库中的数据保持一致的状态。在分布式系统中,由于网络延迟、并发操作等因素,缓存与数据库之间可能会出现数据不一致的情况。

1.2 双写策略

双写策略是指在更新数据时,同时更新数据库和缓存。常见的双写策略有两种:

  • 先更新数据库,再更新缓存:这种策略可能会导致数据不一致,因为在更新数据库和更新缓存之间,可能有其他线程读取到旧数据。
  • 先更新数据库,再删除缓存:这种策略相对安全,因为删除缓存后,后续的读取操作会从数据库重新加载数据到缓存。

1.3 延迟双删

延迟双删是指在更新数据时,先删除缓存,然后更新数据库,最后再延迟一段时间后再次删除缓存。这种策略可以解决并发更新时的缓存一致性问题。

1.4 脏读

脏读是指一个事务读取到了另一个事务未提交的数据。在缓存场景中,脏读通常是指读取到了缓存中过时的数据。

二、技术方案

2.1 架构设计

缓存一致性双写策略的架构设计主要包括以下几个部分:

  1. 数据层:数据库,存储业务数据
  2. 缓存层:Redis 或其他缓存系统,存储热点数据
  3. 服务层:Spring Boot 应用,负责业务逻辑和缓存管理
  4. 同步层:确保缓存与数据库之间的一致性

2.2 技术选型

  • Spring Boot:作为基础框架,提供依赖注入、配置管理等功能
  • Spring Data JPA:用于操作数据库
  • Redis:作为缓存系统
  • Spring Cache:提供缓存抽象
  • Redisson:提供分布式锁和延迟队列
  • Lombok:简化代码

2.3 核心流程

  1. 读取数据

    • 首先从缓存中读取数据
    • 如果缓存命中,直接返回数据
    • 如果缓存未命中,从数据库读取数据,然后写入缓存,最后返回数据
  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 实现方案

  1. 读取商品信息

    • 首先从缓存中读取商品信息
    • 如果缓存命中,直接返回商品信息
    • 如果缓存未命中,从数据库读取商品信息,然后写入缓存,最后返回商品信息
  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 测试读取商品信息

  1. 第一次请求:从数据库读取商品信息,然后写入缓存
  2. 第二次请求:从缓存读取商品信息

4.4.2 测试更新商品信息

  1. 请求更新商品信息
  2. 系统先删除缓存
  3. 系统更新数据库
  4. 系统延迟一段时间后再次删除缓存
  5. 请求读取商品信息:从数据库读取新的商品信息,然后写入缓存

五、最佳实践

5.1 缓存设计最佳实践

原则

  • 合理设置缓存过期时间:根据业务场景设置合适的缓存过期时间
  • 使用缓存预热:在系统启动时,预先加载热点数据到缓存
  • 使用缓存降级:当缓存不可用时,直接从数据库读取数据
  • 监控缓存命中率:定期监控缓存命中率,优化缓存策略

建议

  • 对于读多写少的场景,使用缓存可以显著提高性能
  • 对于写多读少的场景,缓存的效果可能不明显,甚至会增加系统复杂性
  • 对于实时性要求高的数据,应该谨慎使用缓存

5.2 双写策略最佳实践

原则

  • 先更新数据库,再删除缓存:这种策略比先删除缓存再更新数据库更安全
  • 使用事务:确保数据库更新操作的原子性
  • 处理缓存删除失败:当缓存删除失败时,应该有相应的处理机制

建议

  • 对于重要的数据,应该使用事务来确保数据库更新和缓存删除的一致性
  • 对于非重要的数据,可以容忍短暂的不一致

5.3 延迟双删最佳实践

原则

  • 合理设置延迟时间:延迟时间应该大于数据库事务的执行时间
  • 使用可靠的延迟队列:使用 Redis 的延迟队列或其他可靠的消息队列
  • 监控延迟队列:确保延迟队列的正常运行

建议

  • 延迟时间一般设置为 1-5 秒,具体根据数据库事务的执行时间来调整
  • 对于高并发场景,应该使用分布式延迟队列

5.4 并发控制最佳实践

原则

  • 使用分布式锁:在更新数据时,使用分布式锁来避免并发更新
  • 使用乐观锁:在数据库层面使用乐观锁来避免并发更新
  • 使用版本号:在缓存中存储数据的版本号,避免脏读

建议

  • 对于高并发场景,应该使用分布式锁来确保数据的一致性
  • 对于低并发场景,可以使用乐观锁或版本号来避免并发问题

六、总结

缓存一致性是高并发系统中一个重要的挑战。通过本文的实现方案,开发者可以构建一个可靠的缓存一致性机制,避免缓存与数据库之间的不一致问题。特别是延迟双删策略,可以有效解决并发更新时的缓存一致性问题,防止脏读。

互动话题

  1. 你在实际项目中遇到过缓存一致性的问题吗?是如何解决的?
  2. 你认为哪种缓存一致性策略最适合你的业务场景?
  3. 你有使用过其他缓存一致性的解决方案吗?

欢迎在评论区留言讨论!更多技术文章,欢迎关注公众号:服务端技术精选


标题:SpringBoot + 缓存一致性双写策略 + 延迟双删:先更新 DB 再删缓存,防脏读实战方案
作者:jiangyi
地址:http://jiangyi.space/articles/2026/04/12/1775887832422.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消