【Redis】Redis缓存双写一致性之更新策略

文章探讨了在使用Redis作为缓存时如何处理与数据库双写一致性的问题,包括先写数据库还是先写缓存的选择、延时双删策略以及双检加锁策略。同时,文章提到了不同更新策略可能导致的数据不一致情况,并给出了解决这些问题的方法,如使用消息队列确保最终一致性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

介绍

在这里插入图片描述

面试题

1、只要用到缓存,就可能会涉及到Redis缓存与数据库双存储双写,只要是双写,就一定会有数据一致性问题,怎么解决一致性问题?

2、双写一致性,先动缓存redis还是数据库mysql?为什么?

3、延时双删?有哪些问题?

4、有一种情况,微服务查询redis无mysql有,为保证数据双写一致性回写redis需要注意什么?

5、双检加锁策略?如何避免缓存击穿?

6、redis和mysql双写一定会出现纰漏,做不到强一致性,如何保证最终一致性?

双写一致性

Redis中有数据,需要和数据库中的值相同。

Redis中无数据,数据库中的值是最新的值,并且准备回写Redis。

缓存按照操作划分

  • 只读缓存

  • 读写缓存

    • 同步直写策略

      • 写数据库后同步写Redis缓存,缓存中的数据和数据库中的一致
      • 对于读写缓存,要保证缓存和数据库中数据的一致
    • 异步缓写策略

      • 正常业务运行,MySQL数据变动,但是可以在业务上容许出现一定时间后才作用于Redis,比如仓库等。
      • 异常情况出现后,不得不将失败的动作修补,可能需要借助kafka等消息中间件,实现重写重试。

采用双检加锁策略

  • 多个线程同时去查询数据库的这条数据,就在第一个查询数据的请求上使用一个互斥锁来锁住他。
  • 其他线程获取不到锁就一直等待,等第一个线程查询到了数据,然后做了缓存
  • 后面的线程进来发现已经有了缓存,就直接走缓存

Java示例

package com.lv.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.lv.User;
import com.lv.mapper.UserMapper;
import com.lv.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 * @author 晓风残月Lx
 * @date 2023/3/27 12:39
 */
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    public static final String CACHE_KEY_USER = "user:";
    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate;


    /**
     *  业务逻辑没有写错,对于小厂中厂(QPS《=1000)可以使用,但是大厂不行
     * @param id
     * @return
     */
    public User findUserById1(Long 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.selectById(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(Long id){
        User user = null;
        String key = CACHE_KEY_USER + id;

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

数据库和缓存一致性的几种更新策略

目的

达到最终一致性。

  • 给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。
  • 我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以mysql的数据库写入库为准

不可停机的四种更新策略

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

问题一

更新MySQL的某个商品的库存,当前商品的库存是100,更新为99 个。下一步先更新MySQL修改为99成功,然后更新Redis。这时出现异常,更新Redis失败,导致MySQL中的库存是99,而Redis中的还是100。这样会让数据库和缓存Redis中的数据不一致,读到Redis脏数据

问题二

A、B两个线程发起调用,正常逻辑如下:

1、A update mysql 100
2、A update redis 100
3、B update mysql 80
4、B update redis 80

而在多线程环境下,A和B两个线程有快有慢,有前有后,有并行,因此异常逻辑如下:

1、A update mysql 100
2、B update mysql 80
3、B update redis 80
4、A update redis 100

最终,MySQL和Redis中的数据不一致

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

一般业务会将MySQL作为底单数据库,以MySQL为准。

异常问题

A、B两个线程发起调用,正常逻辑如下:

1、A update redis 100
2、A update mysql 100
3、B update redis 80
4、B update mysql 80

而在多线程环境下,A和B两个线程有快有慢,有前有后,有并行,因此异常逻辑如下:

1、A update redis 100
2、B update redis 80
3、B update mysql 80
4、A update mysql 100

最终,MySQL和Redis中的数据不一致。

先删除缓存,再更新数据库

异常问题

A线程先成功删除了Redis中的数据,然后去更新MySQL,此时MySQL正在更新,还没结束。B突然出现要读取缓存数据。

在这里插入图片描述

此时Redis里面的数据是空的,B线程读取,先读取Redis里的数据(此时已经被A线程delete掉了),这里会出现两个问题:

B从MySQL获取了旧值,B线程发现了Redis中没有数据(缓存缺失),会马上去MySQL中读取,这时数据库还没更新完成,从数据库中读取的是旧值。

B会将获取到的旧值写回到Redis。B获取旧值数据后,返回前台并写回进Redis(刚被A线程删除的旧数据极大可能又被写回。)

在这里插入图片描述

之后,A线程更新完MySQL,发现Redis里面的缓存是脏数据,这时A线程就不好处理了。

这里有两个并发操作,一个更新操作,一个查询操作。

A删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后,放到缓存中,然后A更新操作更新了数据库。

因此,在Redis缓存中的数据还是老数据,导致Redis缓存中的数据是脏的,而且会一直是脏数据。

时间线程A线程B出现问题
t1请求A进行写操作,删除缓存成功后,正在进行MySQL更新操作……
t21、缓存中读取不到,立刻读取MySQL,因为A还没有更新完MySQL,因此读到的是旧值
2、将从MySQL读取的旧值,写回了Redis
1、A没有更新完MySQL导致B读到了旧值
2、线程B遵守回写机制,将旧值写回了Redis,导致其他请求从缓存中读取的是旧值,并没有更新
t3A更新完MySQL数据库,完成Redis缓存是被B写回的旧值
MySQL中是被A更新的新值
出现了数据不一致的问题
总结

在该策略下,如果数据库更新失败或超时或返回不及时,导致B线程请求访问缓存时,发现Redis缓存中没有数据,缓存缺失,B会去MySQL数据库中进行读取,取到旧值,写回Redis缓存,导致数据不一致。

解决方案
延时双删策略

示例代码(Java):

在这里插入图片描述

上述代码中,加上sleep,为了让线程B能先从数据库中读取数据,将缺失的数据写入缓存,然后A线程进行删除,因此,线程A Sleep的时间要大于线程B读取数据写入缓存的时间

这个方案会在第一次删除缓存后,延迟一段时间后再次进行删除——延迟双删。

相关面试题

1、这个删除该休眠多久?

  • 在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。
  • 新启动一个后台监控程序,比如WatchDog监控程序,会加时

2、这种同步淘汰策略,吞吐量降低怎么办?

第二次删除缓存使用异步删除

在这里插入图片描述

3、看门狗WatchDog源码分析

先更新数据库,再删除缓存

异常问题
时间线程A线程B出现的问题
t1更新数据库MySQL的值
t2缓存立刻命中,此时B读取的是缓存旧值A还没删除缓存的值,导致B缓存命中读到旧值
t3更新缓存数据
解决方案(消息队列)

在这里插入图片描述

在这里插入图片描述

总结

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值