• Redis 双写一致原理篇


    前言

    我们都知道,redis一般的作用是顶在mysql前面做一个"带刀侍卫"的角色,可以缓解mysql的服务压力,但是我们如何保证数据库的数据和redis缓存中的数据的双写一致呢,我们这里先说一遍流程,然后以流程为切入点来谈谈redis和mysql的双写一致性是如何保证的吧

    流程

    首先我们先看一个图

    这就是进行一次查询的基本流程

    第一步就是查询redis看看是否有对应的热点数据,没有的话,就去mysql进行查询

    mysql查询到了再进行回写进redis,这样下一个用户来进行查询的时候,这里就可以直接从redis进行查询对应的数据了

    但是这里就会涉及到很多问题了,如何保证双写一致性??

    我更新数据的更新策略是先更新mysql还是先更新redis??

    下面我们慢慢说

    缓存双写一致性的理解 

    这里查询如过redis有数据那么就进行立即返回

    如果redis没有数据那么就打到mysql中查看数据并进行回写

    这里的缓存我们可以分为两种

    只读缓存和可写缓存

    可写缓存这里我们也分为两种写入策略

    同步直写策略和异步缓写策略

    同步直写策略就是读取完mysql的数据迅速进行一个回写操作

    如果这里想保存数据的高度一致,就最好是使用同步缓写的操作

    比如这个时候我们想把一个vip的状态进行快速的切换,充值成功立马就得更新

    异步缓写策略就是我们一个物流状态的更新,或者是订单成功的积分操作都可以使用一个异步的操作,因为这个操作是非即时性质的

    但是这里也可能导致很多错误

    比如假设这里回写失败了咋办

    我们可以使用一个消息队列等来进行对应的补偿重试机制

    假设高并发的情况下出现了对应的数据进行覆盖

    或者可能出现mysql死锁mysql负载过高的情况

    这里我们就可以使用双检加锁策略解决问题  

    这里主要是为了保证每次只有一个请求打在mysql上,减少mysql服务器的负载

    至于后面的值覆盖问题一会儿再说

    我们展示一段代码再进行对应的讲解

    1. @Service
    2. @Slf4j
    3. public class UserService {
    4. public static final String CACHE_KEY_USER = "user:";
    5. @Resource
    6. private UserMapper userMapper;
    7. @Resource
    8. private RedisTemplate redisTemplate;
    9. /**
    10. * 业务逻辑没有写错,对于小厂中厂(QPS《=1000)可以使用,但是大厂不行
    11. * @param id
    12. * @return
    13. */
    14. public User findUserById(Integer id)
    15. {
    16. User user = null;
    17. String key = CACHE_KEY_USER+id;
    18. //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
    19. user = (User) redisTemplate.opsForValue().get(key);
    20. if(user == null)
    21. {
    22. //2 redis里面无,继续查询mysql
    23. user = userMapper.selectByPrimaryKey(id);
    24. if(user == null)
    25. {
    26. //3.1 redis+mysql 都无数据
    27. //你具体细化,防止多次穿透,我们业务规定,记录下导致穿透的这个key回写redis
    28. return user;
    29. }else{
    30. //3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
    31. redisTemplate.opsForValue().set(key,user);
    32. }
    33. }
    34. return user;
    35. }

    这段代码对于并发量低的情况下还是可以使用的

    但是假设这里redis的数据同一时间有很多用户访问,但是redis没有,得去mysql的底单数据表去查询,这里我们就得考虑万一都打在mysql上,导致mysql的压力过大就不好了,所以我们建议加锁,每次只让一个线程去操作对应的用户即可  

    这里代码示例可以在mysql操作加上一个互斥锁

    注意这里我们检查了两次,这是因为假设a线程和b线程都查询到redis没有这个数据,但是此时a线程被调度走了,b线程已经将数据带回来了,此时再调度到a线程a线程直接查询redis即可,避免给mysql更大的压力,下面我们展示加锁后的代码

    1. /**
    2. * 加强补充,避免突然key失效了,打爆mysql,做一下预防,尽量不出现击穿的情况。
    3. * @param id
    4. * @return
    5. */
    6. public User findUserById2(Integer id)
    7. {
    8. User user = null;
    9. String key = CACHE_KEY_USER+id;
    10. //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
    11. // 第1次查询redis,加锁前
    12. user = (User) redisTemplate.opsForValue().get(key);
    13. if(user == null) {
    14. //2 大厂用,对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
    15. synchronized (UserService.class){
    16. //第2次查询redis,加锁后
    17. user = (User) redisTemplate.opsForValue().get(key);
    18. //3 二次查redis还是null,可以去查mysql了(mysql默认有数据)
    19. if (user == null) {
    20. //4 查询mysql拿数据(mysql默认有数据)
    21. user = userMapper.selectByPrimaryKey(id);
    22. if (user == null) {
    23. return null;
    24. }else{
    25. //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
    26. redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
    27. }
    28. }
    29. }
    30. }
    31. return user;
    32. }
    33. }

    更新策略

    我们知道mysql和redis的数据得保证一致性,但是这个强一致性是不太好保证的,我们只能保证最终一致性,那么mysql和redis我们先保证谁的数据更新呢,就是我们接下来要探讨的问题了

    注:这里的策略仅供参考,以实际需求为准

    策略1:停机更新

    首先第一个策略不是很常用但是很有效,直接在用户量较少的时候停机进行服务降级更新

    此时让运维工程师使用单线程来操作即可,因为多线程出错的概率更大

    策略2:先更新数据库,再更新redis

    先更新数据库再更新redis可能导致一些异常,举例如下

    假设现在更新mysql成功了,但是redis回写却失败了

    这里就很可能导致数据库和缓存中的数据就不一致了

    策略3:先更新redis,再更新数据库

    这也是存在和以上差不多的情况的

    技术上可以做,但是不太推荐,因为我们一般是将mysql作为一个底单数据库的

    这里异常情况下数据同样是不一致的

    策略4:先删除缓存,再更新数据库

    这也不太行假设先删除redis的数据,而mysql还没更新完成

    这个时候有一个线程来读取缓存的数据没找到,读取mysql就可能导致了脏读问题,

    然后将对应的脏数据回写进了redis,此时mysql更新完了发现缓存中已经有数据了

    这里就引入一种延时双删的策略

    我们非常悲观的以为一定会有这么一个线程读取脏数据

    所以我们在mysql更新结束之后我们对redis在进行一次删除的操作

    但是这里延迟的时间不一定好确定,一般是写数据在业务耗时加上100ms即可

    还有就是使用后台监控的策略(咱们后面再说)

    策略5:先更新数据库再删除缓存

    最后一个策略就是较为折中的策略,我们选择先更新数据库再删除缓存

    这里的缺点是假设a线程没有更新完mysql并且删除缓存之前就有另外的线程读取对应的数据

    这里可能就导致读到了缓存里面的旧值

    这里也是有一些成熟的解决方案的

    下面我们介绍一下流程

    比如使用阿里的canal

    其实也就是在更新完数据库之后,写入mysql的binlog日志文件中

    订阅程序或者是消息中间价提取出对应的key

    然后另起一段非业务代码来获取这里的信息

    尝试删除缓存,删除失败的话就将这里的数据发送给消息队列

    然后重新重消息队列中获取数据重新复写缓存

    流程图如下

    我们其实就是做不到强一致性,所以我们之只能采取最终一致性的方案

    这也就导致了充值话费或者是短信有一定的滞后性

    小总结

    我们大多数情况下都是先更新数据库,再删除缓存

    这是因为先删除缓存能保证每次获取数据的时候是直接访问数据库,可能导致数据库负载过高

    其次就是即时使用延时双删的操作,这里可能延时的时间也不好计算等等

  • 相关阅读:
    皕杰报表单元格内显示方框和带√的方框和修改导出excel名称
    汇川IT7000系列HMI使用脚本实现画面跳转时自动切换手自动模式
    CentOS7安装xvfb,解决服务器没有X-Server的问题
    【react】报错记录
    2023国赛C题:蔬菜类商品的自动定价与补货决策 评阅要点完整分析
    第2次作业练习题(第三章 指令系统)
    串级PID为什么外环输出时内环的期望值
    4款视频号数据分析平台!
    TPU演进十年:Google的十大经验教训
    TOOLLLM: FACILITATING LARGE LANGUAGE MODELS TO MASTER 16000+ REAL-WORLD APIS
  • 原文地址:https://blog.csdn.net/qiuqiushuibx/article/details/139573976