Redis 缓存一致性是指 Redis 中的数据与数据库中的数据保持一致。由于数据库操作和缓存操作通常无法处于同一个本地事务中,因此在并发、网络异常或缓存删除失败时,可能出现数据库已经更新但 Redis 仍保存旧数据的问题。

常见方案是采用 Cache Aside 模式:查询时先查缓存,缓存未命中再查数据库并回填;更新时先更新数据库,再删除缓存,而不是直接更新缓存。同时为缓存设置 TTL,并通过消息队列、重试机制或监听 MySQL Binlog 的方式保证缓存删除最终成功。对于余额、支付等强一致性业务,不能只依赖普通缓存,应以数据库或事务系统中的数据为准。
所谓缓存一致性,通常指:
Redis中缓存的数据,与数据库中的真实数据保持一致。

public User gerUser(Long id) {
    String key = "user:" + id;
    // 1、从Redis缓存中取数据
    User user = redisTemplate.posForValue().get(key);
    if(user != null) {
        return user;
    }
    // 2、缓存未命中,查数据库
    user = userMapper.selectById(id);
    
    //3、将查到的结果写入Redis缓存
    redisTemplate.opsForValue().set(key, user);
    
    return user;
}
  • 读取数据时先从Redis缓存中查找数据,如果没找到再去数据库中查找,最后将数据库结果写入Redis;
  • 更新数据时先更新数据库,然后删除Redis缓存

常见的更新方案:

  1. 先更新数据库,再更新缓存
    一般不推荐,例如:

    线程A:数据库更新为A
    线程B:数据库更新为B
    线程 B:缓存更新为 B
    线程 A:缓存更新为 A

    最终的结果:

    数据库:B
    缓存:A

    因为数据库更新顺序和缓存更新顺序可能不一致。
    另外,更新缓存可能需要复杂计算,而这份缓存之后不一定会被读取,存在无效写入。

  2. 先删除缓存,再更新数据库
    同样存在问题:

    线程 A:删除缓存
    线程 B:查询缓存,发现不存在
    线程 B:读取数据库旧数据
    线程 A:更新数据库新数据
    线程 B:把旧数据写入缓存

    结果:

    数据库:新数据
    缓存:旧数据
  3. 先更新数据库,再删除缓存
    Cache Aside Pattern,旁路缓存模式
    示例:

    @Transactional
    public void updateUser(User user) {
         userMapper.updateById(user);
    
         redisTemplate.delete("user:" + user.getId());
    }

    然而先更新数据库,在删除缓存的方式并不能保证数据库和Redis缓存绝对一致,如果存在两个并发线程,线程A查询Redis未命中,再去读取数据库旧数据,线程B更新数据库为新数据,并删除Redis缓存,之后线程A将刚刚读取到的旧数据写入Redis,最终Redis中仍然可能是旧数据。(这种情况成立的条件比较苛刻:读数据的操作必须比更新数据库+删除缓存的过程还慢。因此大多数业务中,概率相对较低)为了进一步降低风险,可以采用以下措施:

  4. 设置缓存过期时间TTL

    redisTemplate.opsForValue().set(
         "user:" + id,
         user,
         20,
         TimeUnit.MINUTES
    );

    TTL无法解决实时一致性问题

  5. 延迟双删

    1、删除Redis缓存
    2、更新数据库
    3、等待一段时间
    4、再次删除缓存
    public void updateUser(User user) {
         String key = "user:" + user.getId();
         // 第一次删除缓存
         redisTemplate.delete(key);
         userMapper.updateById(user);
    
         CompletableFuture.runAsync(() -> {
             try {
                 Thread.sleep(500);
                 // 第二次删除用于清理并发线程可能重新写入的旧缓存。
                 redisTemplate.delete(key);
             } catch (InterruptedException e) {
                 Thread.currentThread().interrupt();
             }
         });
    }
  6. 删除失败后进行重试
    最典型的失败:数据库更新成功,但是Redis删除失败。
    为解决这个问题,可以把缓存删除操作发送到消息队列:数据库更新成功-->发送“删除缓存”消息-->消费者删除Redis-->失败则继续重试。
    为了保证消息最终被消费,通常还会配合消息重试、死信队列、操作日志、人工补偿、幂等处理。因为删除同一个Redis Ley多次没有副作用,因此缓存删除操作适合做幂等重试。
  7. 监听数据库Binlog
    更可靠的方案是监听MySQL Binlog:

    业务程序更新 MySQL
         ↓
    MySQL 产生 Binlog
         ↓
    Canal / Debezium 监听 Binlog
         ↓
    发送变更消息
         ↓
    删除或更新 Redis

    常见架构为:MySQL → Binlog → Canal → Kafka/RocketMQ → Redis

  8. 使用分布式锁
    对一致性要求较高的热点数据,可以让缓存重建和数据库更新互斥:

    获取锁
      ↓
    检查缓存
      ↓
    查询数据库
      ↓
    写入缓存
      ↓
    释放锁

    但是这回增加系统复杂度和延迟,通常只对热点Key使用,不会给所有缓存操作都加锁

    实际系统需要根据业务选择一致性等级:
    最终一致性
    允许短时间内缓存与数据库不同,但最终会恢复一致。
    适用于:

    • 商品详情
    • 用户资料
    • 文章内容
    • 商品列表
    • 普通配置信息

    强一致性
    每次读取都必须获得最新数据。
    适用于:

    • 余额扣减
    • 库存最终确认
    • 支付状态
    • 交易结果
    • 权限立即撤销

    这类数据不能简单依赖普通 Redis 缓存。通常应以数据库或专门的事务系统为准,或者在关键操作中直接绕过缓存。
    大部分 Redis 缓存场景追求的是最终一致性。

手段作用说明
设置过期时间TTL最终兜底即使删除失败,缓存也会自动过期
缓存空值防止缓存穿透数据库查不到时也缓存一个空对象,设置较短过期时间
分布式锁防止缓存击穿热点 key 失效时,只允许一个线程去查数据库
Canal / Maxwell强一致性同步监听 MySQL binlog,自动更新/删除 Redis(最彻底但复杂度高)

标签:无

你的评论