怎么保证缓存和数据库的一致性

保证缓存和数据库的一致性是一个经典难题,主要因为两者独立更新且无法实现原子操作。在高并发场景下,如果不加控制,可能出现缓存与数据库数据不一致的问题。

Java 代码 完整说明缓存与数据库一致性方案。所有示例代码均采用 Java(Spring Boot + RedisTemplate 风格),并保留了方案对比与避坑指南。


一、最常用方案:旁路缓存(Cache Aside Pattern)

核心规则

  • 读:先查缓存,命中返回;未命中查数据库,再写入缓存。
  • 写:先更新数据库,然后删除缓存(而不是更新缓存)。

为什么删除而不是更新?

避免并发写导致缓存脏数据,更新缓存可能因并发写导致缓存与数据库值不一致,且多次更新会浪费性能。

删除操作幂等且简单。删除缓存则让下次读时重新加载,简单可靠。

Java 代码示例(基于 Spring Boot + RedisTemplate)

java 复制代码
@Service
public class UserService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private UserRepository userRepository;

    private static final String USER_CACHE_PREFIX = "user:";

    // 读操作:旁路缓存
    public User getUser(Long id) {
        String cacheKey = USER_CACHE_PREFIX + id;
        // 1. 查缓存
        User user = (User) redisTemplate.opsForValue().get(cacheKey);
        if (user != null) {
            return user;
        }
        // 2. 缓存未命中,查数据库
        user = userRepository.findById(id).orElse(null);
        if (user != null) {
            // 3. 写入缓存(可设置合理过期时间)
            redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);
        }
        return user;
    }

    // 写操作:先更新数据库,再删除缓存
    @Transactional
    public void updateUser(User user) {
        // 1. 更新数据库
        userRepository.save(user);
        // 2. 删除缓存
        String cacheKey = USER_CACHE_PREFIX + user.getId();
        redisTemplate.delete(cacheKey);
    }
}

该方案的并发风险与"缓存双删"

极低概率下会出现:读线程读到旧数据后,写线程删除了缓存,但读线程又把旧数据写回缓存。
解决方案:延迟双删 -- 更新数据库后,先删缓存,等待一小段时间(大于一次并发读写的耗时),再次删除。

java 复制代码
@Transactional
public void updateUserWithDoubleDelete(User user) {
    String cacheKey = USER_CACHE_PREFIX + user.getId();
    
    // 1. 第一次删除缓存
    redisTemplate.delete(cacheKey);
    
    // 2. 更新数据库
    userRepository.save(user);
    
    // 3. 延迟一段时间(例如 500ms),再次删除缓存
    new Thread(() -> {
        try {
            Thread.sleep(500); // 实际应使用更优雅的调度线程池
            redisTemplate.delete(cacheKey);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }).start();
}

生产建议 :不要直接用 new Thread,可使用 ScheduledExecutorService 或消息队列延时消息。


二、解耦方案:订阅数据库变更日志(Canal + MQ)

流程:业务代码只操作数据库 → Canal 监听 binlog → 发 MQ → 消费者删除缓存。

优点:完全解耦,即使缓存删除失败,MQ 重试机制保证最终一致。

Java 消费者示例(Spring Boot + RocketMQ)

java 复制代码
@Component
@RocketMQMessageListener(topic = "user-update-topic", consumerGroup = "cache-consume-group")
public class CacheDeleteConsumer implements RocketMQListener<String> {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void onMessage(String userIdStr) {
        Long userId = Long.valueOf(userIdStr);
        String cacheKey = "user:" + userId;
        redisTemplate.delete(cacheKey);
        log.info("删除缓存成功,key: {}", cacheKey);
    }
}

对应业务代码只需更新数据库,无需触碰缓存:

java 复制代码
@Transactional
public void updateUser(User user) {
    userRepository.save(user);
    // 可选:发送一条MQ消息作为保险(Canal 负责主要删除,这里可省略)
    // mqProducer.send("user-update-topic", String.valueOf(user.getId()));
}

Canal 配置方法(MySQL binlog 开启 ROW 模式)


三、兜底方案:设置缓存过期时间(TTL)

无论用哪种方案,永远给缓存设置一个合理的 TTL (如 30 分钟)。

即使所有删除机制都失败,数据最终也会自动从数据库重新加载。

java 复制代码
// 写入缓存时统一设置过期时间
redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);

四、强一致性方案:分布式锁(读写串行化)

适用场景 :极少数对一致性要求极高、且并发量不高的场景(如金融库存)。
代价:吞吐量大幅下降。

Java 示例(Redisson 分布式锁)

java 复制代码
@Autowired
private RedissonClient redissonClient;

public User updateUserWithLock(User user) {
    String lockKey = "lock:user:" + user.getId();
    RLock lock = redissonClient.getLock(lockKey);
    try {
        lock.lock(3, TimeUnit.SECONDS);
        // 1. 更新数据库
        userRepository.save(user);
        // 2. 删除缓存
        redisTemplate.delete("user:" + user.getId());
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
    return user;
}

public User getUserWithLock(Long id) {
    String lockKey = "lock:user:" + id;
    RLock lock = redissonClient.getLock(lockKey);
    try {
        lock.lock(1, TimeUnit.SECONDS);
        // 1. 查缓存
        User user = (User) redisTemplate.opsForValue().get("user:" + id);
        if (user != null) return user;
        // 2. 查数据库
        user = userRepository.findById(id).orElse(null);
        if (user != null) {
            redisTemplate.opsForValue().set("user:" + id, user, 30, TimeUnit.MINUTES);
        }
        return user;
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

五、不同场景的方案选择(决策表)

业务场景 推荐方案 可容忍不一致时间
商品信息、用户资料(读多写少) 旁路缓存 + TTL 几分钟
库存、账户余额(写较多) 延迟双删 / Canal+MQ 秒级
排行榜、计数器(允许误差) 只写缓存,异步回写 DB 分钟~小时
支付、订单状态(强要求) 直接读数据库,不用缓存 0 容忍

六、避坑指南(两个致命错误)

❌ 错误 1:先删缓存,再更新数据库

java 复制代码
// 错误示范
redisTemplate.delete(cacheKey);
userRepository.save(user);   // 此时若另一个线程读并写回旧数据,缓存永久脏

后果:高并发下缓存长时间为旧值,直到 TTL 过期。

❌ 错误 2:写请求中直接更新缓存(而非删除)

java 复制代码
// 错误示范
userRepository.save(user);
redisTemplate.opsForValue().set(cacheKey, user);  // 可能导致并发顺序错乱

后果:两个写请求乱序,缓存与数据库值相反。


七、最终推荐架构(Java 生产实践)

java 复制代码
// 1. 写操作:延迟双删 + 事务注解
@Transactional
public void updateProduct(Product product) {
    String cacheKey = "product:" + product.getId();
    // 第一次删除
    redisTemplate.delete(cacheKey);
    // 更新数据库
    productRepository.save(product);
    // 延迟删除(使用线程池)
    scheduledExecutor.schedule(() -> redisTemplate.delete(cacheKey), 500, TimeUnit.MILLISECONDS);
}

// 2. 读操作:旁路缓存 + TTL
public Product getProduct(Long id) {
    String cacheKey = "product:" + id;
    Product p = (Product) redisTemplate.opsForValue().get(cacheKey);
    if (p != null) return p;
    p = productRepository.findById(id).orElse(null);
    if (p != null) {
        redisTemplate.opsForValue().set(cacheKey, p, 10, TimeUnit.MINUTES);
    }
    return p;
}

// 3. 全局兜底:在配置类中为所有 Redis 缓存设置默认 TTL
@Bean
public RedisCacheManagerBuilderCustomizer customizer() {
    return builder -> builder
        .withDefaultCacheConfiguration(RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30)));
}

总结

  • 核心:Cache Aside(先 DB 后删缓存)。
  • 加强:延迟双删 或 Canal+MQ。
  • 底线:永远设置 TTL。
  • 原则:不追求绝对强一致性,否则应放弃缓存。
相关推荐
aXin_ya1 分钟前
乐尚代驾,总结
java
仙俊红6 分钟前
Java JUC:CompletableFuture 详解,多个任务并行执行并等待全部完成
java·python·spring
BomanGe29 分钟前
NSK直线导轨LH55EL与NH55EM替代指南
前端·javascript·数据库·经验分享·规格说明书
JAVA面经实录9179 分钟前
MongoDB(文档型 NoSQL)
java·数据库·mongodb·nosql
cfm_29149 分钟前
JVM类加载机制初步了解
java·jvm
让我上个超影吧11 分钟前
Cluade code:上下文压缩
java·服务器·ai
睡不醒男孩03082312 分钟前
第十篇:PostgreSQL 生产环境高可用选型:CLUP 与 Patroni 深度架构对比与踩坑实录
数据库·postgresql·架构
JAVA面经实录91714 分钟前
HBase 知识点梳理(文档型 NoSQL)
大数据·数据库·nosql数据库·hbase
plainGeekDev14 分钟前
批量写入 → Room 事务
android·java·kotlin
宋哥转AI14 分钟前
MCP 第一天我没写@Tool,先在一个大仓库里划这三层
java·agent·mcp