故事

春天,作业室外的世界总是让人神往的,小猫带着耳机,托着腮帮,望着外面美好的春光神游着…

一声不和谐的座机电话声打破这份本该归于小猫的宁静,“hi,小猫,线上有个客户想购买A产品标准的产品,投诉说下单总是失利,帮助看一下啥原因。”客服部小姐姐甜美的声音从电话那头传来。“哦哦,好,我看一下,把产品编号发一下吧……”

因为前一段时间的体系了解,小猫对现在的数据表模型现已了然于胸,当下就直接定位到了产品标准信息表,发现数据库中客户想购买的标准现已被下架了,可是前端的缓存如同并没有被改写。

小猫在体系中找到了之前开发人员留的后门接口,直接curl句子从头改写了一下接口,缓存问题搞定了。

关于产品缓存和数据库不共同的状况,其实小猫一周会遇到好几个这样的客诉,他深受DB以及缓存不共同的苦,于是他下定决心想要从根本上处理问题,而不是curl调用后门接口……

写在前面

小猫的情绪其实仍是相当值得必定的,当他下定决心从根本上排查问题的时分开端,小猫其实便是一名合格而且负责的研制,这也是咱们每一位软件研制人员所需求具备的处理事情的情绪。

在软件体系演进的进程中,只要咱们在修正前史遗留的问题的时分,才是真正意义上地对体系进行了维护,假如咱们运用一些极点的手段(例如上述提到的后门接口curl句子)来坚持古老而陈腐的代码持续作业的时分,这其实是一种苟且。一旦体系有了问题,咱们其实就需求及时进行优化修正,否则会形成不好的演示,更多的后来者倾向于类似的办法处理问题,这也是为什么FixController存在的原因,这其实便是体系腐化的标志。

言归正传,关于缓存和DB不共同信任咱们在日常开发的进程中都有遇到过,那么咱们接下来就和咱们好好盘一盘,缓存和DB不共同的时分,咱们是怎么去处理的。接下来,咱们会看到处理计划以及实战。

缓存把我坑惨了..

惯例接口缓存读取更新

缓存把我坑惨了..

看到上面的图,咱们能够清晰地知道缓存在实际场景中的作业原理。

  1. 产生恳求的时分,优先读取缓存,假如射中缓存则回来成果集。
  2. 假如缓存没有射中,则回归数据库查询。
  3. 将数据库查询得到的成果集再次同步到缓存中,并且回来对应的成果集。

这是咱们比较了解的缓存运用办法,能够有效减轻数据库压力,进步接口拜访功用。可是在这样的一个架构中,会有一个问题,便是一份数据一起保存在数据库和缓存中,假如数据产生变化,需求一起更新缓存和数据库,因为更新是有先后顺序的,并且它不像数据库中多表事务操作满足ACID特性,所以这样就会呈现数据共同性的问题。

DB和缓存不共同计划与实战DEMO

关于缓存和DB不共同,其实无非便是以下四种处理计划:

  1. 先更新缓存,再更新数据库
  2. 先更新数据库,再更新缓存
  3. 先删去缓存,后更新数据库
  4. 先更新数据库,后删去缓存

先更新缓存,再更新数据库(不主张)

缓存把我坑惨了..

这种计划其实是不提倡的,这种计划存在的问题是缓存更新成功,可是更新数据库呈现异常了。这样会导致缓存数据与数据库数据完全不共同,而且很难发觉,因为缓存中的数据一直都存在。

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

先更新数据库,再更新缓存,假如缓存更新失利了,其实也会导致数据库和缓存中的数据不共同,这样客户端恳求过来的或许一直便是错误的数据。

缓存把我坑惨了..

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

这种场景在并发量比较小的时分或许问题不大,理想状况是运用拜访缓存的时分,发现缓存中的数据是空的,就会从数据库中加载并且保存到缓存中,这样数据是共同的,可是在高并发的极点状况下,因为删去缓存和更新数据库非原子行为,所以这期间就会有其他的线程对其拜访。于是,如下图。

缓存把我坑惨了..

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

  1. 线程1会先删去缓存中的数据,可是尚未去更新数据库。
  2. 此刻线程2看到缓存中的数据是空的,就会去数据库中查询该值,并且从头更新到缓存中。
  3. 可是此刻线程1并没有更新成功,或者是事务还未提交(MySQL的事务隔离级别,会导致未提交的事务数据不会被另一个线程看到),因为线程2快于线程1,所以线程2去数据库查询得到旧值。
  4. 这种状况下终究发现缓存中仍是为旧值,可是数据库中却是最新的。

由此可见,这种计划其实也并不是完美的,在高并发的状况下仍是会有问题。那么下面的这种总归是完美的了吧,有小伙伴必定会这么认为,让咱们一起来分析一下。

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

先说结论,其实这种计划也并不是完美的。咱们经过下图来说一个比较极点的场景。

缓存把我坑惨了..

上图中,咱们履行的时间顺序是依照数字由小到大进行。在高并发场景下,咱们说一下比较极点的场景。

上面有线程1和线程2两个线程。其间线程1是读线程,当然它也会负责将读取的成果集同步到缓存中,线程2是写线程,主要负责更新和从头同步缓存。

  1. 因为缓存失效,所以线程1开端直接查询的便是DB。
  2. 此刻写线程2开端了,因为它的速度较快,所以直接完结了DB的更新和缓存的删去更新。
  3. 当线程2完结之后,线程1又从头更新了缓存,那此刻缓存中被更新之后的当然是旧值了。

如此,咱们又发现了问题,又呈现了数据库和缓存不共同的状况。

那么显然上面的这四种计划其实都多多少少会存在问题,那么终究怎么去坚持数据库和缓存的共同性呢?

确保强共同性

假如有人问,那咱们能否确保缓存和DB的强共同性呢?答复当然是必定的,那便是针对更新数据库和改写缓存这两个动作加上锁。当DB和缓存数据完结同步之后再去开释,一旦其间任何一个组件更新失利,咱们直接逆向回滚操作。咱们或许还得做快照便于其前史缓存重写。那这种规划显然代价会很大。

其实在很大一部分状况下,要求缓存和DB数据强共同大部分都是伪需求。咱们或许只要达到终究尽量坚持缓存共同即可。有缓存要求的大部分事务其实也是能承受数据在短期内不共同的状况。所以咱们就能够运用下面的这两种终究共同性的计划。

错误重试达到终究共同

如下示意图所示:

缓存把我坑惨了..

上面的图中咱们看到。当然上述老猫仅仅画了更新线程,其实读取线程也相同。

  1. 更新线程优先更新数据,然后再去更新缓存。
  2. 此刻咱们发现缓存更新失利了,咱们就将其从头放到音讯行列中。
  3. 单独写一个消费者接收更新失利记录,然后进行重试更新操作。

说到音讯行列重试,还有一种办法是基于异步使命重试,咱们能够把更新缓存失利的这个数据保存到数据库,然后经过别的的一个守时使命从而扫描待履行使命,然后去做相关的缓存更新动作。

当然上面咱们提到的这两种计划,其实比较依靠咱们的事务代码做出相对应的调整。咱们当然也能够凭借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启用项。如下:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--运用注解完结缓存技能-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

因为咱们在application.yml中就需求装备redis相关的装备项:

spring:
  redis:
    host: localhost
    port: 6379
    database: 0 
    jedis:
      pool:
        max-active: 8 # 最大链接数据
        max-wait: 1ms # 衔接池最大阻塞等待时间
        max-idle: 4 # 衔接线中最大的闲暇链接
        min-idle: 0 # 衔接池中最小闲暇链接
   cache:
    redis:
      time-to-live: 1800000 

常用注解

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

缓存把我坑惨了..

针对上述的注解,咱们做一下demo用法,如下:

用法简略盘点

@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableCaching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class);
    }
}

在service层咱们注入所需求用到的cacheManager:

@Autowired
private CacheManager cacheManager;
/**
 * 大众号:程序员老猫
 * 咱们能够经过代码的办法主动清除缓存,例如
 **/
public void clearCache(String productCode) {
  try {
      RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager;
      Cache backProductCache = redisCacheManager.getCache("backProduct");
      if(backProductCache != null) {
          backProductCache.evict(productCode);
      }
  } catch (Exception e) {
      logger.error("redis 缓存清除失利", e);
  }
}

接下来咱们看一下每一个注解的用法,以下关于缓存用法的注解,咱们都能够将其加到dao层:

第一种@Cacheable

在办法履行前spring先检查缓存中是否有数据,假如有数据,则直接回来缓存数据;若没有数据,调用办法并将办法回来值放到缓存中。

@Cacheable 注解中的核心参数有以下几个:

  • value:缓存的名称,能够是一个字符串数组,表明该办法的成果能够被缓存到哪些缓存中。默许值为一个空数组,表明缓存到默许的缓存中。
  • key:缓存的 key,能够是一个 SpEL 表达式,表明缓存的 key 能够依据办法参数动态生成。默许值为一个空字符串,表明运用默许的 key 生成战略。
  • condition:缓存的条件,能够是一个 SpEL 表达式,表明缓存的成果是否应该被缓存。默许值为一个空字符串,表明不考虑任何条件,缓存一切成果。
  • unless:缓存的扫除条件,能够是一个 SpEL 表达式,表明缓存的成果是否应该被扫除在缓存之外。默许值为一个空字符串,表明不扫除任何成果。

上述提及的SpEL是是Spring Framework中的一种表达式语言,此处不展开,不了解的小伙伴能够自己去查阅一下相关资料。

代码运用事例:

@Cacheable(value="picUrlPrefixDO",key="#id")
public PicUrlPrefixDO selectById(Long id) {
    PicUrlPrefixDO picUrlPrefixDO = writeSqlSessionTemplate.selectOne("PicUrlPrefixDao.selectById", id);
    return picUrlPrefixDO;
}

第二种@CachePut

表明将办法回来的值放入缓存中。 注解的参数列表和@Cacheable的参数列表共同,代表的意思也相同。 代码运用事例:

@CachePut(value = "userCache",key = "#users.id")
@GetMapping()
public User get(User user){
   User users= dishService.getById(user);
   return users;
}

第三种@CacheEvict

表明从缓存中删去数据。运用事例如下:

@CacheEvict(value="picUrlPrefixDO",key="#urfPrefix")
public Integer deleteByUrlPrefix(String urfPrefix) {
  return writeSqlSessionTemplate.delete("PicUrlPrefixDao.deleteByUrlPrefix", urfPrefix);
}

上述和咱们共享了一下SpringCache的用法,对于上述提及的三个缓存注解中,老猫在日常开发进程中用的比较多的是@CacheEvict以及@Cacheable,假如对SpringCache完结原理感兴趣的小伙伴能够查阅一下相关的源码。

运用缓存的其他留意点

当咱们运用缓存的时分,除了会遇到数据库和缓存不共同的状况之外,其实还有其他问题。严重的状况下或许还会呈现缓存雪崩。关于缓存失效形成雪崩,咱们能够看一下这里【糟糕!缓存击穿,商详页进不去了】。

别的假如加了缓存之后,运用程序发动或服务高峰期之前,咱们一定要做好缓存预热从而避免上线后瞬时大流量形成体系不可用。关于缓存预热的处理计划,因为篇幅过长老猫在此不展开了。不过计划概要能够供给,详细如下:

  • 守时预热。选用守时使命将需求运用的数据预热到缓存中,以确保数据的热度。
  • 发动时加载预热。在运用程序发动时,将常用的数据提早加载到缓存中,例如完结InitializingBean 接口,并在 afterPropertiesSet 办法中履行缓存预热的逻辑。
  • 手动触发加载:在体系达到高峰期之前,手动触发加载常用数据到缓存中,以进步缓存射中率和体系功用。
  • 热门预热。将体系中的热门数据提早加载到缓存中,以减轻体系压力。5
  • 推迟异步预热。将需求预热的数据放入一个行列中,由后台异步使命来完结预热。
  • 增量预热。按需预热数据,而不是一次性预热一切数据。经过依据数据的拜访模式和优先级逐渐预热数据,以削减预热进程对体系的冲击。

假如小伙伴们还有其他的预热办法也欢迎咱们留言。

总结

上述总结了关于缓存在日常运用的时分的一些计划以及坑点,当然这些也是面试官最喜欢发问的一些点。文中关于缓存的介绍老猫其实并没有说完,很多其实仍是需求小伙伴们自己去抽时间研讨研讨。不得不说缓存是一门以空间换时间的艺术。要想运用好缓存,死记硬背战略必定是行不通的。实在的事务场景往往要杂乱的多,当然处理计划也不同,老猫上面提及的这些咱们能够做一个参阅,遇到实际问题仍是需求咱们详细问题详细分析。