计算机系统应用教程网站

网站首页 > 技术文章 正文

如何保证redis缓存与数据库数据一致性

btikc 2024-11-30 19:06:09 技术文章 61 ℃ 0 评论

快速了解:

1、redis缓存与数据库数据一致性产生原因

2、解决数据一致性的常见方案

面试官可能问:

1、有这么一个场景,我读取redis数据的时候,发现与数据库的数据不一致,这种情况怎么规避呢?

2、redis缓存什么情况下导致与数据库数据不一致

3、如何保证redis缓存与数据库数据一致性


日常生产场景中,为了避免大量请求同时打在数据库上导致故障或者快速响应数据,数据库+缓存的方式已经成了日常标配。引入了缓存,必然会带来缓存一致性的问题,如果想达到强一致性,只能加锁,但是会损耗性能,并且降低了并发性,所以大部分追求的是数据的最终一致性。如何解决数据的最终一致性,到底是先操作数据库还是先操作缓存,这点可能困扰着很多人。


从大方向的解决方案看:

一、先操作缓存、再操作数据库

二、先操作数据库、再操作缓存

细分:

先更新或删除缓存,再操作数据库;先操作数据库,再更新或者删除缓存。操作缓存的话也有说法,是更新呢,还是删除呢,这里的缓存操作推荐直接删除而不是更新,因为可以尽可能的避免脏数据的读取,还有另外一个是懒加载的思想,因为每次更改数据之后,不一定立马就有人来用。如果缓存更新的成本很高的话(比如涉及到十几张表的查询计算),此时也会非常浪费性能资源。

从项目的解决场景中,衍生出了很多细致的解决方案,比如延迟双删、监听binglog删除以及重试方案等等,其目的都是保证数据最终一致性


常见的解决方案

方案:先更新数据库,后删除缓存/先删除缓存,再操作数据库

方案:延迟双删

延迟双删:首先,删除缓存中的数据;然后,更新数据库中的数据;最后,在一定的延迟(例如睡眠一段时间)后,再次删除缓存中的数据

演变1:睡眠一会再删。弊端:可能仍然存在旧数据的读取(发生的节点:1、在第一次删除缓存之后,未更新完数据库,就有新的线程来读取的,读取的仍然是旧值,尽管第二次有再次删除缓存的操作,也可能有删除失败的风险。2、或者例如线程2的读取写入缓存的时候,线程1的数据库更新事务仍未提交)

演变二:引入MQ进行重试删除。主要是规避演变1的二次缓存删除失败的场景,但同时也提高了系统代码的耦合度。

方案:异步监听binlog删除 + 重试

核心流程:

  1. 更新数据库
  2. 监听binlog删除缓存(databus、Canal)
  3. 缓存删除失败则通过MQ不断重试,直至删除成功

1)脏数据时间窗口“较大”

这个脏数据时间窗口较大,是相对同步删除来说。在你收到binlog之前,他中间要经过:binlog从主库同步到从库、binlog从库到binlog监听组件、binlog从监听组件发送到MQ、消费MQ消息,这些操作每个都是有一定的耗时的,可能是几十毫秒甚至几百毫秒,所以说它其实整体是有一个脏数据的时间窗口。

而同步删除是在更新完数据库后马上删除,时间窗口大概也就是1毫秒左右,所以说binlog的方式相对于同步删除,可能存在的脏数据窗口会稍微大一点。

2)极端场景下存在长期脏数据问题

  • binlog抓取组件宕机导致脏数据。该方案强依赖于监听binlog的组件,如果监听binlog组件出现宕机,则会导致大量脏数据。
  • 拆库拆表流程中可能存在并发脏数据


拆库拆表流程中并发脏数据问题

表A正在进行数据库拆分,当前进行到灰度切读流量阶段:部分读新库,部分读老库

数据库拆分大致流程:增量数据同步(双写)、全量数据迁移、数据一致性校验、灰度切读、切读完毕后停写老库。

小结:该方案在大多数场景下没有太大问题,业务比较小的场景可以使用,或者在其基础上进行适当补充。


读+缓存请求流程

读数据,如果缓存没有,则读mysql,并且回写到缓存的逻辑代码的实现?

双检加锁机制:加锁前从redis中查一次,加锁后再查一次。

@Service
@Slf4j
public class UserService {
    public static final String CACHE_KEY_USER = "user:";
    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 业务逻辑没有写错,对于小厂中厂(QPS<=1000)可以使用,但是大厂不行
     * @param id
     * @return
     */
    public User findUserById(Integer id)
    {
        User user = null;
        String key = CACHE_KEY_USER+id;

        //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
        user = (User) redisTemplate.opsForValue().get(key);

        if(user == null)
        {
            //2 redis里面无,继续查询mysql
            user = userMapper.selectByPrimaryKey(id);
            if(user == null)
            {
                //3.1 redis+mysql 都无数据
                //你具体细化,防止多次穿透,我们业务规定,记录下导致穿透的这个key回写redis
                return user;
            }else{
                //3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
                redisTemplate.opsForValue().set(key,user);
            }
        }
        return user;
    }


    /**
     * 加强补充,避免突然key失效了,打爆mysql,做一下预防,尽量不出现击穿的情况。
     * @param id
     * @return
     */
    public User findUserById2(Integer id)
    {
        User user = null;
        String key = CACHE_KEY_USER+id;

        //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
        // 第1次查询redis,加锁前
        user = (User) redisTemplate.opsForValue().get(key);
        if(user == null) {
            //2 大厂用,对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
            synchronized (UserService.class){
                //第2次查询redis,加锁后
                user = (User) redisTemplate.opsForValue().get(key);
                //3 二次查redis还是null,可以去查mysql了(mysql默认有数据)
                if (user == null) {
                    //4 查询mysql拿数据(mysql默认有数据)
                    user = userMapper.selectByPrimaryKey(id);
                    if (user == null) {
                        return null;
                    }else{
                        //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
                        redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
                    }
                }
            }
        }
        return user;
    }
}

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表