服务端缓存技术总结

1、使用场景

什么状况适合用缓存?考虑如下两种场景:html

  • 短期内相同数据重复查询屡次且数据更新不频繁,这个时候能够选择先从缓存查询,查询不到再从数据库加载并回设到缓存的方式
  • 高并发查询和更新热点数据,后端数据库不堪重负,能够用缓存来扛。

2、缓存利弊

(1) 加速读写:一般来讲加速是明显的,由于缓存一般都是全内存的系统,然后端(多是mysql、甚至是别人的HTTP, RPC接口)都有速度慢和抗压能力差的特性,经过缓存的使用能够有效的提升用户的访问速度同时优化了用户的体验。java

(2) 下降后端负载:经过缓存的添加,若是程序没有什么问题,在命中率还能够的状况下,能够帮助后端减小访问量和复杂计算(join、或者没法在优化的sql等),在很大程度下降了后端的负载。mysql

弊(代价)

 (1) 数据不一致性:不管你的设计作的多么好,缓存数据与权威数据源(能够理解成真实或者后端数据源)必定存在着必定时间窗口的数据不一致性,这个时间窗口的大小可大可小,具体多大还要看一下你的业务容许多大时间窗口的不一致性。redis

 (2) 代码维护成本:加入缓存后,代码就会在原数据源基础上加入缓存的相关代码,例如原来只是一些sql, 如今要加入k-v缓存,必然增长了代码的维护成本。算法

 (3) 架构复杂度:加入缓存后,例如加入了redis-cluster,通常来讲缓存不会像Mysql有专门的DBA,颇有可能没有专职的管理人员,因此也增长了架构的复杂度和维护成本。sql

若是要加入选择了缓存,必定要能给出足够的理由,不是为了简单的show技术和想固然,最好的方法就是用数听说话:加速比有多少、后端负载下降了多少。数据库

3、缓存分类

1. 本地缓存

(1) 缓存和应用在一个JVM中,请求缓存快速,没有网络传输的开销。编程

(2) 缓存间是不通讯的、独立的,应用程序和缓存耦合,多个应用程序没法直接共享缓存,缓存单独维护,对内存是一种浪费。后端

(3) 弱一致性。缓存

常见本地缓存

(1)本地编程直接实现

成员变量或者局部变量实现,以局部变量map结构缓存部分业务数据,减小频繁的重复数据库I/O操做。缺点仅限于类的自身做用域内,类间没法共享缓存;

静态变量实现,实现类间共享。那么如何解决本地缓存的实时性问题,实现自动更新缓存?目前大量使用的是结合ZooKeeper的自动发现机制,实时变动本地静态变量缓存。

                                来自美团点评技术中心

                                               (上图来自美团点评技术中心博客

这类缓存实现,优势是能直接在heap区内读写,最快也最方便;缺点一样是受heap区域影响,缓存的数据量很是有限,同时缓存时间受GC影响(JVM在进行垃圾回收时,会致使全部的工做线程暂停(stop the world),GC成为影响Java程序性能的重要因素)。主要知足单机场景下的小数据量缓存需求,同时对缓存数据的变动无需太敏感感知,如上通常配置管理、基础静态数据等场景。

(2) Ehcache

Ehcache是如今最流行的纯Java开源缓存框架,配置简单、结构清晰、功能强大,是一个很是轻量级的缓存实现,咱们经常使用的Hibernate(Hibernate二级缓存)里面就集成了相关缓存功能。

须要注意的是,虽然Ehcache支持磁盘的持久化,可是因为存在两级缓存介质,在一级内存中的缓存,若是没有主动的刷入磁盘持久化的话,在应用异常down机等情形下,依然会出现缓存数据丢失,为此能够根据须要将缓存刷到磁盘,将缓存条目刷到磁盘的操做能够经过cache.flush()方法来执行,须要注意的是,对于对象的磁盘写入,前提是要将对象进行序列化。

(3)Guava Cache

继承了ConcurrentHashMap的思路,使用多个segments方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。Cache相似于Map,它是存储键值对的集合,不一样的是它还须要处理evict、expire、dynamic load等算法逻辑,须要一些额外信息来实现这些操做。

2. Standalone(单机)

                                 

(1) 缓存和应用是独立部署的。

(2) 缓存能够是单台。(例如memcache/redis单机等等)

(3) 强一致性

(4) 无高可用、无分布式。

(5) 跨进程、跨网络

3. Distributed(分布式)

                                

例如Redis-Cluster, memcache集群等等

(1) 缓存和应用是独立部署的。

(2) 多个实例。(例如memcache/redis等等)

(3) 强一致性或者最终一致性

(4) 支持Scale Out、高可用。

(5) 跨进程、跨网络

memcache集群

memcached是应用较广的开源分布式缓存产品之一,它自己其实不提供分布式解决方案。在服务端,memcached集群环境实际就是一个个memcached服务器的堆积,环境搭建较为简单;cache的分布式主要是在客户端实现,经过客户端的路由处理来达到分布式解决方案的目的。

redis集群

与memcached客户端支持分布式方案不一样,Redis更倾向于在服务端构建分布式存储。

Redis Cluster是一个实现了分布式且容许单点故障的Redis高级版本,它没有中心节点,具备线性可伸缩的功能。

4、选型考虑

  • 若是数据量小,而且不会频繁地增加又清空(这会致使频繁地垃圾回收),那么能够选择本地缓存。具体的话,若是须要一些策略的支持(好比缓存满的逐出策略),能够考虑Ehcache;如不须要,能够考虑HashMap;如须要考虑多线程并发的场景,能够考虑ConcurentHashMap。
  • 其余状况,能够考虑缓存服务。目前从资源的投入度、可运维性、是否能动态扩容以及配套设施来考虑,咱们优先考虑Tair。除非目前Tair还不能支持的场合(好比分布式锁、Hash类型的value),咱们考虑用Redis。

5、设计关键点

何时更新缓存?如何保障更新的可靠性和实时性?

  • (被动)接收变动消息,准实时的更新。
  • (主动)设置过时时间,过时以后从DB捞数据而且回设到缓存,这个策略是对第一个策略的有力补充,解决了手动变动DB不发消息、接消息更新程序临时出错等问题致使的第一个策略失效的问题。经过这种双保险机制,有效地保证了缓存数据的可靠性和实时性。

缓存是否会满,缓存满了怎么办?

对于一个缓存服务,理论上来讲,随着缓存数据的日益增多,在容量有限的状况下,缓存确定有一天会满的。如何应对?
① 给缓存服务,选择合适的缓存逐出算法,好比最多见的LRU。
② 针对当前设置的容量,设置适当的警惕值,好比10G的缓存,当缓存数据达到8G的时候,就开始发出报警,提早排查问题或者扩容。
③ 给一些没有必要长期保存的key,尽可能设置过时时间。

缓存是否容许丢失?丢失了怎么办?

根据业务场景判断,是否容许丢失。若是不容许,就须要带持久化功能的缓存服务来支持,好比Redis或者Tair。更细节的话,能够根据业务对丢失时间的容忍度,还能够选择更具体的持久化策略,好比Redis的RDB或者AOF

简单理解:

RDB持久化,把当前进程数据生成快照保存到硬盘的过程。

AOF持久化,以独立日志的方式记录每次写命令,重启时再从新执行AOF文件中的命令达到恢复数据的目的。

6、缓存算法

缓存容量超过预设,如何踢掉“无用”的数据。

FIFO(first in first out)

先进先出策略,最早进入缓存的数据在缓存空间不够的状况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的建立时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。

LFU(less frequently used)

最少使用策略,不管是否过时,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。在保证高频数据有效性场景下,可选择这类策略。

LRU(least recently used)

最近最少使用策略,不管是否过时,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。在热点数据场景下较适用,优先保证热点数据的有效性。与LFU的存在必定区别

                                        

                                                    图-LRU示意图

能够想象,要清理哪些数据,不是由开发者决定(只能决定大体方向:以上策略算法),数据的一致性是最差的。

通常来讲咱们都须要配置超过最大缓存后的更新策略(例如:LRU)以及最大内存,这样能够保证系统能够继续运行(例如redis可能存在OOM问题)(极端状况下除外,数据一致性要求极高)

超时剔除

通常来讲业务能够容忍一段时间内(例如一个小时),缓存数据和真实数据(例如:mysql, hbase等等)数据不一致(通常来讲,缓存能够提升访问速度下降后端负载),那么咱们能够对一个数据设置必定时间的过时时间,在数据过时后,再从真实数据源获取数据,从新放到缓存中,继续设置过时时间。一段时间内(取决于过时时间)存在数据一致性问题,即缓存数据和真实数据源数据不一致。

主动更新

具备强一致性,维护成本高。业务对于数据的一致性要求很高,须要在真实数据更新后,当即更新缓存数据。具体作法:例如能够利用消息系统或者其余方式(好比数据库触发器,或者其余数据源的listener机制来完成)通知缓存更新。

存在的问题:若是主动更新发生了问题,那么这条数据极可能很长时间不会更新了。

通常来讲咱们须要把超时剔除和主动更新组合使用,那样即便主动更新出了问题,也能保证过时时间后,缓存就被清除了(不至于永远都是脏数据)。

7、缓存使用中的坑与对策

缓存粒度

假如我如今须要对视频的信息作一个缓存,也就是须要对select * from video where id=?的每一个id在redis里作一份缓存,这样cache层就能够帮助我抗住不少的访问量(注:这里不讨论一致性和架构等等问题,只讨论缓存的粒度问题)。

咱们假设视频表有100个属性(这个真有,有些人可能不可思议),那么问题来了,须要缓存什么维度呢,也就是有两种选择吧:

(1)cache(id)=select * from video where id=#id  

(2)cache(id)=select importantColumn1, importantColumn2 .. importantColumnN from video where id=#id  

以上这两种方式在通用性、空间占用和代码维护方面均存在较大差别。

缓存粒度问题是一个容易被忽视的问题,若是使用不当,可能会形成不少无用空间的浪费,可能会形成网络带宽的浪费,可能会形成代码通用性较差等状况,必须学会综合数据通用性、空间占用比、代码维护性 三点评估取舍因素权衡使用。

缓存穿透

缓存穿透是指查询一个必定不存在的数据,因为缓存不命中,而且出于容错考虑, 若是从存储层查不到数据则不写入缓存,这将致使这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。查一个压根就不存在的值, 若是不作兼容,永远会查询storage。

如何解决?

方案一

                                   

        (1) 如上图所示,当第②步MISS后,仍然将空对象保留到Cache中(多是保留几分钟或者一段时间,具体问题具体分析),下次新的Request(同一个key)将会从Cache中获取到数据,保护了后端的Storage。

        (2) 适用场景:数据命中不高,数据频繁变化实时性高(一些乱转业务)

        (3) 维护成本:代码比较简单,可是有两个问题:

            第一是空值作了缓存,意味着缓存系统中存了更多的key-value,也就是须要更多空间(有人说空值没多少,可是架不住多啊),解决方法是咱们能够设置一个较短的过时时间。

            第二是数据会有一段时间窗口的不一致,假如,Cache设置了5分钟过时,此时Storage确实有了这个数据的值,那此段时间就会出现数据不一致,解决方法是咱们能够利用消息或者其余方式,清除掉Cache中的数据。

方案二

bloomfilter或者压缩filter(bitmap等等)提早拦截。

                                

                                            图-布隆过滤器解决缓存穿透示意图

方案三(技术分享)

存在问题的策略

                                

解决后的策略

                                

缓存雪崩

若是Cache层因为某些缘由(宕机、cache服务挂了或者不响应了)总体crash掉了,也就意味着全部的请求都会达到Storage层,全部Storage的调用量会暴增,因此它有点扛不住了,甚至也会挂掉。

如何解决?

方案一

保证Cache服务高可用性,和飞机都有多个引擎同样,若是咱们的cache也是高可用的,即便个别实例挂掉了,影响不会很大(主从切换或者可能会有部分流量到了后端),实现自动化运维。一致性hash算法能够很好地解决由于cache集群节点宕机时数据存取变化问题,具备良好的可扩展性。

方案二

其实不管是cache或者是mysql, hbase, 甚至别人的API,都会出现问题,咱们能够将这些视同为资源,做为并发量较大的系统,在服务不可用或者并发量过大会对系统形成影响时,设置必定的降级、限流、隔离等策略。

无底洞问题

键值数据库或者缓存系统,因为一般采用hash函数将key映射到对应的实例,形成key的分布与业务无关,可是因为数据量、访问量的需求,须要使用分布式后(不管是客户端一致性哈性、redis-cluster、codis),批量操做好比批量获取多个key(例如redis的mget操做),一般须要从不一样实例获取key值,相比于单机批量操做只涉及到一次网络操做,分布式批量操做会涉及到屡次网络io。

无底洞问题带来的危害

  (1) 客户端一次批量操做会涉及屡次网络操做,也就意味着批量操做会随着实例的增多,耗时会不断增大。

  (2) 服务端网络链接次数变多,对实例的性能也有必定影响。

用一句通俗的话总结:更多的机器不表明更多的性能,所谓“无底洞”就是说投入越多不必定产出越多。分布式又是不能够避免的,由于咱们的网站访问量和数据量愈来愈大,一个实例根本坑不住,因此如何高效的在分布式缓存和存储批量获取数据是一个难点。

热点key问题

在缓存失效的瞬间,有大量线程来构建缓存(缓存的构建是须要必定时间的。(多是一个复杂计算,例如复杂的sql、屡次IO、多个依赖(各类接口)等等)),形成后端负载加大,甚至可能会让系统崩溃。

如何解决?

方案一

使用互斥锁(mutex key): 这种解决方案思路比较简单,就是只让一个线程构建缓存,其余线程等待构建缓存的线程执行完,从新从缓存获取数据就能够了。

若是是单机,能够用synchronized或者lock来处理,若是是分布式环境能够用分布式锁就能够了(分布式锁,能够用memcache的add, redis的setnx, zookeeper的添加节点操做)

                                

                                                    图-互斥锁解决热点key问题分析

方案二

"提早"使用互斥锁(mutex key),在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已通过期时候,立刻延长timeout1并从新设置到cache。而后再从数据库加载数据并设置到cache中。

方案三

“永远不过时”

 (1) 从redis上看,确实没有设置过时时间,这就保证了,不会出现热点key过时问题,也就是“物理”不过时。

 (2) 从功能上看,若是不过时,那不就成静态的了吗?因此咱们把过时时间存在key对应的value里,若是发现要过时了,经过一个后台的异步线程进行缓存的构建,也就是“逻辑”过时。

从实战看,这种方法对于性能很是友好,惟一不足的就是构建缓存时候,其他线程(非构建缓存的线程)可能访问的是老数据,可是对于通常的互联网功能来讲这个仍是能够忍受。

方案四

hystrix资源保护

方案五

使用mutex

如何解决:业界比较经常使用的作法,是使用mutex。简单地来讲,就是在缓存失效的时候(判断拿出来的值为空),不是当即去load db,而是先使用缓存工具的某些带成功操做返回值的操做(好比Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操做返回成功时,再进行load db的操做并回设缓存;不然,就重试整个get缓存的方法。相似下面的代码:

public String get(key) {
      String value = redis.get(key);
      if (value == null) { //表明缓存值过时
          //设置3min的超时,防止del操做失败的时候,下次缓存过时一直不能load db
          if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {  //表明设置成功
               value = db.get(key);
                      redis.set(key, value, expire_secs);
                      redis.del(key_mutex);
              } else {  //这个时候表明同时候的其余线程已经load db并回设到缓存了,这时候重试获取缓存值便可
                      sleep(50);
                      get(key);  //重试
              }
          } else {
              return value;      
          }
  }

 

8、缓存更新的模式

缓存更新模式指的是如何更新数据库和缓存,特别是在并发环境下避免脏数据等错误,如下提供了一些可借鉴的更新模式(或者说是缓存更新的经常使用套路)。

Cache Aside更新模式

                                  

                                                     图-Cache Aside更新模式

这种方式属于比较标准的缓存更新模式,即先更新数据库,再删除缓存,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略,在实际的系统中也推荐使用这种方式。可是这种方式理论上仍是可能存在问题。以下图(以Redis和Mysql为例),查询操做没有命中缓存,而后查询出数据库的老数据。此时有一个并发的更新操做,更新操做在读操做以后更新了数据库中的数据而且删除了缓存中的数据。然而读操做将从数据库中读取出的老数据更新回了缓存。这样就会形成数据库和缓存中的数据不一致,应用程序中读取的都是原来的数据(脏数据)。可是这种并发的几率极低,由于这个条件须要发生在读缓存时缓存失效并且有一个并发的写操做。实际上数据库的写操做会比读操做慢得多,并且还要加锁,而读操做必需在写操做前进入数据库操做,又要晚于写操做更新缓存,全部这些条件都具有的几率并不大。可是为了不这种极端状况形成脏数据所产生的影响,咱们仍是要为缓存设置过时时间。

                                  

                                                        图-Cache Aside更新模式潜在问题分析(低几率事件)

常见的错误作法及缘由分析以下:

先更新数据库,再更新缓存。这种作法最大的问题就是两个并发的写操做致使脏数据。两个并发更新操做,数据库先更新的反然后更新缓存,数据库后更新的反而先更新缓存。这样就会形成数据库和缓存中的数据不一致,应用程序中读取的都是脏数据。

                                  

                                                        图-先更新数据库再更新缓存错误分析

先删除缓存,再更新数据库。这个逻辑是错误的,由于两个并发的读和写操做致使脏数据。以下图(以Redis和Mysql为例)。假设更新操做先删除了缓存,此时正好有一个并发的读操做,没有命中缓存后从数据库中取出老数据而且更新回缓存,这个时候更新操做也完成了数据库更新。此时,数据库和缓存中的数据不一致,应用程序中读取的都是原来的数据(脏数据)。

                                

                                                       图-先删除缓存再更新数据库错误分析

Read/Write Through更新模式

咱们能够看到,在上面的Cache Aside套路中,咱们的应用代码须要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。因此,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操做由缓存本身代理了,因此,对于应用层来讲,就简单不少了。能够理解为,应用认为后端就是一个单一的存储,而存储本身维护本身的Cache。

Read Through 套路就是在查询操做中更新缓存,也就是说,当缓存失效的时候(过时或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务本身来加载,从而对应用方是透明的。

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,若是没有命中缓存,直接更新数据库,而后返回。若是命中了缓存,则更新缓存,而后再由Cache本身更新数据库这是一个同步操做

Write Behind Caching更新模式

在更新数据的时候,只更新缓存,不更新数据库,而咱们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操做飞快无比(由于直接操做内存),异步还能够合并对同一个数据的屡次操做,因此性能的提升是至关可观的。可是,其带来的问题是数据不是强一致性的,并且可能会丢失。

和Read/Write Through更新模式相似,区别是Write Behind Caching更新模式的数据持久化操做是异步的,可是Read/Write Through更新模式的数据持久化操做是同步的。

9、二级缓存

                                

10、典型使用场景举例

场景一:同一热卖商品高并发读/写请求

读请求:本地缓存结合分布式缓存集群,为缩短本地缓存和分布式缓存之间的数据不一致窗口期,能够引入消息队列。

如何进行缓存的更新?

更新时主动发消息,借助缓存更新程序进行更新;

设置key过时时间(防止消息丢失或者缓存更新程序更新失败);

借助DataBus实时更新(解决缓存和DB的不一致性)

写请求:可直接在缓存中进行库存信息操做,如何防止超卖?引入用ZK实现的分布式锁

防止超卖的核心在于:不容许同一商品的库存记录在同一时刻被不一样的两个数据库事务修改。

如何作到?利用数据库的事务锁机制;分布式锁(基于ZK或者Redis)

场景二:小/大库存商品秒杀典型架构

小库存:数据库乐观锁实现库存信息修改;

大库存:直接在缓存中修改库存,使用分布式锁防止超卖;

场景三:缓存业务接口查询数据避免重复的数据库查询

具体能够采起切面的形式以无侵入方式实现,对接口总体返回结果进行缓存。

参考连接

http://carlosfu.iteye.com/blog/2269678(感谢大神的系列文章)

https://tech.meituan.com/cache_about.html

相关文章
相关标签/搜索