如何保证Redis缓存和数据库的数据一致性

前言

如果项目业务处于起步阶段,流量非常小,那无论是读请求还是写请求,直接操作数据库即可,这时架构模型是这样的:

graph LR A([应用系统]) -.读写数据库..- B[(数据库)]

但随着业务量的增长,项目业务请求量越来越大,这时如果每次都从数据库中读数据,那肯定会有性能问题。这个阶段通常的做法是,引入缓存来提高读性能,架构模型就变成了这样:

graph LR A([应用系统]) -.读写数据库..- B[(数据库)] A ---读数据--->C([Redis])

在实际开发过程中,缓存的使用频率是非常高的,只要使用缓存和数据库存储,就难免会出现双写时数据一致性的问题,就是 Redis 缓存的数据和数据库中保存的数据出现不相同的现象。

如上图所示,大多数人的很多业务操作都是根据这个图来做缓存的,这样能有效减轻数据库压力。但是一旦设计到双写或者数据库和缓存更新等操作,就很容易出现数据一致性的问题。无论是先写数据库,在删除缓存,还是先删除缓存,在写入数据库,都会出现数据一致性的问题。例如:

  • 先删除了redis缓存,但是因为其他什么原因还没来得及写入数据库,另外一个线程就来读取,发现缓存为空,则去数据库读取到之前的数据并写入缓存,此时缓存中为脏数据。
  • 如果先写入了数据库,但是在缓存被删除前,写入数据库的线程因为其他原因被中断了,没有删除掉缓存,就也会出现数据不一致的情况。

总的来说,写和读在多数情况下都是并发的,不能绝对保证先后顺序,就会很容易出现缓存和数据库数据不一致的情况,那我们又该如何解决呢?

一、谈谈一致性

首先,我们先来看看有哪几种一致性的情况呢?

graph TD A([一致性]) --> A1 & A2 & A3 A1[强一致性] A2[弱一致性] A3[最终一致性]
  • 最终一致性:如果你的项目对缓存的要求是强一致性的,那么请不要使用缓存。这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大。读请求和写请求会串行化,串到一个内存队列里去,这样会大大增加系统的处理效率,吞吐量也会大大降低。
  • 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态。
  • 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型。一般情况下,高可用只确保最终一致性,不确保强一致性。

二、 情景分析

2.1 针对读场景

A请求查询数据,如果命中缓存,那么直接取缓存数据返回即可。如果请求中不存在,数据库中存在,那么直接取数据库数据返回,然后将数据同步到Redis中。不会存在数据不一致的情况。

graph LR A([读]) --> B["缓存刚失效,导致未命中"] -->C[读取数据库] --> D[写入缓存] -->E([返回结果]) A -->B1[命中缓存] -->E style A fill:#6d5ffd style B fill:#b1f2ba style C fill:#b1f2ba style D fill:#b1f2ba style E fill:#b1f2ba style B1 fill:#29dc09

在高并发的情况下,A请求和B请求一起访问某条数据,如果缓存中数据存在,直接返回即可,如果不存在,直接取数据库数据返回即可。无论A请求B请求谁先谁后,本质上没有对数据进行修改,数据本身没变,只是从缓存中取还是从数据库中取的问题,因此不会存在数据不一致的情况。

因此,单独的读场景是不会造成Redis与数据库缓存不一致的情况,因此我们不用关心这种情况。

2.2 针对写场景

如果该数据在缓存中不存在,那么直接修改数据库中的数据即可,不会存在数据不一致的情况。

如果该数据在缓存中和数据库中都存在,那么就需要既修改缓存中的数据又修改数据库中的数据。如果写数据库的值与更新到缓存值是一样的,可以马上更新缓存;如果写数据库的值与更新缓存的值不一致,在高并发的场景下,还存在先后关系,这就会导致数据不一致的问题。例如:

  • 当更新数据时,如更新某商品的库存,当前商品的库存是100,现在要更新为99,先更新数据库更改成99,然后删除缓存,发现删除缓存失败了,这意味着数据库存的是99,而缓存是100,这导致数据库和缓存不一致。
  • 在高并发的情况下,如果当删除完缓存的时候,这时去更新数据库,但还没有更新完,另外一个请求来查询数据,发现缓存里没有,就去数据库里查,还是以上面商品库存为例,如果数据库中产品的库存是100,那么查询到的库存是100,然后插入缓存,插入完缓存后,原来那个更新数据库的线程把数据库更新为了99,导致数据库与缓存不一致的情况。

三、同步策略

想要保证缓存与数据库的双写一致,一共有4种方式,即4种同步策略:

graph LR A([同步策略]) --> B & C & D & E B(["1、先更新缓存,再更新数据库"]) C(["2、先更新数据库,再更新缓存"]) D(["3、先删除缓存,再更新数据库"]) E(["4、先更新数据库,再删除缓存"])

从这4种同步策略中,我们需要作出比较的是:更新缓存与删除缓存哪种方式更合适?应该先操作数据库还是先操作缓存?

3.1 先更新缓存,再更新数据库

这个方案我们一般不考虑。原因是当数据同步时,更新 Redis 缓存成功,但更新数据库出现异常时,会导致 Redis 缓存数据与数据库数据完全不一致,而且这很难察觉,因为 Redis 缓存中的数据一直都存在。

只要缓存进行了更新,后续的读请求基本上就不会出现缓存未命中的情况。但在某些业务场景下,更新数据的成本较大,并不是单纯将数据的数据查询出来丢到缓存中即可,而是需要连接很多张表组装对应数据存入缓存中,并且可能存在更新后,该数据并不会被使用到的情况。

3.2 先更新数据库,再更新缓存

这个方案我们一般也是不考虑。原因是当数据同步时,数据库更新成功,但 Redis 缓存更新失败,那么此时数据库中是最新值,Redis 缓存中是旧值。之后的应用系统的读请求读到的都是 Redis 缓存中旧数据。只有当 Redis 缓存数据失效后,才能从数据库中重新获得正确的值。

该方案还存在并发引发的一致性问题,假设同时有两个线程进行数据更新操作,如下图所示:

从上图可以看到,线程1虽然先于线程2发生,但线程2操作数据库和缓存的时间,却要比线程1的时间短,执行时序发生错乱,最终这条数据结果是不符合预期的。如果是写多读少的场景,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

3.3 先删除缓存,后更新数据库

这种方案只是尽可能保证一致性而已,极端情况下,还是有可能发生数据不一致问题,原因是当数据同步时,如果删除 Redis 缓存失败,更新数据库成功,那么此时数据库中是最新值,Redis 缓存中是旧值。之后的应用系统的读请求读到的都是 Redis 缓存中旧数据。只有当 Redis 缓存数据失效后,才能从数据库中重新获得正确的值。由于缓存被删除,下次查询无法命中缓存,需要在查询后将数据写入缓存,增加查询逻辑。同时在高并发的情况下,同一时间大量请求访问该条数据,第一条查询请求还未完成写入缓存操作时,这种情况,大量查询请求都会打到数据库,加大数据库压力。

该方案还存在并发引发的一致性问题,假设同时有两个线程进行数据更新操作,如下图所示。当缓存被线程一删除后,如果此时有新的读请求(线程二)发生,由于缓存已经被删除,这个读请求(线程二)将会去从数据库查询。如果此时线程一还没有修改完数据库,线程二从数据库读的数据仍然是旧值,同时线程二将读的旧值写入到缓存。线程一完成后,数据库变为新值,而缓存还是旧值。

从上图可见,先删除 Redis 缓存,后更新数据库,当发生读/写并发时,还是存在数据不一致的情况。如何解决呢?最简单的解决办法就是延时双删策略:先淘汰缓存、再写数据库、休眠后再次淘汰缓存。这样做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

java 复制代码
public void deleteRedisData(UserInfo userInfo){
    // 删除Redis中的缓存数据
    jedis.del(userInfo);
    // 更新MySQL数据库数据
    userInfoDao.update(userInfo);
    try {
        TimeUnit.SECONDS.sleep(2);
    } catch(Exception exp){
        exp.printStackTrace();
    }
    // 删除Redis中的缓存数据
    jedis.del(userInfo);
}

延时双删就能彻底解决不一致吗?当然不一定来。首先,我们评估的延时时间并不能完全代表实际运行过程中的耗时,运行过程如果因为系统压力过大,我们评估的耗时就是不准确,仍然会导致数据不一致的出现。其次,延时双删虽然在保证事务提交完以后再进行删除缓存,但是如果使用的是MySQL的读写分离的机构,主从同步之间其实也会有时间差。

3.4 先更新数据库,后删除缓存

实际使用中,建议采用这种方案。当然,这种方案其实一样也可能有失败的情况。

当数据同步时,如果更新数据库成功,而删除 Redis 缓存失败,那么此时数据库中是最新值,Redis 缓存中是旧值。之后的应用系统的读请求读到的都是 Redis 缓存中旧数据。只有当 Redis 缓存数据失效后,才能从数据库中重新获得正确的值。读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。更新的时候,先更新数据库,然后再删除缓存。

该方案还存在并发引发的一致性问题,假设同时有两个线程进行数据更新操作,如下图所示。当数据库的数据被更新后,如果此时缓存还没有被删除,那么缓存中的数据仍然是旧值。如果此时有新的读请求(查询数据)发生,由于缓存中的数据是旧值,这个读请求将会获取到旧值。当缓存刚好失效,这时有请求来读缓存(线程一),未命中缓存,然后到数据库中读取,在要写入缓存时,线程二来修改了数据库,而线程一写入缓存的是旧的数据,导致了数据的不一致。

四、解决办法

当我们在应用中同时使用MySQL和Redis时,如何保证两者的数据一致性呢?下面就来分享几种实用的解决方案。

4.1 双写一致性

最直接的办法就是在业务代码中同时对MySQL和Redis进行更新。通常我们会先更新MySQL,然后再更新Redis。

java 复制代码
// 更新MySQL
userMapper.update(user);
// 更新Redis
redisTemplate.opsForValue().set("user_" + user.getId(), user);

这种方式最大的问题就是在于网络故障或者程序异常的情况下,可能会导致MySQL和Redis中的数据不一致。因此,我们需要额外的手段来检测和修复数据不一致的情况。

4.2 异步更新(异步通知)

在更新数据库数据时,同时发送一个异步通知给Redis,让Redis知道数据库数据已经更新,需要更新缓存中的数据。这个过程是异步的,不会阻塞数据库的更新操作。当Redis收到异步通知后,会立即删除缓存中对应的数据,确保缓存中没有旧数据。这样,即使在这个过程中有新的读请求发生,也不会读取到旧数据。等到数据库更新完成后,Redis再次从数据库中读取最新的数据并缓存起来。

java 复制代码
// 更新MySQL
userMapper.update(user);
// 发送消息
rabbitTemplate.convertAndSend("updateUser", user.getId());

/**
 * 然后在消息消费者中更新Redis。
 */
@RabbitListener(queues = "updateUser")
public void updateUser(String userId) {
    User user = userMapper.selectById(userId);
    redisTemplate.opsForValue().set(redisTemplate.opsForValue().set("user_" + user.getId(), user);
}

这种异步通知的方式,可以确保Redis中的数据与数据库中的数据保持一致,避免出现数据不一致的情况。这种方案可以降低数据不一致的风险,但仍然无法完全避免。因为消息队列本身也可能因为各种原因丢失消息。

4.3 使用Redis的事务支持

Redis提供了事务(Transaction)支持,可以将一系列的操作作为一个原子操作执行。我们可以利用Redis的事务来实现MySQL和Redis的原子更新。

java 复制代码
redisTemplate.execute(new Sessioncallback<Object>(){
    @0verridepublic Object execute(RedisOperations operations) throws DataAccessException {
        // 开启事务
        operations.multi();
        // 更新MySQL
        userMapper.update(user);
        // 更新Redis
        operations.opsForValue().set("user_" + user.getId(),user);
        // 执行事务
        operations.exec();
        return null;
    }
});

使用Redis事务可以确保MySQL和Redis的更新在同一事务中执行,避免了中间出现不一致的情况。但需要注意的是,Redis的事务并非严格的ACID事务,可能存在部分成功的情况。

4.4 用 Redisson 实现读锁和写锁

Redisson 是一个基于 Redis 的分布式 Java 对象存储和缓存框架,它提供了丰富的功能和 API 来操作 Redis 数据库,其中包括了读写锁的支持。读写锁是一种常用的并发控制机制,它允许多个线程同时读取共享资源,但在写操作时互斥,只允许一个线程进行写操作。使用 Redisson 的读写锁方法:

  1. 获取读锁:通过 Redisson 的 RReadWriteLock 对象的 readLock() 方法获取读锁。在获取读锁后,可以并发读取共享资源,不会阻塞其他获取读锁的线程。
  2. 获取写锁:通过 Redisson 的 RReadWriteLock 对象的 writeLock() 方法获取写锁。在获取写锁后,其他获取读锁和写锁的线程将被阻塞,只有当前线程能够进行写操作。
  3. 释放锁:使用完读锁或写锁后,应该及时调用 unlock() 方法释放锁,以便其他线程可以获取锁并进行操作。在 Redisson 中,读锁和写锁都继承自锁对象 RLock,因此可以使用 RLock 的 unlock() 方法来释放锁。

下面是一个使用 Redisson 读写锁的示例,通过 Redisson 的 RReadWriteLock 对象获取读锁和写锁,并在需要的代码段中进行相应的操作。执行完操作后,使用 unlock() 方法释放锁,最后关闭 Redisson 客户端。

java 复制代码
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonReadWriteLockExample {
    public static void main(String[] args) {
        // 创建 Redisson 客户端
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        RedissonClient redisson = Redisson.create(config);
        
        // 获取读写锁
        RReadWriteLock rwLock = redisson.getReadWriteLock("myLock");
        RLock readLock = rwLock.readLock();
        RLock writeLock = rwLock.writeLock();
        
        try {
            // 获取读锁并进行读操作
            readLock.lock();
            // 读取共享资源
            
            // 获取写锁并进行写操作
            writeLock.lock();
            // 写入共享资源
        } finally {
            // 释放锁
            writeLock.unlock();
            readLock.unlock();
        }
        
        // 关闭 Redisson 客户端
        redisson.shutdown();
    }
}

五、结语

综上所述,我们提供了更全面的MySQL与Redis数据一致性解决方案。根据具体的业务需求和系统环境,选择合适的方案可以提高数据一致性的可靠性。然而,每种方案都有其优缺点和适用场景,需要综合考虑权衡。

对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。

把今天最好的表现当作明天最新的起点......~

相关推荐
瓜牛_gn38 分钟前
mysql特性
数据库·mysql
奶糖趣多多2 小时前
Redis知识点
数据库·redis·缓存
CoderIsArt3 小时前
Redis的三种模式:主从模式,哨兵与集群模式
数据库·redis·缓存
paopaokaka_luck4 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
师太,答应老衲吧5 小时前
SQL实战训练之,力扣:2020. 无流量的帐户数(递归)
数据库·sql·leetcode
码农小旋风5 小时前
详解K8S--声明式API
后端
Peter_chq5 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
Yaml46 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~6 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616886 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端