缓存双写一致性之更新策略探讨
1、 面试题
只要双写,就一定会有数据一致性问题,那么如何解决一致性问题?
双写一致性,你先动缓存redis还是数据库?为什么?
延时双删做过吗?会有哪些问题?
有这么一种情况,微服务查询redis无mysql有,为保证数据一致性回写redis你需要注意什么?双检加锁策略你了解过吗?如何尽量避免缓存击穿?
redis和mysql双写100%会出纰漏,做不到强一致性,你如何保证最终一致性?
2、缓存双写一致性,谈谈你的理解
如果Redis中有数据
需要和数据库中的值相同
如果Redis中无数据
数据库中的值要是最新值,且准备回写进Redis
缓存按照操作来分,细分2种
只读缓存
读写缓存
● 同步直写策略
○ 写数据库后也同步写Redis缓存,缓存和数据库中的数据一致
○ 对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略
● 异步缓写策略
○ 正常业务运行中,mysql数据变动了,但是可以在业务上容许出现一定时间后才作用于Redis,比如仓库、物流系统
○ 异常情况出现了,不得不将失败的动作重新修补,有可能需要借助kafka或者RabbitMQ等消息中间件,实现重试重写
问题,上面业务逻辑你用java代码如何写?
一图教会你如何写
双检加锁策略
多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它。其他线程走到这一步拿不到锁就等着,等第一个线程查到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。
搜索
100%
便笺
package com.atguigu.redis.service;
import com.atguigu.redis.entities.User;
import com.atguigu.redis.mapper.UserMapper;
import io.swagger.models.auth.In;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PathVariable;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* @auther zzyy
* @create 2021-05-01 14:58
*/
@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;
}
}
3、数据库和缓存一致性的几种更新策略
目的
达到最终一致性
● 给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案
● 我们可以对存入缓存的数据设置过期时间,那么所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要达到了过期时间,则后面的读请求自然会从数据库中读取新值然后回写到缓存中,达到一致性,切记,要以mysql的数据库写入库为准
● 上述方案和后续落地案例是调研后的主流+成熟的做法,但是需要考虑不同公司业务系统的差距,不是100%绝对正确,不保证绝对适配全部情况。
可以停机的情况
● 挂牌报错,凌晨升级,温馨提示,服务降级
● 单线程,这样重量级的数据操作最好不要多线程
4种更新策略
先更新数据库,再更新缓存
先更新缓存,再更新数据库
先删除缓存,再更新数据库
○ 双删方案面试题
■ 这个删除应该休眠多久呢?—>线程A sleep的时间,需要大于线程B读取数据再写入缓存的时间
● 第一种方案:在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,以此为基础来进行估算,然后写数据的休眠时间则再读数据业务逻辑的耗时上加几百毫秒即可
● 第二种方案:新启动一个后台监控程序,比如WatchDog监控程序,会加时
■ 这种同步淘汰策略,吞吐量降低怎么办?---->二次删除使用异步的方式
先更新数据库,再删除缓存
● 业务指导思想
○ 微软云
○ 阿里巴巴canal
● 类似经典的分布式事务问题,只有一个权威答案—>保证最终一致性
○ 流量充值,先下发短信实际充值可能滞后5分钟,可以接受
○ 电商发货,短信下发但是物流明天见
小总结
在大多数业务场景下,优先使用先更新数据库,再删除缓存的方案(先更库—>后删存)。理由如下:
● 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满Mysql
● 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删的等待时间就不好设置。
补充:如果是使用先更新数据库,再删缓存的方案,如果业务层要求必须读取一致性的数据,那么我们就需要再更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际是不推荐的,因为在真实生产案例上,分布式下很难做到实时一致性,一般都是最终一致性。