Redis缓存和数据库的数据一致性的问题解决

 更新时间:2024年09月20日 10:43:13   作者:普通网友  
随业务增长,直接操作数据库性能下降,引入缓存提高读性能常见,但缓存和数据库的双写操作会引发数据不一致问题,本文讨论几种常用同步策略,感兴趣的可以了解一下

前言

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

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

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

image

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

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

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

一、谈谈一致性

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

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

二、 情景分析

2.1 针对读场景

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

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

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

2.2 针对写场景

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

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

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

三、同步策略

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

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

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

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

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

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

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

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

image

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

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

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

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

image

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

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 缓存数据失效后,才能从数据库中重新获得正确的值。读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。更新的时候,先更新数据库,然后再删除缓存。

image

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

image

四、解决办法

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

4.1 双写一致性

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

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

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

4.2 异步更新(异步通知)

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

// 更新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的原子更新。

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 的读写锁方法:

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

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

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数据一致性解决方案。根据具体的业务需求和系统环境,选择合适的方案可以提高数据一致性的可靠性。然而,每种方案都有其优缺点和适用场景,需要综合考虑权衡。

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

到此这篇关于Redis缓存和数据库的数据一致性的问题解决的文章就介绍到这了,更多相关Redis缓存和数据库的数据一致性内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • RedisTemplate中boundHashOps的使用小结

    RedisTemplate中boundHashOps的使用小结

    redisTemplate.boundHashOps(key) 是 RedisTemplate 类的一个方法,本文主要介绍了RedisTemplate中boundHashOps的使用小结,具有一定的参考价值,感兴趣的可以了解一下
    2024-04-04
  • 利用Redis进行数据缓存的项目实践

    利用Redis进行数据缓存的项目实践

    在实际的业务场景中,Redis 一般和其他数据库搭配使用,用来减轻后端数据库的压力,本文就介绍了利用Redis进行数据缓存的项目实践,具有一定的参考价值,感兴趣的可以了解一下
    2022-06-06
  • Redis获取某个大key值的脚本实例

    Redis获取某个大key值的脚本实例

    这篇文章主要给大家分享介绍了关于Redis获取某个大key值的一个脚本实例,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧。
    2018-04-04
  • Redis特殊数据类型Geospatial地理空间

    Redis特殊数据类型Geospatial地理空间

    这篇文章主要为大家介绍了Redis特殊数据类型Geospatial地理空间,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-05-05
  • redis中使用java脚本实现分布式锁

    redis中使用java脚本实现分布式锁

    这篇文章主要介绍了redis中使用java脚本实现分布式锁,本文同时讲解了java脚本和lua脚本实现分布式锁,需要的朋友可以参考下
    2015-01-01
  • 详解redis分布式锁(优化redis分布式锁的过程及Redisson使用)

    详解redis分布式锁(优化redis分布式锁的过程及Redisson使用)

    在分布式的开发中,以电商库存的更新功能进行讲解,在实际的应用中相同功能的消费者是有多个的,这篇文章主要介绍了redis分布式锁详解(优化redis分布式锁的过程及Redisson使用),需要的朋友可以参考下
    2021-11-11
  • Redis中序列化的两种实现

    Redis中序列化的两种实现

    本文主要介绍了Redis中序列化的两种实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2024-07-07
  • Redis 事务与过期时间详细介绍

    Redis 事务与过期时间详细介绍

    这篇文章主要介绍了Redis 事务与过期时间详细介绍的相关资料,需要的朋友可以参考下
    2017-05-05
  • 利用Redis的有序集合实现排行榜功能实例代码

    利用Redis的有序集合实现排行榜功能实例代码

    这篇文章主要给大家介绍了关于如何利用Redis的有序集合实现排行榜功能的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者使用Redis具有一定的参考学习价值,需要的朋友们下面来一起学习学习吧
    2019-03-03
  • 安装redis(windows和Ubuntu)详解

    安装redis(windows和Ubuntu)详解

    这篇文章主要介绍了Redis在Ubuntu和Windows下的安装,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-04-04

最新评论