缓存把我坑惨了..

打印 上一主题 下一主题

主题 874|帖子 874|积分 2622

故事

春天,办公室外的世界总是让人神往的,小猫带着耳机,托着腮帮,望着表面精美的春光神游着...
一声不和谐的座机电话声突破这份本该属于小猫的宁静,“hi,小猫,线上有个客户想购买A产品规格的商品,投诉说下单总是失败,帮忙看一下啥缘故原由。”客服部小姐姐甜美的声音从电话那头传来。“哦哦,好,我看一下,把商品编号发一下吧......”
由于前一段时间的系统认识,小猫对现在的数据表模型已经了然于胸,当下就直接定位到了商品规格信息表,发现数据库中客户想购买的规格已经被下架了,但是前端的缓存好像并没有被革新。
小猫在系统中找到了之前开辟人员留的后门接口,直接curl语句重新革新了一下接口,缓存问题搞定了。
关于商品缓存和数据库不同等的情况,实在小猫一周会遇到好几个如许的客诉,他深受DB以及缓存不同等的苦,于是他下定决心想要从根本上解决问题,而不是curl调用后门接口......
写在前面

小猫的态度实在还是相当值得肯定的,当他下定决心从根本上排查问题的时候开始,小猫实在就是一名合格而且负责的研发,这也是我们每一位软件研发人员所需要具备的处置惩罚事情的态度。
在软件系统演进的过程中,只有我们在修复汗青遗留的问题的时候,才是真正意义上地对系统进行了维护,如果我们使用一些极端的手段(例如上述提到的后门接口curl语句)来保持古老而陈腐的代码继续工作的时候,这实在是一种苟且。一旦系统有了问题,我们实在就需要实时进行优化修复,否则会形成不好的示范,更多的后来者倾向于类似的方式解决问题,这也是为什么FixController存在的缘故原由,这实在就是系统腐化的标记。
言归正传,关于缓存和DB不同等信赖大家在日常开辟的过程中都有遇到过,那么我们接下来就和大家好好盘一盘,缓存和DB不同等的时候,咱们是怎样去解决的。接下来,大家会看到解决方案以及实战。

通例接口缓存读取更新


看到上面的图,我们可以清晰地知道缓存在实际场景中的工作原理。

  • 发生请求的时候,优先读取缓存,如果命中缓存则返回结果集。
  • 如果缓存没有命中,则回归数据库查询。
  • 将数据库查询得到的结果集再次同步到缓存中,并且返回对应的结果集。
这是大家比较认识的缓存使用方式,可以有效减轻数据库压力,提升接口访问性能。但是在如许的一个架构中,会有一个问题,就是一份数据同时生存在数据库和缓存中,如果数据发生变化,需要同时更新缓存和数据库,由于更新是有先后次序的,并且它不像数据库中多表事务操作满足ACID特性,所以如许就会出现数据同等性的问题。
DB和缓存不同等方案与实战DEMO

关于缓存和DB不同等,实在无非就是以下四种解决方案:

  • 先更新缓存,再更新数据库
  • 先更新数据库,再更新缓存
  • 先删除缓存,后更新数据库
  • 先更新数据库,后删除缓存
先更新缓存,再更新数据库(不建议)


这种方案实在是不提倡的,这种方案存在的问题是缓存更新成功,但是更新数据库出现异常了。如许会导致缓存数据与数据库数据完全不同等,而且很难察觉,因为缓存中的数据一直都存在。
先更新数据库,再更新缓存

先更新数据库,再更新缓存,如果缓存更新失败了,实在也会导致数据库和缓存中的数据不同等,如许客户端请求过来的大概一直就是错误的数据。

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

这种场景在并发量比较小的时候大概问题不大,理想情况是应用访问缓存的时候,发现缓存中的数据是空的,就会从数据库中加载并且生存到缓存中,如许数据是同等的,但是在高并发的极端情况下,由于删除缓存和更新数据库非原子活动,所以这期间就会有其他的线程对其访问。于是,如下图。

解释一下上图,老猫罗列了两个线程,分别是线程1和线程2。

  • 线程1会先删除缓存中的数据,但是尚未去更新数据库。
  • 此时线程2看到缓存中的数据是空的,就会去数据库中查询该值,并且重新更新到缓存中。
  • 但是此时线程1并没有更新成功,大概是事务还未提交(MySQL的事务隔离级别,会导致未提交的事务数据不会被另一个线程看到),由于线程2快于线程1,所以线程2去数据库查询得到旧值。
  • 这种情况下终极发现缓存中还是为旧值,但是数据库中却是最新的。
由此可见,这种方案实在也并不是完美的,在高并发的情况下还是会有问题。那么下面的这种总归是完美的了吧,有小伙伴肯定会这么认为,让我们一起来分析一下。
先更新数据库,后删除缓存

先说结论,实在这种方案也并不是完美的。咱们通过下图来说一个比较极端的场景。

上图中,我们实行的时间次序是按照数字由小到大进行。在高并发场景下,我们说一下比较极端的场景。
上面有线程1和线程2两个线程。其中线程1是读线程,当然它也会负责将读取的结果集同步到缓存中,线程2是写线程,重要负责更新和重新同步缓存。

  • 由于缓存失效,所以线程1开始直接查询的就是DB。
  • 此时写线程2开始了,由于它的速度较快,所以直接完成了DB的更新和缓存的删除更新。
  • 当线程2完成之后,线程1又重新更新了缓存,那此时缓存中被更新之后的当然是旧值了。
如此,咱们又发现了问题,又出现了数据库和缓存不同等的情况。
那么显然上面的这四种方案实在都多多少少会存在问题,那么究竟怎样去保持数据库和缓存的同等性呢?
包管强同等性

如果有人问,那我们可否包管缓存和DB的强同等性呢?回答当然是肯定的,那就是针对更新数据库和革新缓存这两个动作加上锁。当DB和缓存数据完成同步之后再去开释,一旦其中任何一个组件更新失败,我们直接逆向回滚操作。我们大概还得做快照便于其汗青缓存重写。那这种计划显然代价会很大。
实在在很大一部分情况下,要求缓存和DB数据强同等大部分都是伪需求。我们大概只要达到终极尽量保持缓存同等即可。有缓存要求的大部分业务实在也是能接受数据在短期内不同等的情况。所以我们就可以使用下面的这两种终极同等性的方案。
错误重试达到终极同等

如下示意图所示:

上面的图中我们看到。当然上述老猫只是画了更新线程,实在读取线程也一样。

  • 更新线程优先更新数据,然后再去更新缓存。
  • 此时我们发现缓存更新失败了,咱们就将其重新放到消息队列中。
  • 单独写一个消费者接收更新失败记载,然后进行重试更新操作。
说到消息队列重试,还有一种方式是基于异步使命重试,咱们可以把更新缓存失败的这个数据生存到数据库,然后通过另外的一个定时使命进而扫描待实行使命,然后去做相关的缓存更新动作。
当然上面我们提到的这两种方案,实在比较依靠我们的业务代码做出相对应的调整。我们当然也可以借助Canal组件来监控MySQL中的binlog的日志。通过数据库的 binlog 来异步淘汰 key,利用工具(canal)将 binlog日志采集发送到 MQ 中,然后通过 ACK 机制确认处置惩罚删除缓存。先更新DB,然后再去更新缓存,这种方式,被称为 Cache Aside Pattern,属于缓存更新的经典计划模式之一。

上述我们总结了缓存使用的一些方案,我们发现实在没有一种方案是完美的,最完美的方案实在还是得去结合详细的业务场景去使用。方案已经同步了,那么怎样去撸数据库以及缓存同步的代码呢?接下来,和大家分享的当然是日常开辟中比较好用的SpringCache缓存处置惩罚框架了。
SpringCache实战

SpringCache是一个框架,实现了基于注解缓存功能,只需要简单地加一个注解,就能实现缓存功能。
SpringCache进步了一层抽象,底层可以切换不同的cache实现,详细就是通过cacheManager接口来同一不同的缓存技能,cacheManager是spring提供的各种缓存技能抽象接口。
现在存在以下几种:

  • EhCacheCacheManager:将缓存的数据存储在内存中,以进步应用程序的性能。
  • GuavaCaceManager:使用Google的GuavaCache作为缓存技能。
  • RedisCacheManager:使用Redis作为缓存技能。
配置

我们日常开辟中用到比较多的实在是redis作为缓存,所以咱们就可以用RedisCacheManager,做一下代码演示。咱们以springboot项目为例。
老猫这里拿看一下redisCacheManager来举例,项目开始的时候我们当忽然要在pom文件依靠的时候就肯定需要redis启用项。如下:
  1. <dependency>
  2.   <groupId>org.springframework.boot</groupId>
  3.   <artifactId>spring-boot-starter-data-redis</artifactId>
  4. </dependency>
  5. <dependency>
  6.   <groupId>org.springframework.boot</groupId>
  7.   <artifactId>spring-boot-starter-cache</artifactId>
  8. </dependency>
复制代码
因为我们在application.yml中就需要配置redis相关的配置项:
  1. spring:
  2.   redis:
  3.     host: localhost
  4.     port: 6379
  5.     database: 0
  6.     jedis:
  7.       pool:
  8.         max-active: 8 # 最大链接数据
  9.         max-wait: 1ms # 连接池最大阻塞等待时间
  10.         max-idle: 4 # 连接线中最大的空闲链接
  11.         min-idle: 0 # 连接池中最小空闲链接
  12.    cache:
  13.     redis:
  14.       time-to-live: 1800000
复制代码
常用注解

关于SpringCache常用的注解,整理如下:

针对上述的注解,咱们做一下demo用法,如下:
用法简单盘点
  1. @Slf4j
  2. @SpringBootApplication
  3. @ServletComponentScan
  4. @EnableCaching
  5. public class Application {
  6.     public static void main(String[] args) {
  7.         SpringApplication.run(ReggieApplication.class);
  8.     }
  9. }
复制代码
在service层我们注入所需要用到的cacheManager:
  1. @Autowired
  2. private CacheManager cacheManager;
  3. /**
  4. * 公众号:程序员老猫
  5. * 我们可以通过代码的方式主动清除缓存,例如
  6. **/
  7. public void clearCache(String productCode) {
  8.   try {
  9.       RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager;
  10.       Cache backProductCache = redisCacheManager.getCache("backProduct");
  11.       if(backProductCache != null) {
  12.           backProductCache.evict(productCode);
  13.       }
  14.   } catch (Exception e) {
  15.       logger.error("redis 缓存清除失败", e);
  16.   }
  17. }
复制代码
接下来我们看一下每一个注解的用法,以下关于缓存用法的注解,我们都可以将其加到dao层:
第一种@Cacheable
在方法实行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中。
@Cacheable 注解中的核心参数有以下几个:

  • value:缓存的名称,可以是一个字符串数组,表现该方法的结果可以被缓存到哪些缓存中。默认值为一个空数组,表现缓存到默认的缓存中。
  • key:缓存的 key,可以是一个 SpEL 表达式,表现缓存的 key 可以根据方法参数动态生成。默认值为一个空字符串,表现使用默认的 key 生成策略。
  • condition:缓存的条件,可以是一个 SpEL 表达式,表现缓存的结果是否应该被缓存。默认值为一个空字符串,表现不思量任何条件,缓存所有结果。
  • unless:缓存的排除条件,可以是一个 SpEL 表达式,表现缓存的结果是否应该被排除在缓存之外。默认值为一个空字符串,表现不排除任何结果。
上述提及的SpEL是是Spring Framework中的一种表达式语言,此处不展开,不了解的小伙伴可以自己去查阅一下相关资料。
代码使用案例:
  1. @Cacheable(value="picUrlPrefixDO",key="#id")
  2. public PicUrlPrefixDO selectById(Long id) {
  3.     PicUrlPrefixDO picUrlPrefixDO = writeSqlSessionTemplate.selectOne("PicUrlPrefixDao.selectById", id);
  4.     return picUrlPrefixDO;
  5. }
复制代码
第二种@CachePut
表现将方法返回的值放入缓存中。
注解的参数列表和@Cacheable的参数列表同等,代表的意思也一样。
代码使用案例:
  1. @CachePut(value = "userCache",key = "#users.id")
  2. @GetMapping()
  3. public User get(User user){
  4.    User users= dishService.getById(user);
  5.    return users;
  6. }
复制代码
第三种@CacheEvict
表现从缓存中删除数据。使用案例如下:
  1. @CacheEvict(value="picUrlPrefixDO",key="#urfPrefix")
  2. public Integer deleteByUrlPrefix(String urfPrefix) {
  3.   return writeSqlSessionTemplate.delete("PicUrlPrefixDao.deleteByUrlPrefix", urfPrefix);
  4. }
复制代码
上述和大家分享了一下SpringCache的用法,对于上述提及的三个缓存注解中,老猫在日常开辟过程中用的比较多的是@CacheEvict以及@Cacheable,如果对SpringCache实现原理感爱好的小伙伴可以查阅一下相关的源码。
使用缓存的其他注意点

当我们使用缓存的时候,除了会遇到数据库和缓存不同等的情况之外,实在还有其他问题。严峻的情况下大概还会出现缓存雪崩。关于缓存失效造成雪崩,大家可以看一下这里【糟糕!缓存击穿,商详页进不去了】。
另外如果加了缓存之后,应用程序启动或服务高峰期之前,大家一定要做好缓存预热从而避免上线后瞬时大流量造成系统不可用。关于缓存预热的解决方案,由于篇幅过长老猫在此不展开了。不外方案概要可以提供,详细如下:

  • 定时预热。采用定时使命将需要使用的数据预热到缓存中,以包管数据的热度。
  • 启动时加载预热。在应用程序启动时,将常用的数据提前加载到缓存中,例如实现InitializingBean 接口,并在 afterPropertiesSet 方法中实行缓存预热的逻辑。
  • 手动触发加载:在系统达到高峰期之前,手动触发加载常用数据到缓存中,以进步缓存命中率和系统性能。
  • 热点预热。将系统中的热点数据提前加载到缓存中,以减轻系统压力。5
  • 延迟异步预热。将需要预热的数据放入一个队列中,由后台异步使命来完成预热。
  • 增量预热。按需预热数据,而不是一次性预热所有数据。通过根据数据的访问模式和优先级渐渐预热数据,以淘汰预热过程对系统的冲击。
如果小伙伴们还有其他的预热方式也欢迎大家留言。
总结

上述总结了关于缓存在日常使用的时候的一些方案以及坑点,当然这些也是面试官最喜欢提问的一些点。文中关于缓存的介绍老猫实在并没有说完,许多实在还是需要小伙伴们自己去抽时间研究研究。不得不说缓存是一门以空间换时间的艺术。要想使用好缓存,死记硬背策略肯定是行不通的。真实的业务场景往往要复杂的多,当然解决方案也不同,老猫上面提及的这些大家可以做一个参考,遇到实际问题还是需要大家详细问题详细分析。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

农妇山泉一亩田

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表