近况

再来水一篇博客,我觉得写博客最重要的是创新,总结,而不是照搬,不然和重复造轮子有什么分别
但我个人实在太菜,要做创新也是非常难的,想到一个笑话:把别人的变成自己的,把自己的变成祖传的~

话不多说,让我们来看看这篇博客具体水哪些内容吧

缓存误用场景

  • 误用一:把缓存作为服务与服务之间传递数据的媒介

    如上图: 服务1和服务2约定好key和value,通过缓存传递数据 服务1将数据写入缓存,服务2从缓存读取数据,达到两个服务通信的目的

    该方案存在的问题:

    • 数据管道,数据通知场景,MQ更加适合

      1,MQ是互联网常见的逻辑解耦,物理解耦组件,支持1对1,1对多各种模式,非常成熟的数据通道,
          而cache反而会将service-A/B/C/D耦合在一起,大家要彼此协同约定key的格式,ip地址等
      2,MQ能够支持push,而cache只能拉取,不实时,有时延
      3,MQ天然支持集群,支持高可用,而cache未必
      4,MQ能支持数据落地,cache具备将数据存在内存里,具有“易失”性,当然,有些cache支持落地,
          但互联网技术选型的原则是,让专业的软件干专业的事情:nginx做反向代理,db做固化,cache做缓存,mq做通道
    • 多个服务关联同一个缓存实例,会导致服务耦合

      1,大家要彼此协同约定key的格式,ip地址等,耦合
      2,约定好同一个key,可能会产生数据覆盖,导致数据不一致
      3,不同服务业务模式,数据量,并发量不一样,会因为一个cache相互影响
          例如service-A数据量大,占用了cache的绝大部分内存,会导致service-B的热数据全部被挤出cache,导致cache失效
          又例如service-A并发量高,占用了cache的绝大部分连接,会导致service-B拿不到cache的连接,从而服务异常
  • 误用二:使用缓存未考虑雪崩

    常规的缓存玩法,如上图: 服务先读缓存,缓存命中则返回 缓存不命中,再读数据库

    什么时候会产生雪崩?
    答:如果缓存挂掉,所有的请求会压到数据库,如果未提前做容量预估,可能会把数据库压垮
    (在缓存恢复之前,数据库可能一直都起不来),导致系统整体不可服务。

    如何应对潜在的雪崩?
    答:提前做容量预估,如果缓存挂掉,数据库仍能扛住,才能执行上述方案。

    否则,就要进一步设计,常见方案如下:

    • 高可用缓存

      使用高可用缓存集群,一个缓存实例挂掉后,能够自动做故障转移。

    • 缓存水平切分

      如上图:使用缓存水平切分(推荐使用一致性哈希算法进行切分),一个缓存实例挂掉后,不至于所有的流量都压到数据库上。

  • 误用三:调用方缓存数据

    如上图:
    服务提供方缓存,向调用方屏蔽数据获取的复杂性(这个没问题)
    服务调用方,也缓存一份数据,先读自己的缓存,再决定是否调用服务(这个有问题)

    该方案存在的问题是:

    1、调用方需要关注数据获取的复杂性(耦合问题)
    2、更严重的,服务修改db里的数据,淘汰了服务cache之后,难以通知调用方淘汰其cache里的数据,从而导致数据不一致(带入一致性问题)
    3、有人说,服务可以通过MQ通知调用方淘汰数据,额,难道下游的服务要依赖上游的调用方,分层架构设计不是这么玩的(反向依赖问题)
  • 误用四:多服务共用缓存实例

    如上图:服务A和服务B共用一个缓存实例(不是通过这个缓存实例交互数据)

    该方案存在的问题是:

    1、可能导致key冲突,彼此冲掉对方的数据
        画外音:可能需要服务A和服务B提前约定好了key,以确保不冲突,常见的约定方式是使用namespace:key的方式来做key。
    2、不同服务对应的数据量,吞吐量不一样,共用一个实例容易导致一个服务把另一个服务的热数据挤出去
    3、共用一个实例,会导致服务之间的耦合,与微服务架构的“数据库,缓存私有”的设计原则是相悖的

    建议的玩法是:

    如上图:各个服务私有化自己的数据存储,对上游屏蔽底层的复杂性。

  • 总结

    1、服务与服务之间不要通过缓存传递数据
    2、如果缓存挂掉,可能导致雪崩,此时要做高可用缓存,或者水平切分
    3、调用方不宜再单独使用缓存存储服务底层的数据,容易出现数据不一致,以及反向依赖
    4、不同服务,缓存实例要做垂直拆分

如何保证缓存与数据一致性

  • 缓存,究竟是淘汰,还是修改?
    1,KV缓存都缓存了一些什么数据?

    1)朴素类型的数据,例如:int
    2)序列化后的对象,例如:User实体,本质是binary
    3)文本数据,例如:json或者html
    4)...

    2,淘汰缓存中的这些数据,修改缓存中的这些数据,有什么差别?

    1)淘汰某个key,操作简单,直接将key置为无效,但下一次该key的访问会cache miss
    2)修改某个key的内容,逻辑相对复杂,但下一次该key的访问仍会cache hit
    
    可以看到,差异仅仅在于一次cache miss。

    3,缓存中的value数据一般是怎么修改的?

    1)朴素类型的数据(string等),直接set修改后的值即可
    2)序列化后的对象:一般需要先get数据,反序列化成对象,修改其中的成员,再序列化为binary,再set数据
    3)json或者html数据:一般也需要先get文本,parse成dom树对象,修改相关元素,序列化为文本,再set数据
    
    结论:对于对象类型,或者文本类型,修改缓存value的成本较高,一般选择直接淘汰缓存。

    4,对于朴素类型的数据,究竟应该修改缓存,还是淘汰缓存?

    答:仍然视情况而定。
    
    案例1:
    假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,购买了一个商品pid=456。
    分析:如果修改缓存,可能需要:
    1)去db查询pid的价格是50元
    2)去db查询活动的折扣是8折(商品实际价格是40元)
    3)去db查询用户的优惠券是10元(用户实际要支付30元)
    4)从cache查询get用户的余额是100元
    5)计算出剩余余额是100 - 30 = 70
    6)到cache设置set用户的余额是70
    为了避免一次cache miss,需要额外增加若干次db与cache的交互,得不偿失。
    结论:此时,应该淘汰缓存,而不是修改缓存。
    
    案例2:
    假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,需要扣减30元。
    分析:如果修改缓存,需要:
    1)从cache查询get用户的余额是100元
    2)计算出剩余余额是100 - 30 = 70
    3)到cache设置set用户的余额是70
    为了避免一次cache miss,需要额外增加若干次cache的交互,以及业务的计算,得不偿失。
    结论:此时,应该淘汰缓存,而不是修改缓存。
    
    案例3:
    假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,余额要变为70元。
    分析:如果修改缓存,需要:
    1)到cache设置set用户的余额是70
    修改缓存成本很低。
    结论:此时,可以选择修改缓存。当然,如果选择淘汰缓存,只会额外增加一次cache miss,成本也不高。

    总结:
    允许cache miss的KV缓存写场景:
    大部分情况,修改value成本会高于“增加一次cache miss”,因此应该淘汰缓存
    如果还在纠结,总是淘汰缓存,问题也不大

  • 发生数据不一致有哪些原因

    缓存一致性或者叫缓存双写一致性,缓存同步,说的是在同时操作数据库和缓存时发生数据不一致情况
    本质上是因为两个操作不是原子性的。

    上面我们排除了更新缓存,那么摆在我们面前的就只有两种方案了

    1)先操作数据库,再删除缓存
    2)先删除缓存,再操作数据库

    让我们来理一理,发生数据不一致在实际生产中,主要有哪些原因?

    1)异常,即第二步操作失败
        有人问第一步操作不能失败嘛?当然不会,如果第一步操作失败,第二步操作就不执行,哪来的数据不一致
    2)并发
    3)主从数据库时延
  • 在出现异常情况下,上面两个方案的比较

    • 先操作数据库

      线程A操作数据库,数据库数据发生改变
      线程A删除缓存,删除缓存失败
      线程B读取缓存,读到旧数据
      出现异常情况下,删除缓存失败,会出现数据不一致情况
    • 先操作缓存

      线程A删除缓存,删除缓存成功
      线程A操作数据库,操作数据库失败
      线程B读取缓存,读取缓存失败
      线程B读取数据库,读取数据库成功
      出现异常情况下,线程B读取到的数据与数据库数据一致,并没有发生数据不一致情况

    注意哦,上面异常的情况下,线程都是同步的哦,而我们更多的是讨论异步的情况

  • 在并发的情况下,上面两个方案的比较

    • 先操作数据库

      如果有2个线程要并发读写数据,可能会发生以下场景:
      1)缓存中 X 不存在(数据库 X = 1)
      2)线程 A 读取数据库,得到旧值(X = 1)
      3)线程 B 更新数据库(X = 2)
      4)线程 B 删除缓存
      5)线程 A 将旧值写入缓存(X = 1)
      最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生数据不一致。

      这种情况「理论」来说是可能发生的,但实际真的有可能发生吗?
      其实概率「很低」,这是因为它必须满足 3 个条件:

      1.缓存刚好已失效
      2.读请求 + 写请求并发
      3.更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
        因为3,4步骤如果时间长,线程B还是能把缓存删除,从而保持数据一致

      仔细想一下,条件 3 发生的概率其实是非常低的。
      因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的。
      这么来看,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
      所以,我们应该采用这种方案,来操作数据库和缓存,这个方案也是旁路缓存策略的写部分。

    • 先操作缓存

      依旧是 2 个线程并发「读写」数据:
      1)线程 A 先删除缓存
      2)线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
      3)线程 A 将新值写入数据库(X = 2)
      4)线程 B 将旧值写入缓存(X = 1)
      最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。

      可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。

    • 总结
      上面可以看到,在并发读写情况下,[先操作数据库]优于[先操作缓存],[先操作缓存]发生数据不一致情况
      比[先操作数据库]发生数据不一致情况的概率要高。

      我们也可以通过某种方法使得[先操作缓存]同样数据保持一致,即延迟双删策略
      再看看[先操作缓存]中的线程A,线程B,如果我们添加第5步,线程A再次删除缓存
      只要我们保证线程A的第5步在最后一步执行,就能保证数据库是最新数据,缓存中无数据,保证数据一致
      如何保证线程A的第5步在最后一步执行,我们需要给线程A一个休眠时间,这个休眠时间要大于
      [线程B读缓存,读取失败,线程B读数据库,并将数据写到缓存中]的时间,才能保证线程A删除有效
      休眠太小,线程A再次删除缓存就不是最后一步

      注意:并发和异常的情况是有可能都发生的哦
      也就是说,并发+异常情况下,
      [先操作数据库]虽然并发下,数据暂且认为可以一致(很小概率不一致),但异常下可能会删除缓存操作失败,从而导致数据不一致
      [先操作缓存]虽然并发下增加延迟双删机制保证数据一致,但异常下,最后删除操作失败,又可以导致数据不一致
      现在的问题明确了,删除操作失败怎么办?引入删除重试机制!下面会有详细介绍

  • 在有主从时延的情况下,上面两个方案的比较
    先来个结论:发生写请求后(不管是先操作数据库,还是先删除缓存),在主从数据库同步完成之前,
    如果有读请求,都可能发生读Cache Miss,读从库把旧数据存入缓存的情况。

    上图是先操作缓存,其实先操作数据库也是同样的情况,都会发生数据不一致情况。 主从时延导致的数据不一致问题根本原因是数据库主从不一致引起的,当主库上发生写操作之后, 从库binlog同步的时间间隔内,读请求,可能导致有旧数据入缓存。

    那么主从时延的问题如何解决?这里有一个思路:
    可选择性读主

    那能不能写操作记录下来,在主从时延的时间段内,读取修改过的数据的话,强制读主,并且更新缓存,
    这样子缓存内的数据就是最新。在主从时延过后,这部分数据继续读从库,从而继续利用从库提高读取能力。

    可以利用一个缓存记录必须读主的数据。

    如上图,当写请求发生时:
    1)写主库
    2)将哪个库,哪个表,哪个主键三个信息拼装一个key设置到cache里,这条记录的超时时间,设置为“主从同步时延”
        PS:key的格式为“db:table:PK”,假设主从延时为1s,这个key的cache超时时间也为1s。
    注意:你别跟我说在1,2步之间如果有读请求查询发现没这个key,就去读从库了,没完没了了~
    如上图,当读请求发生时: 这是要读哪个库,哪个表,哪个主键的数据呢,也将这三个信息拼装一个key,到cache里去查询
    1)如果cache里有这个key,说明1s内刚发生过写请求,数据库主从同步可能还没有完成,此时就应该去主库查询。
        并且把主库的数据set到缓存中,防止下一次cahce miss。
    2)如果cache里没有这个key,说明最近没有发生过写请求,此时就可以去从库查询

    所以我们可以知道,可选择性读可以解决主从时延的问题,但终究解决不了删除缓存失败问题。

  • 缓存删除重试机制
    删除缓存失败,如果同步再删除缓存,大概率还会失败,一般都是通过异步的方式
    异步删除缓存分为两种,一种使用消息队列的方式,一种使用订阅数据库的binlog二进制日志文件。

    • 使用消息队列方式

      此时解决方案就是利用消息队列进行删除的补偿。但是这个方案会有一个缺点
      就是会对业务代码造成大量的侵入,深深的耦合在一起,所以我们可以尝试下面的方式。
    • 使用订阅数据库binlog方式
      我们知道对 Mysql 数据库更新操作后再 binlog 日志中我们都能够找到相应的操作,
      那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。 订阅binlog服务方式比直接使用消息队列方式要好,因为订阅binlog服务方式是独立的服务 而消息队列则要集成到微服务中,耦合性要低,阿里巴巴有一个叫canal的框架,就可以帮助我们 将binlog日志采集发送到MQ队列里面,然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性

经过上面的不断验证,有两个完整方案出炉

  • [先操作数据库,再删除缓存]+[订阅数据库binlog,解决删除缓存失败]+[可选择性读,解决主从延迟]
  • [先删除缓存,再操作数据库]+[延迟双删]+[订阅数据库binlog]+[可选择性读]

这两个方案孰优孰劣呢?都挺好的,但都不好,提升了缓存一致性,便降低了并发能力,我就只想用个缓存,给我整这么多~

下面是一些有关文章,本文大量采用其他博客内容,如有雷同,纯属我抄你

三个经典的缓存模式

缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般我们是如何使用缓存呢?有三种经典的缓存模式:

1)Cache-Aside Pattern,旁路缓存模式
2)Read-Through/Write through,读写穿透模式
3)Write behind,异步缓存写入模式
  • Cache-Aside Pattern
    旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。
    该模式是最用的模式,该模式的写流程,读流程如下

    • 读流程
      先读缓存–>缓存命中则直接返回数据–>缓存未命中则查询数据库–>将查询的数据写入缓存并返回数据
    • 写流程
      先操作数据库–>再删除缓存
  • Read-Through/Write through
    这两个模式统称为读写穿透模式,该模式中服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都通过抽象缓存层完成的
    抽象缓存层是请求和缓存之间的一层,我们只需要操作抽象缓存层即可,不用管下面如何实现,计算机不就是
    不停的封装,抽象嘛,任何问题都可以通过加一层来解决,如果不行,加两层~
    让我们看看Read-Through/Write through的工作流程

    • Read-Through Read-Through实际只是在**Cache-Aside**之上进行了一层封装,它会让程序代码变得更简洁,同时也减少数据源上的负载。
    • Write through
      Write-Through模式下,当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的更新,流程如下:
  • Write behind

    异步缓存写入模式,Write behind跟Read-Through/Write-Through有相似的地方,
    都是由Cache Provider来负责缓存和数据库的读写。它俩又有个很大的不同:
    Read/Write Through是同步更新缓存和数据的,
    Write Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。

    这种方式下,缓存和数据库的一致性不强,**对一致性要求高的系统要谨慎使用**。但是它适合频繁写的场景, MySQL的**InnoDB Buffer Pool机制**就使用到这种模式。

多级缓存的概念

聊多级缓存之前,我们看看之前我们脑中缓存的流程:

请求来到后端服务器,后端服务器查询redis,如果查询失败,则查询数据库,查到后将数据回填到redis中

那么多级缓存是个什么概念呢?请看下图:

1,请求先查询浏览器缓存(localStorage,SessionStorage)
2,如果没查到,请求来看nginx反向代理服务器,经过反向代理,来到nginx的本地缓存集群
    使用nginx存储本地缓存
3,如果nginx本地缓存中没有数据,则通过OpenResty框架+Lua脚本语言向redis查询数据
4,如果nginx查询redis失败,则nginx请求后端服务器,查询tomcat中的进程缓存,进程缓存指的是运行在JVM中的缓存
    例如集合,map之类的,当然更多的是使用Caffeine库,这是一个高性能的进程缓存库,spring内部的缓存使用的就是caffeine
5,如果进程缓存库中也没有数据,则直接查询数据库,并将数据回填到上面各个缓存中。

上面流程中,最主要就是nginx本地缓存能够查询redis,查询tomcat,和进程缓存的使用。
nginx之所以可以被编程,全靠OpenResty框架,这是一个封装了nginx的lua库,使得nginx可以编程代码了。
当然语言用的是lua脚本语言。

具体lua基本语法,进程缓存的caffeine的API使用就不多说了,百度查一查都有,多级缓存网课资料