当前位置: 首页 > news >正文

Redis缓存双写的学习(五)

一、缓存双写一致性

1.1、为什么会有双写?

    1. 缓存和数据库的数据不一致,导致数据错乱。
    • 原因:先更新数据库,再更新缓存;或者先更新缓存,再更新数据库。
    • 解决:先删除缓存,再更新数据库;或者先更新数据库,再删除缓存。

1.2、缓存操作

Redis作为缓存,有以下两种模式:

    1. 只读缓存:Redis有就读取,没有就向数据库查询,不做写回操作
    1. 读写缓存:
    • 同步直写策略:
      • 写数据库后也同步写redis,保证缓存和数据库一致。
      • 缺点:写数据库和缓存是串行的,在高并发场景下,会影响性能。
    • 异步缓写策略(推荐):
      • mysql数据变动了,但是可以在业务上容许出现一定时间后才作用于redis,比如仓库,物流系统
      • 异常情况出现了,不得不将失败的动作重新修补,有可能需要借助kafka或者rabbitMQ之类的消息队列来做,实现重试重写。
        在这里插入图片描述

1.3、实际操作

// 同步直写策略
string getUserName(string userId)
{//查询redis缓存string userName = redis.get(userId);if (userName == null) {// 缓存中没有,查询数据库userName = db.queryUserNameByUserId(userId);redis.set(userId, userName);}return userName;
}

这段代码在并发量小的时候,没问题,但是如果并发量大,就会出现问题:当并发量很大时,多个线程同时查询缓存中没有数据,就会同时去数据库中查询,然后同时写入缓存。这样就会导致缓存中的数据被多次覆盖,从而导致数据不一致。

解决方案:采用双检加锁策略让其变为原子操作

/** 双检加锁策略 **/
/* 当多个线程同时去查询数据时,可以在第一个查询数据的请求上,
*  加一把就互斥锁,后续的查询数据请求都会被阻塞。直到第一个线程把数据写入缓存之后,释放锁,
*  其他线程发现缓存当中已经有数据了,就不会再去数据库中查询
*/
string getUserName(string userId)
{//查询redis缓存string userName = redis.get(userId);if (userName.empty()) {// 加锁,防止多个线程同时去数据库中查询数据lock_guard<mutex> lock(mutex); // 互斥锁// 再次检查缓存是否为空userName = redis.get(userId);if (userName.empty()) {// 缓存中没有,查询数据库userName = db.queryUserNameByUserId(userId);redis.set(userId, userName);}}return userName;
}

这段代码解决了并发,导致数据多次被覆盖,数据不一致问题,但还是存在问题:在高并发场景下,key突然失效,大量请求打到数据库,导致出现缓存击穿,所以还要再优化下

/** 避免缓存击穿 **/
/**相对于上面的代码就多了对MySQL查询完数据之后,对数据进行判空,设置key的过期时间**/
string getUserName(string userId)
{//查询redis缓存string userName = redis.get(userId);if (userName.empty()) {// 加锁,防止多个线程同时去数据库中查询数据lock_guard<mutex> lock(mutex); // 互斥锁// 再次检查缓存是否为空userName = redis.get(userId);if (userName .empty()) {// 缓存中没有,查询数据库userName = db.queryUserNameByUserId(userId);if(userName.empty()){return "";}else{// 设置过期时间,防止数据冗余redis.setex(userId, userName, 7L, TimeUnit.DAYS);}}}return userName;
}

1.4、缓存与数据库一致的几种策略

重要理念:最新数据是以MySQL之类的数据库为准,不是缓存

    1. 可以停机的情况
      先发出公告,告知用户,何时停机,停机多久,然后停机更新数据库和缓存。比如游戏服务器,晚上凌晨停机。
    1. 不允许停机的情况(线上保持数据一致)
    • 2.1 先更新数据库,再更新缓存

      • 异常问题1:更新数据库成功了,更新缓存失败;导致数据库和缓存不一致,后续会读到redis脏数据
      • 异常问题2:在高并发情况下,假设存在A,B两个线程:
        • 正常情况下:
          • A线程update mysql 100
          • A线程update redis 100
          • B线程update mysql 80
          • B线程update redis 80
        • 异常情况:(线程会抢占资源,不是顺序依次进行的)
          • A线程update mysql 100
          • B线程update mysql 80
          • B线程update redis 80
          • A线程update redis 100
    • 2.2 先更新缓存,再更新数据库

      • 不推荐,最新数据是以MySQL之类的数据库为准,不是缓存。
      • 异常问题1:在高并发情况下,假设存在A,B两个线程:
        • 正常情况下:
          • A线程update redis 100
          • A线程update mysql 100
          • B线程update redis 80
          • B线程update mysql 80
        • 异常情况:
          • A线程update redis 100
          • B线程update redis 80
          • B线程update mysql 80
          • A线程update mysql 100
    • 2.3 先删除缓存,再更新数据库

      • 异常问题:在高并发情况下,假设存在A,B两个线程,A负责更新数据库,B负责读取数据
        • 正常情况下:
          • A线程delete redis
          • A线程update mysql 100
          • A线程set redis 100
          • B线程get redis 100
        • 异常情况下:
          • A线程delete redis
          • A线程update mysql 100
          • B线程get redis 空值,去数据库查询到旧值80,然后set到redis中

      • 小结:如果数据库更新失败或超时或返回不及时,导致B线程请求访问缓存时,发现redis里面没有数据,就去数据库查询到了旧值80,然后set到redis中。这样就导致A线程做无用功。
      • 解决办法:延迟双删策略,即在更新数据库之后,延迟一段时间再删除缓存。
/** 延迟双删策略 **/
/** 1.线程A先删除redis缓存,然后去更新数据库 ** 2.为了让线程B能够先从数据库读取数据,再把缺失的数据写入redis,然后线程A再进行删除** 3.线程A sleep的时间 > 线程B读取数据再写入缓存的时间
**/
void delayDoubleDelete(const string& key, const string& newValue, int delayTime = 500)
{// 第一次删除缓存redis.del(key);// 更新数据库db.update(key, newValue);// 延迟一段时间再删除缓存Thread::sleep(delayTime);// 再删除一次缓存,确保缓存中的数据是最新的redis.del(key);
}

问题:

  • 那么延迟双删策略的延迟时间应该设置为多少?
      1. 适用有经验的开发者:统计该线程读取数据和写缓存的操作时间,来进行估算,然后再 + 几百毫秒的时间
      1. 适用于新人:启动后台监控程序,使用watch dog机制,即开启一个定时任务,每隔一段时间就去检查缓存是否是最新的数据。如果不是最新的,就删除缓存,然后重新加载最新数据到缓存中。
  • 延时双删,同步策略,吞吐量降低了,如何解决?
    采用异步方案
/** 异步延时双删函数 **/
void asyncDelayedDoubleDeletion(const string& key, const string& newValue, int delayTime = 500) 
{// 第一次删除缓存redis.del(key);// 更新数据库db.update(key, newValue);// 启动异步线程来执行等待std::async(std::launch::async, [delayTime]() {std::this_thread::sleep_for(std::chrono::milliseconds(delayTime));// 再次删除缓存,确保缓存中的数据是最新的redis.del(key);});
}
  • 2.4 先更新数据库,再删除缓存(推荐)
    • 异常问题1:在更新数据库,还未及时更新redis时,其他线程请求读取redis缓存旧数据,会使得数据不一致,不过只是暂时的

以上删除缓存时,都默认删除能成功,但如果删除失败,怎么办呢?

  • 解决办法:重试机制,即在删除缓存失败时,进行重试。
    在这里插入图片描述
  1. 可以把要删除的缓存值或者是要更新的数据库值存到消息队列中(kafka/RabbitMQ/RocketMQ等)
  2. 当程序没有能够成功删除缓存值或是要更新数据库时,可以重新从消息队列中获取这些值,然后再次进行删除或更新。
  3. 如果能够成功地删除或更新,我们就要把这些值从消息队列中移除,避免重复处理。此时,缓存和数据库最终会保持一致。
  4. 如果重试超过一定次数后(3,5次),还是没有成功,那么我们通知运维人员手动处理这些数据。

    小结:

    这几种方案都或多或少存在一些问题,但是目前业界公认的最佳实践是:先更新数据库,再删除缓存。理由如下:
    1. 先删除缓存再更新数据库,有可能导致请求因缓存确实而访问数据库,给数据库带来压力,导致打满数据库。
    1. 如果业务应用中读取数据库和写缓存的时间不好估算,延时双删的时间不好设置。

1.5、canal

    1. 为什么需要canal
      当直接操作数据库,并非通过业务代码操作数据库,不会触发写回缓存的操作,此时可以手动触发缓存更新,但每次数据库变更都需要手动触发,工作量太大,而且容易出错;可以监听数据库的binlog日志,通过解析binlog日志来实现缓存更新-----这就是canal的作用
    1. canal是什么
      canal是基于MySQL数据库binlog的增量订阅&消费组件,主要用于实时数据同步。
    1. canal下载地址:
      https://github.com/alibaba/canal
    1. canal工作原理:
      在这里插入图片描述

在这里插入图片描述

    1. canal作用:
      在这里插入图片描述
    1. canal使用配置
      官网文档:https://github.com/alibaba/canal/wiki/QuickStart

      就不再赘述

二、总结

    1. 为什么会有缓存双写?
      因为缓存和数据库的数据不一致,导致数据不准确,所以需要缓存双写,保持数据一致性。
    1. 缓存双写的策略有哪些?
      有如下四种:
      • 先更新数据库,再更新缓存
      • 先更新缓存,再更新数据库
      • 先删除缓存,再更新数据库
      • 先更新数据库,再删除缓存

四种都或多或少存在一些问题,但是目前业界公认的最佳实践是:先更新数据库,再删除缓存

    1. 为什么业界公认的最佳实践是:先更新数据库,再删除缓存
        1. 先删除缓存再更新数据库,有可能导致请求因缓存确实而访问数据库,给数据库带来压力,导致打满数据库。
        1. 如果业务应用中读取数据库和写缓存的时间不好估算,延时双删的时间不好设置。
    1. 如何知道数据库信息变更?
      通过观察binlog日志得知数据库操作记录变更。

0vice·GitHub

http://www.dtcms.com/a/285686.html

相关文章:

  • Python暑期学习笔记5
  • 平板可以用来办公吗?从文档处理到创意创作的全面测评
  • JavaScript 的垃圾回收机制
  • 第 14 章 线性回归预测策略----SPL量化编程课
  • CUPED (Controlled-experiment using Pre-Experiment Data) 论文学习笔记
  • 软删除设计:为什么使用 deleted_at = ‘1970-01-01 00:00:00‘ 表示未删除?
  • 1-大语言模型—理论基础:详解Transformer架构的实现(1)
  • 零信任产品联合宁盾泛终端网络准入,打造随需而变、精准贴合业务的网络安全访问体系
  • python爬虫获取PDF
  • pdf格式怎么提取其中一部分张页?
  • PDF 拆分合并PDFSam:开源免费 多文件合并 + 按页码拆分 本地处理
  • Elasticsearch 和 solr 的区别
  • 【Docker#2】容器历史发展 | 虚拟化实现方式
  • C# 转换(引用转换)
  • uni-app 跳转页面传参
  • Docker --privileged 命令详解
  • Diffusion-VLA 中的 Reasoning Token 注入机制解析:语言推理如何控制扩散模型?
  • 2.3 前端-ts的接口以及自定义类型
  • k8s:手动创建PV,解决postgis数据库本地永久存储
  • 【unity实战】使用unity的Navigation+LineRenderer实现一个3D人物寻路提前指示预测移动轨迹的效果,并可以适配不同的地形
  • 搜索引擎优化全攻略:提升百度排名优化
  • 解决Flutter运行android提示Deprecated imperative apply of Flutter‘s Gradle plugins
  • 武汉江滩某码头变形及应力自动化监测
  • 麒麟操作系统unity适配
  • java前端基础--HTMLCSS、JavaScript、Vue、Ajax
  • Python网络爬虫实现selenium对百度识图二次开发以及批量保存Excel
  • unity实现梦日记式效果
  • CS课程项目设计3:支持AI人机对战的井字棋游戏
  • 「Java EE开发指南」如何用MyEclipse创建企业应用项目?(一)
  • UniApp -- 小程序自定义导航栏组件