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

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

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。
  • 原则:不追求绝对强一致性,否则应放弃缓存。
相关推荐
一条泥憨鱼13 小时前
【Java 进阶】LinkedHashMap 与 TreeMap
java·开发语言·数据结构·笔记·后端·学习
ゆづき13 小时前
假如编程语言们有外号
java·c语言·c++·python·学习·c#·生活
凤山老林13 小时前
63-Java LinkedList(链表)
java·开发语言·链表
恣艺13 小时前
用Go从零实现一个高性能KV存储引擎:B+Tree索引、WAL持久化、LRU缓存的工程实践
开发语言·数据库·redis·缓存·golang
TDengine (老段)13 小时前
TDengine 支持数据类型深度解析 — 类型体系、存储编码与选型指南
java·大数据·数据库·系统架构·时序数据库·tdengine·涛思数据
浮尘笔记15 小时前
Java Snowy框架CI/CD云效自动化部署流程
java·运维·服务器·阿里云·ci/cd·自动化
weelinking1 天前
【产品】00_产品经理用Claude实现产品系列介绍
数据库·人工智能·sql·数据挖掘·github·产品经理
一直不明飞行1 天前
Java的equals(),hashCode()应该在什么时候重写
java·开发语言·jvm
REDcker1 天前
有限状态机与状态模式详解 FSM建模Java状态模式与C++表驱动模板实践
java·c++·状态模式