缓存那些事-zz

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

前言

通常而言,如今互联网应用(网站或App)的总体流程,能够归纳如图1所示,用户请求从界面(浏览器或App界面)到网络转发、应用服务再到存储(数据库或文件系统),而后返回到界面呈现内容。html

随着互联网的普及,内容信息愈来愈复杂,用户数和访问量愈来愈大,咱们的应用须要支撑更多的并发量,同时咱们的应用服务器和数据库服务器所作的计算也愈来愈多。可是每每咱们的应用服务器资源是有限的,且技术变革是缓慢的,数据库每秒能接受的请求次数也是有限的(或者文件的读写也是有限的),如何可以有效利用有限的资源来提供尽量大的吞吐量?一个有效的办法就是引入缓存,打破标准流程,每一个环节中请求能够从缓存中直接获取目标数据并返回,从而减小计算量,有效提高响应速度,让有限的资源服务更多的用户。java

如图1所示,缓存的使用能够出如今1~4的各个环节中,每一个环节的缓存方案与使用各有特色。node

图1 互联网应用通常流程程序员

缓存特征

缓存也是一个数据模型对象,那么必然有它的一些特征:redis

命中率

命中率=返回正确结果数/请求缓存次数,命中率问题是缓存中的一个很是重要的问题,它是衡量缓存有效性的重要指标。命中率越高,代表缓存的使用率越高。算法

最大元素(或最大空间)

缓存中能够存放的最大元素的数量,一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略根据不一样的场景合理的设置最大元素值每每能够必定程度上提升缓存的命中率,从而更有效的时候缓存。spring

清空策略

如上描述,缓存的存储空间有限制,当缓存空间被用满时,如何保证在稳定服务的同时有效提高命中率?这就由缓存清空策略来处理,设计适合自身数据特征的清空策略能有效提高命中率。常见的通常策略有:数据库

  • FIFO(first in first out)编程

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

  • LFU(less frequently used)

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

  • LRU(least recently used)

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

除此以外,还有一些简单策略好比:

  • 根据过时时间判断,清理过时时间最长的元素;
  • 根据过时时间判断,清理最近要过时的元素;
  • 随机清理;
  • 根据关键字(或元素内容)长短清理等。

缓存介质

虽然从硬件介质上来看,无非就是内存和硬盘两种,但从技术上,能够分红内存、硬盘文件、数据库。

  • 内存:将缓存存储于内存中是最快的选择,无需额外的I/O开销,可是内存的缺点是没有持久化落地物理磁盘,一旦应用异常break down而从新启动,数据很难或者没法复原。
  • 硬盘:通常来讲,不少缓存框架会结合使用内存和硬盘,在内存分配空间满了或是在异常的状况下,能够被动或主动的将内存空间数据持久化到硬盘中,达到释放空间或备份数据的目的。
  • 数据库:前面有提到,增长缓存的策略的目的之一就是为了减小数据库的I/O压力。如今使用数据库作缓存介质是否是又回到了老问题上了?其实,数据库也有不少种类型,像那些不支持SQL,只是简单的key-value存储结构的特殊数据库(如BerkeleyDB和Redis),响应速度和吞吐量都远远高于咱们经常使用的关系型数据库等。

缓存分类和应用场景

缓存有各种特征,并且有不一样介质的区别,那么实际工程中咱们怎么去对缓存分类呢?在目前的应用服务框架中,比较常见的,时根据缓存雨应用的藕合度,分为local cache(本地缓存)和remote cache(分布式缓存):

  • 本地缓存:指的是在应用中的缓存组件,其最大的优势是应用和cache是在同一个进程内部,请求缓存很是快速,没有过多的网络开销等,在单应用不须要集群支持或者集群状况下各节点无需互相通知的场景下使用本地缓存较合适;同时,它的缺点也是应为缓存跟应用程序耦合,多个应用程序没法直接的共享缓存,各应用或集群的各节点都须要维护本身的单独缓存,对内存是一种浪费。

  • 分布式缓存:指的是与应用分离的缓存组件或服务,其最大的优势是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存。

目前各类类型的缓存都活跃在成千上万的应用服务中,尚未一种缓存方案能够解决一切的业务场景或数据类型,咱们须要根据自身的特殊场景和背景,选择最适合的缓存方案。缓存的使用是程序员、架构师的必备技能,好的程序员能根据数据类型、业务场景来准确判断使用何种类型的缓存,如何使用这种缓存,以最小的成本最快的效率达到最优的目的。

本地缓存

编程直接实现缓存

个别场景下,咱们只须要简单的缓存数据的功能,而无需关注更多存取、清空策略等深刻的特性时,直接编程实现缓存则是最便捷和高效的。

a. 成员变量或局部变量实现

简单代码示例以下:

public void UseLocalCache(){
     //一个本地的缓存变量
     Map<String, Object> localCacheStoreMap = new HashMap<String, Object>();

    List<Object> infosList = this.getInfoList();
    for(Object item:infosList){
        if(localCacheStoreMap.containsKey(item)){ //缓存命中 使用缓存数据
            // todo
        } else { // 缓存未命中  IO获取数据,结果存入缓存
            Object valueObject = this.getInfoFromDB();
            localCacheStoreMap.put(valueObject.toString(), valueObject);

        }
    }
}
//示例
private List<Object> getInfoList(){
    return new ArrayList<Object>();
}
//示例数据库IO获取
private Object getInfoFromDB(){
    return new Object();
}

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

b. 静态变量实现

最经常使用的单例实现静态资源缓存,代码示例以下:

public class CityUtils {
      private static final HttpClient httpClient = ServerHolder.createClientWithPool(); 
      private static Map<Integer, String> cityIdNameMap = new HashMap<Integer, String>();
      private static Map<Integer, String> districtIdNameMap = new HashMap<Integer, String>();

  static {
    HttpGet get = new HttpGet("http://gis-in.sankuai.com/api/location/city/all");
    BaseAuthorizationUtils.generateAuthAndDateHeader(get,
            BaseAuthorizationUtils.CLIENT_TO_REQUEST_MDC,
            BaseAuthorizationUtils.SECRET_TO_REQUEST_MDC);
    try {
        String resultStr = httpClient.execute(get, new BasicResponseHandler());
        JSONObject resultJo = new JSONObject(resultStr);
        JSONArray dataJa = resultJo.getJSONArray("data");
        for (int i = 0; i < dataJa.length(); i++) {
            JSONObject itemJo = dataJa.getJSONObject(i);
            cityIdNameMap.put(itemJo.getInt("id"), itemJo.getString("name"));
        }
    } catch (Exception e) {
        throw new RuntimeException("Init City List Error!", e);
    }
}
    static {
    HttpGet get = new HttpGet("http://gis-in.sankuai.com/api/location/district/all");
    BaseAuthorizationUtils.generateAuthAndDateHeader(get,
            BaseAuthorizationUtils.CLIENT_TO_REQUEST_MDC,
            BaseAuthorizationUtils.SECRET_TO_REQUEST_MDC);
    try {
        String resultStr = httpClient.execute(get, new BasicResponseHandler());
        JSONObject resultJo = new JSONObject(resultStr);
        JSONArray dataJa = resultJo.getJSONArray("data");
        for (int i = 0; i < dataJa.length(); i++) {
            JSONObject itemJo = dataJa.getJSONObject(i);
            districtIdNameMap.put(itemJo.getInt("id"), itemJo.getString("name"));
        }
    } catch (Exception e) {
        throw new RuntimeException("Init District List Error!", e);
    }
}

    public static String getCityName(int cityId) {
      String name = cityIdNameMap.get(cityId);
      if (name == null) {
        name = "未知";
      }
       return name;
     }

    public static String getDistrictName(int districtId) {
      String name = districtIdNameMap.get(districtId);
       if (name == null) {
         name = "未知";
        }
       return name;
     }
   }

O2O业务中经常使用的城市基础基本信息判断,经过静态变量一次获取缓存内存中,减小频繁的I/O读取,静态变量实现类间可共享,进程内可共享,缓存的实时性稍差。

为了解决本地缓存数据的实时性问题,目前大量使用的是结合ZooKeeper的自动发现机制,实时变动本地静态变量缓存:

美团点评内部的基础配置组件MtConfig,采用的就是相似原理,使用静态变量缓存,结合ZooKeeper的统一管理,作到自动动态更新缓存,如图2所示。

 

图2 Mtconfig实现图

这类缓存实现,优势是能直接在heap区内读写,最快也最方便;缺点一样是受heap区域影响,缓存的数据量很是有限,同时缓存时间受GC影响。主要知足单机场景下的小数据量缓存需求,同时对缓存数据的变动无需太敏感感知,如上通常配置管理、基础静态数据等场景。

Ehcache

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

图3 Ehcache框架图

从图3中咱们能够了解到,Ehcache的核心定义主要包括:

  • cache manager:缓存管理器,之前是只容许单例的,不过如今也能够多实例了。

  • cache:缓存管理器内能够放置若干cache,存放数据的实质,全部cache都实现了Ehcache接口,这是一个真正使用的缓存实例;经过缓存管理器的模式,能够在单个应用中轻松隔离多个缓存实例,独立服务于不一样业务场景需求,缓存数据物理隔离,同时须要时又可共享使用。

  • element:单条缓存数据的组成单位。

  • system of record(SOR):能够取到真实数据的组件,能够是真正的业务逻辑、外部接口调用、存放真实数据的数据库等,缓存就是从SOR中读取或者写入到SOR中去的。

在上层能够看到,整个Ehcache提供了对JSR、JMX等的标准支持,可以较好的兼容和移植,同时对各种对象有较完善的监控管理机制。它的缓存介质涵盖堆内存(heap)、堆外内存(BigMemory商用版本支持)和磁盘,各介质可独立设置属性和策略。Ehcache最初是独立的本地缓存框架组件,在后期的发展中,结合Terracotta服务阵列模型,能够支持分布式缓存集群,主要有RMI、JGroups、JMS和Cache Server等传播方式进行节点间通讯,如图3的左侧部分描述。

总体数据流转包括这样几类行为:

  • Flush:缓存条目向低层次移动。
  • Fault:从低层拷贝一个对象到高层。在获取缓存的过程当中,某一层发现本身的该缓存条目已经失效,就触发了Fault行为。
  • Eviction:把缓存条目除去。
  • Expiration:失效状态。
  • Pinning:强制缓存条目保持在某一层。

图4反映了数据在各个层之间的流转,同时也体现了各层数据的一个生命周期。

图4 缓存数据流转图(L1:本地内存层;L2:Terracotta服务节点层)

Ehcache的配置使用以下:

<ehcache>
<!-- 指定一个文件目录,当Ehcache把数据写到硬盘上时,将把数据写到这个文件目录下 -->
<diskStore path="java.io.tmpdir"/>

<!-- 设定缓存的默认数据过时策略 -->
<defaultCache
        maxElementsInMemory="10000"
        eternal="false"
        overflowToDisk="true"
        timeToIdleSeconds="0"
        timeToLiveSeconds="0"
        diskPersistent="false"
        diskExpiryThreadIntervalSeconds="120"/>

<!--  
    设定具体的命名缓存的数据过时策略

    cache元素的属性:
        name:缓存名称

        maxElementsInMemory:内存中最大缓存对象数

        maxElementsOnDisk:硬盘中最大缓存对象数,如果0表示无穷大

        eternal:true表示对象永不过时,此时会忽略timeToIdleSeconds和timeToLiveSeconds属性,默认为false

        overflowToDisk:true表示当内存缓存的对象数目达到了maxElementsInMemory界限后,会把溢出的对象写到硬盘缓存中。注意:若是缓存的对象要写入到硬盘中的话,则该对象必须实现了Serializable接口才行。

        diskSpoolBufferSizeMB:磁盘缓存区大小,默认为30MB。每一个Cache都应该有本身的一个缓存区。

        diskPersistent:是否缓存虚拟机重启期数据

        diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认为120秒

        timeToIdleSeconds: 设定容许对象处于空闲状态的最长时间,以秒为单位。当对象自从最近一次被访问后,若是处于空闲状态的时间超过了timeToIdleSeconds属性值,这个对象就会过时,EHCache将把它从缓存中清空。只有当eternal属性为false,该属性才有效。若是该属性值为0,则表示对象能够无限期地处于空闲状态

        timeToLiveSeconds:设定对象容许存在于缓存中的最长时间,以秒为单位。当对象自从被存放到缓存中后,若是处于缓存中的时间超过了 timeToLiveSeconds属性值,这个对象就会过时,Ehcache将把它从缓存中清除。只有当eternal属性为false,该属性才有效。若是该属性值为0,则表示对象能够无限期地存在于缓存中。timeToLiveSeconds必须大于timeToIdleSeconds属性,才有意义

        memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。可选策略有:LRU(最近最少使用,默认策略)、FIFO(先进先出)、LFU(最少访问次数)。
-->
<cache name="CACHE1"
       maxElementsInMemory="1000"
       eternal="true"
       overflowToDisk="true"/>  

<cache name="CACHE2"
    maxElementsInMemory="1000"
    eternal="false"
    timeToIdleSeconds="200"
    timeToLiveSeconds="4000"
    overflowToDisk="true"/>
</ehcache>

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

主要特性:

  • 快速,针对大型高并发系统场景,Ehcache的多线程机制有相应的优化改善。
  • 简单,很小的jar包,简单配置就可直接使用,单机场景下无需过多的其余服务依赖。
  • 支持多种的缓存策略,灵活。
  • 缓存数据有两级:内存和磁盘,与通常的本地内存缓存相比,有了磁盘的存储空间,将能够支持更大量的数据缓存需求。
  • 具备缓存和缓存管理器的侦听接口,能更简单方便的进行缓存实例的监控管理。
  • 支持多缓存管理器实例,以及一个实例的多个缓存区域。

注意:Ehcache的超时设置主要是针对整个cache实例设置总体的超时策略,而没有较好的处理针对单独的key的个性的超时设置(有策略设置,可是比较复杂,就不描述了),所以,在使用中要注意过时失效的缓存元素没法被GC回收,时间越长缓存越多,内存占用也就越大,内存泄露的几率也越大。

Guava Cache

Guava Cache是Google开源的Java重用工具集库Guava里的一款缓存工具,其主要实现的缓存功能有:

  • 自动将entry节点加载进缓存结构中;
  • 当缓存的数据超过设置的最大值时,使用LRU算法移除;
  • 具有根据entry节点上次被访问或者写入时间计算它的过时机制;
  • 缓存的key被封装在WeakReference引用内;
  • 缓存的Value被封装在WeakReference或SoftReference引用内;
  • 统计缓存使用过程当中命中率、异常率、未命中率等统计数据。

Guava Cache的架构设计灵感来源于ConcurrentHashMap,咱们前面也提到过,简单场景下能够自行编码经过hashmap来作少许数据的缓存,可是,若是结果可能随时间改变或者是但愿存储的数据空间可控的话,本身实现这种数据结构仍是有必要的。

Guava Cache继承了ConcurrentHashMap的思路,使用多个segments方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。Cache相似于Map,它是存储键值对的集合,不一样的是它还须要处理evict、expire、dynamic load等算法逻辑,须要一些额外信息来实现这些操做。对此,根据面向对象思想,须要作方法与数据的关联封装。如图5所示cache的内存数据模型,能够看到,使用ReferenceEntry接口来封装一个键值对,而用ValueReference来封装Value值,之因此用Reference命令,是由于Cache要支持WeakReference Key和SoftReference、WeakReference value。

图5 Guava Cache数据结构图

ReferenceEntry是对一个键值对节点的抽象,它包含了key和值的ValueReference抽象类,Cache由多个Segment组成,而每一个Segment包含一个ReferenceEntry数组,每一个ReferenceEntry数组项都是一条ReferenceEntry链,且一个ReferenceEntry包含key、hash、valueReference、next字段。除了在ReferenceEntry数组项中组成的链,在一个Segment中,全部ReferenceEntry还组成access链(accessQueue)和write链(writeQueue)(后面会介绍链的做用)。ReferenceEntry能够是强引用类型的key,也能够WeakReference类型的key,为了减小内存使用量,还能够根据是否配置了expireAfterWrite、expireAfterAccess、maximumSize来决定是否须要write链和access链肯定要建立的具体Reference:StrongEntry、StrongWriteEntry、StrongAccessEntry、StrongWriteAccessEntry等。

对于ValueReference,由于Cache支持强引用的Value、SoftReference Value以及WeakReference Value,于是它对应三个实现类:StrongValueReference、SoftValueReference、WeakValueReference。为了支持动态加载机制,它还有一个LoadingValueReference,在须要动态加载一个key的值时,先把该值封装在LoadingValueReference中,以表达该key对应的值已经在加载了,若是其余线程也要查询该key对应的值,就能获得该引用,而且等待改值加载完成,从而保证该值只被加载一次,在该值加载完成后,将LoadingValueReference替换成其余ValueReference类型。ValueReference对象中会保留对ReferenceEntry的引用,这是由于在Value由于WeakReference、SoftReference被回收时,须要使用其key将对应的项从Segment的table中移除。

WriteQueue和AccessQueue :为了实现最近最少使用算法,Guava Cache在Segment中添加了两条链:write链(writeQueue)和access链(accessQueue),这两条链都是一个双向链表,经过ReferenceEntry中的previousInWriteQueue、nextInWriteQueue和previousInAccessQueue、nextInAccessQueue连接而成,可是以Queue的形式表达。WriteQueue和AccessQueue都是自定义了offer、add(直接调用offer)、remove、poll等操做的逻辑,对offer(add)操做,若是是新加的节点,则直接加入到该链的结尾,若是是已存在的节点,则将该节点连接的链尾;对remove操做,直接从该链中移除该节点;对poll操做,将头节点的下一个节点移除,并返回。

了解了cache的总体数据结构后,再来看下针对缓存的相关操做就简单多了:

  • Segment中的evict清除策略操做,是在每一次调用操做的开始和结束时触发清理工做,这样比通常的缓存另起线程监控清理相比,能够减小开销,但若是长时间没有调用方法的话,会致使不能及时的清理释放内存空间的问题。evict主要处理四个Queue:1. keyReferenceQueue;2. valueReferenceQueue;3. writeQueue;4. accessQueue。前两个queue是由于WeakReference、SoftReference被垃圾回收时加入的,清理时只须要遍历整个queue,将对应的项从LocalCache中移除便可,这里keyReferenceQueue存放ReferenceEntry,而valueReferenceQueue存放的是ValueReference,要从Cache中移除须要有key,于是ValueReference须要有对ReferenceEntry的引用,这个前面也提到过了。而对后面两个Queue,只须要检查是否配置了相应的expire时间,而后从头开始查找已经expire的Entry,将它们移除便可。
  • Segment中的put操做:put操做相对比较简单,首先它须要得到锁,而后尝试作一些清理工做,接下来的逻辑相似ConcurrentHashMap中的rehash,查找位置并注入数据。须要说明的是当找到一个已存在的Entry时,须要先判断当前的ValueRefernece中的值事实上已经被回收了,由于它们能够是WeakReference、SoftReference类型,若是已经被回收了,则将新值写入。而且在每次更新时注册当前操做引发的移除事件,指定相应的缘由:COLLECTED、REPLACED等,这些注册的事件在退出的时候统一调用Cache注册的RemovalListener,因为事件处理可能会有很长时间,于是这里将事件处理的逻辑在退出锁之后才作。最后,在更新已存在的Entry结束后都尝试着将那些已经expire的Entry移除。另外put操做中还须要更新writeQueue和accessQueue的语义正确性。
  • Segment带CacheLoader的get操做:1. 先查找table中是否已存在没有被回收、也没有expire的entry,若是找到,并在CacheBuilder中配置了refreshAfterWrite,而且当前时间间隔已经操做这个事件,则从新加载值,不然,直接返回原有的值;2. 若是查找到的ValueReference是LoadingValueReference,则等待该LoadingValueReference加载结束,并返回加载的值;3. 若是没有找到entry,或者找到的entry的值为null,则加锁后,继续在table中查找已存在key对应的entry,若是找到而且对应的entry.isLoading()为true,则表示有另外一个线程正在加载,于是等待那个线程加载完成,若是找到一个非null值,返回该值,不然建立一个LoadingValueReference,并调用loadSync加载相应的值,在加载完成后,将新加载的值更新到table中,即大部分状况下替换原来的LoadingValueReference。

Guava Cache提供Builder模式的CacheBuilder生成器来建立缓存的方式,十分方便,而且各个缓存参数的配置设置,相似于函数式编程的写法,可自行设置各种参数选型。它提供三种方式加载到缓存中。分别是:

  1. 在构建缓存的时候,使用build方法内部调用CacheLoader方法加载数据;
  2. callable 、callback方式加载数据;
  3. 使用粗暴直接的方式,直接Cache.put 加载数据,但自动加载是首选的,由于它能够更容易的推断全部缓存内容的一致性。

build生成器的两种方式都实现了一种逻辑:从缓存中取key的值,若是该值已经缓存过了则返回缓存中的值,若是没有缓存过能够经过某个方法来获取这个值,不一样的地方在于cacheloader的定义比较宽泛,是针对整个cache定义的,能够认为是统一的根据key值load value的方法,而callable的方式较为灵活,容许你在get的时候指定load方法。使用示例以下:

/**
    * CacheLoader
   */
   public void loadingCache()
   {
     LoadingCache<String, String> graphs =CacheBuilder.newBuilder()
        .maximumSize(1000).build(new CacheLoader<String, String>()
        {
            @Override
            public String load(String key) throws Exception
            {
                System.out.println("key:"+key);
                if("key".equals(key)){
                    return "key return result";
                }else{
                    return "get-if-absent-compute";
                }                   
            }
        });
   String resultVal = null;
   try {
       resultVal = graphs.get("key");
       } catch (ExecutionException e) {
         e.printStackTrace();
      }

    System.out.println(resultVal);
   }

   /**
    *
    * Callable
   */
   public void callablex() throws ExecutionException
    {
      Cache<String, String> cache = CacheBuilder.newBuilder()
        .maximumSize(1000).build();
      String result = cache.get("key", new Callable<String>()
       {
         public String call()
         {
          return "result";
         }
       });
     System.out.println(result);
    }

整体来看,Guava Cache基于ConcurrentHashMap的优秀设计借鉴,在高并发场景支持和线程安全上都有相应的改进策略,使用Reference引用命令,提高高并发下的数据……访问速度并保持了GC的可回收,有效节省空间;同时,write链和access链的设计,能更灵活、高效的实现多种类型的缓存清理策略,包括基于容量的清理、基于时间的清理、基于引用的清理等;编程式的build生成器管理,让使用者有更多的自由度,可以根据不一样场景设置合适的模式。

分布式缓存

memcached缓存

memcached是应用较广的开源分布式缓存产品之一,它自己其实不提供分布式解决方案。在服务端,memcached集群环境实际就是一个个memcached服务器的堆积,环境搭建较为简单;cache的分布式主要是在客户端实现,经过客户端的路由处理来达到分布式解决方案的目的。客户端作路由的原理很是简单,应用服务器在每次存取某key的value时,经过某种算法把key映射到某台memcached服务器nodeA上,所以这个key全部操做都在nodeA上,结构图如图六、图7所示。



图6 memcached客户端路由图

图7 memcached一致性hash示例图

memcached客户端采用一致性hash算法做为路由策略,如图7,相对于通常hash(如简单取模)的算法,一致性hash算法除了计算key的hash值外,还会计算每一个server对应的hash值,而后将这些hash值映射到一个有限的值域上(好比0~2^32)。经过寻找hash值大于hash(key)的最小server做为存储该key数据的目标server。若是找不到,则直接把具备最小hash值的server做为目标server。同时,必定程度上,解决了扩容问题,增长或删除单个节点,对于整个集群来讲,不会有大的影响。最近版本,增长了虚拟节点的设计,进一步提高了可用性。

memcached是一个高效的分布式内存cache,了解memcached的内存管理机制,才能更好的掌握memcached,让咱们能够针对咱们数据特色进行调优,让其更好的为我所用。咱们知道memcached仅支持基础的key-value键值对类型数据存储。在memcached内存结构中有两个很是重要的概念:slab和chunk。如图8所示。

图8 memcached内存结构图

slab是一个内存块,它是memcached一次申请内存的最小单位。在启动memcached的时候通常会使用参数-m指定其可用内存,可是并非在启动的那一刻全部的内存就所有分配出去了,只有在须要的时候才会去申请,并且每次申请必定是一个slab。Slab的大小固定为1M(1048576 Byte),一个slab由若干个大小相等的chunk组成。每一个chunk中都保存了一个item结构体、一对key和value。

虽然在同一个slab中chunk的大小相等的,可是在不一样的slab中chunk的大小并不必定相等,在memcached中按照chunk的大小不一样,能够把slab分为不少种类(class),默认状况下memcached把slab分为40类(class1~class40),在class 1中,chunk的大小为80字节,因为一个slab的大小是固定的1048576字节(1M),所以在class1中最多能够有13107个chunk(也就是这个slab能存最多13107个小于80字节的key-value数据)。

memcached内存管理采起预分配、分组管理的方式,分组管理就是咱们上面提到的slab class,按照chunk的大小slab被分为不少种类。内存预分配过程是怎样的呢?向memcached添加一个item时候,memcached首先会根据item的大小,来选择最合适的slab class:例如item的大小为190字节,默认状况下class 4的chunk大小为160字节显然不合适,class 5的chunk大小为200字节,大于190字节,所以该item将放在class 5中(显然这里会有10字节的浪费是不可避免的),计算好所要放入的chunk以后,memcached会去检查该类大小的chunk还有没有空闲的,若是没有,将会申请1M(1个slab)的空间并划分为该种类chunk。例如咱们第一次向memcached中放入一个190字节的item时,memcached会产生一个slab class 2(也叫一个page),并会用去一个chunk,剩余5241个chunk供下次有适合大小item时使用,当咱们用完这全部的5242个chunk以后,下次再有一个在160~200字节之间的item添加进来时,memcached会再次产生一个class 5的slab(这样就存在了2个pages)。

总结来看,memcached内存管理须要注意的几个方面:

  • chunk是在page里面划分的,而page固定为1m,因此chunk最大不能超过1m。
  • chunk实际占用内存要加48B,由于chunk数据结构自己须要占用48B。
  • 若是用户数据大于1m,则memcached会将其切割,放到多个chunk内。
  • 已分配出去的page不能回收。

对于key-value信息,最好不要超过1m的大小;同时信息长度最好相对是比较均衡稳定的,这样可以保障最大限度的使用内存;同时,memcached采用的LRU清理策略,合理甚至过时时间,提升命中率。

无特殊场景下,key-value能知足需求的前提下,使用memcached分布式集群是较好的选择,搭建与操做使用都比较简单;分布式集群在单点故障时,只影响小部分数据异常,目前还能够经过Magent缓存代理模式,作单点备份,提高高可用;整个缓存都是基于内存的,所以响应时间是很快,不须要额外的序列化、反序列化的程序,但同时因为基于内存,数据没有持久化,集群故障重启数据没法恢复。高版本的memcached已经支持CAS模式的原子操做,能够低成本的解决并发控制问题。

Redis缓存

Redis是一个远程内存数据库(非关系型数据库),性能强劲,具备复制特性以及解决问题而生的独一无二的数据模型。它能够存储键值对与5种不一样类型的值之间的映射,能够将存储在内存的键值对数据持久化到硬盘,可使用复制特性来扩展读性能,还可使用客户端分片来扩展写性能。

图9 Redis数据模型图

如图9,Redis内部使用一个redisObject对象来标识全部的key和value数据,redisObject最主要的信息如图所示:type表明一个value对象具体是何种数据类型,encoding是不一样数据类型在Redis内部的存储方式,好比——type=string表明value存储的是一个普通字符串,那么对应的encoding能够是raw或是int,若是是int则表明世界Redis内部是按数值类型存储和表示这个字符串。

图9左边的raw列为对象的编码方式:字符串能够被编码为raw(通常字符串)或Rint(为了节约内存,Redis会将字符串表示的64位有符号整数编码为整数来进行储存);列表能够被编码为ziplist或linkedlist,ziplist是为节约大小较小的列表空间而做的特殊表示;集合能够被编码为intset或者hashtable,intset是只储存数字的小集合的特殊表示;hash表能够编码为zipmap或者hashtable,zipmap是小hash表的特殊表示;有序集合能够被编码为ziplist或者skiplist格式,ziplist用于表示小的有序集合,而skiplist则用于表示任何大小的有序集合。

从网络I/O模型上看,Redis使用单线程的I/O复用模型,本身封装了一个简单的AeEvent事件处理框架,主要实现了epoll、kqueue和select。对于单纯只有I/O操做来讲,单线程能够将速度优点发挥到最大,可是Redis也提供了一些简单的计算功能,好比排序、聚合等,对于这些操做,单线程模型实际会严重影响总体吞吐量,CPU计算过程当中,整个I/O调度都是被阻塞住的,在这些特殊场景的使用中,须要额外的考虑。相较于memcached的预分配内存管理,Redis使用现场申请内存的方式来存储数据,而且不多使用free-list等方式来优化内存分配,会在必定程度上存在内存碎片。Redis跟据存储命令参数,会把带过时时间的数据单独存放在一块儿,并把它们称为临时数据,非临时数据是永远不会被剔除的,即使物理内存不够,致使swap也不会剔除任何非临时数据(但会尝试剔除部分临时数据)。

咱们描述Redis为内存数据库,做为缓存服务,大量使用内存间的数据快速读写,支持高并发大吞吐;而做为数据库,则是指Redis对缓存的持久化支持。Redis因为支持了很是丰富的内存数据库结构类型,如何把这些复杂的内存组织方式持久化到磁盘上?Redis的持久化与传统数据库的方式差别较大,Redis一共支持四种持久化方式,主要使用的两种:

  1. 定时快照方式(snapshot):该持久化方式实际是在Redis内部一个定时器事件,每隔固定时间去检查当前数据发生的改变次数与时间是否知足配置的持久化触发的条件,若是知足则经过操做系统fork调用来建立出一个子进程,这个子进程默认会与父进程共享相同的地址空间,这时就能够经过子进程来遍历整个内存来进行存储操做,而主进程则仍然能够提供服务,当有写入时由操做系统按照内存页(page)为单位来进行copy-on-write保证父子进程之间不会互相影响。它的缺点是快照只是表明一段时间内的内存映像,因此系统重启会丢失上次快照与重启之间全部的数据。
  2. 基于语句追加文件的方式(aof):aof方式实际相似MySQl的基于语句的binlog方式,即每条会使Redis内存数据发生改变的命令都会追加到一个log文件中,也就是说这个log文件就是Redis的持久化数据。

aof的方式的主要缺点是追加log文件可能致使体积过大,当系统重启恢复数据时若是是aof的方式则加载数据会很是慢,几十G的数据可能须要几小时才能加载完,固然这个耗时并非由于磁盘文件读取速度慢,而是因为读取的全部命令都要在内存中执行一遍。另外因为每条命令都要写log,因此使用aof的方式,Redis的读写性能也会有所降低。

Redis的持久化使用了Buffer I/O,所谓Buffer I/O是指Redis对持久化文件的写入和读取操做都会使用物理内存的Page Cache,而大多数数据库系统会使用Direct I/O来绕过这层Page Cache并自行维护一个数据的Cache。而当Redis的持久化文件过大(尤为是快照文件),并对其进行读写时,磁盘文件中的数据都会被加载到物理内存中做为操做系统对该文件的一层Cache,而这层Cache的数据与Redis内存中管理的数据实际是重复存储的。虽然内核在物理内存紧张时会作Page Cache的剔除工做,但内核极可能认为某块Page Cache更重要,而让你的进程开始Swap,这时你的系统就会开始出现不稳定或者崩溃了,所以在持久化配置后,针对内存使用须要实时监控观察。

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



图10 Redis分布式集群图1

图11 Redis分布式集群图2

Redis Cluster是一个实现了分布式且容许单点故障的Redis高级版本,它没有中心节点,具备线性可伸缩的功能。如图11,其中节点与节点之间经过二进制协议进行通讯,节点与客户端之间经过ascii协议进行通讯。在数据的放置策略上,Redis Cluster将整个key的数值域分红4096个hash槽,每一个节点上能够存储一个或多个hash槽,也就是说当前Redis Cluster支持的最大节点数就是4096。Redis Cluster使用的分布式算法也很简单:crc16( key ) % HASH_SLOTS_NUMBER。总体设计可总结为:

  • 数据hash分布在不一样的Redis节点实例上;
  • M/S的切换采用Sentinel;
  • 写:只会写master Instance,从sentinel获取当前的master Instance;
  • 读:从Redis Node中基于权重选取一个Redis Instance读取,失败/超时则轮询其余Instance;Redis自己就很好的支持读写分离,在单进程的I/O场景下,能够有效的避免主库的阻塞风险;
  • 经过RPC服务访问,RPC server端封装了Redis客户端,客户端基于Jedis开发。

能够看到,经过集群+主从结合的设计,Redis在扩展和稳定高可用性能方面都是比较成熟的。可是,在数据一致性问题上,Redis没有提供CAS操做命令来保障高并发场景下的数据一致性问题,不过它却提供了事务的功能,Redis的Transactions提供的并非严格的ACID的事务(好比一串用EXEC提交执行的命令,在执行中服务器宕机,那么会有一部分命令执行了,剩下的没执行)。可是这个Transactions仍是提供了基本的命令打包执行的功能(在服务器不出问题的状况下,能够保证一连串的命令是顺序在一块儿执行的,中间有会有其它客户端命令插进来执行)。Redis还提供了一个Watch功能,你能够对一个key进行Watch,而后再执行Transactions,在这过程当中,若是这个Watched的值进行了修改,那么这个Transactions会发现并拒绝执行。在失效策略上,Redis支持多大6种的数据淘汰策略:

  1. volatile-lru:从已设置过时时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰;
  2. volatile-ttl:从已设置过时时间的数据集(server.db[i].expires)中挑选将要过时的数据淘汰;
  3. volatile-random:从已设置过时时间的数据集(server.db[i].expires)中任意选择数据淘汰 ;
  4. allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰;
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰;
  6. no-enviction(驱逐):禁止驱逐数据。

我的总结了如下多种Web应用场景,在这些场景下能够充分的利用Redis的特性,大大提升效率。

  • 在主页中显示最新的项目列表:Redis使用的是常驻内存的缓存,速度很是快。LPUSH用来插入一个内容ID,做为关键字存储在列表头部。LTRIM用来限制列表中的项目数最多为5000。若是用户须要的检索的数据量超越这个缓存容量,这时才须要把请求发送到数据库。
  • 删除和过滤:若是一篇文章被删除,可使用LREM从缓存中完全清除掉。
  • 排行榜及相关问题:排行榜(leader board)按照得分进行排序。ZADD命令能够直接实现这个功能,而ZREVRANGE命令能够用来按照得分来获取前100名的用户,ZRANK能够用来获取用户排名,很是直接并且操做容易。
  • 按照用户投票和时间排序:排行榜,得分会随着时间变化。LPUSH和LTRIM命令结合运用,把文章添加到一个列表中。一项后台任务用来获取列表,并从新计算列表的排序,ZADD命令用来按照新的顺序填充生成列表。列表能够实现很是快速的检索,即便是负载很重的站点。
  • 过时项目处理:使用Unix时间做为关键字,用来保持列表可以按时间排序。对current_time和time_to_live进行检索,完成查找过时项目的艰巨任务。另外一项后台任务使用ZRANGE…WITHSCORES进行查询,删除过时的条目。
  • 计数:进行各类数据统计的用途是很是普遍的,好比想知道何时封锁一个IP地址。INCRBY命令让这些变得很容易,经过原子递增保持计数;GETSET用来重置计数器;过时属性用来确认一个关键字何时应该删除。
  • 特定时间内的特定项目:这是特定访问者的问题,能够经过给每次页面浏览使用SADD命令来解决。SADD不会将已经存在的成员添加到一个集合。
  • Pub/Sub:在更新中保持用户对数据的映射是系统中的一个广泛任务。Redis的pub/sub功能使用了SUBSCRIBE、UNSUBSCRIBE和PUBLISH命令,让这个变得更加容易。
  • 队列:在当前的编程中队列随处可见。除了push和pop类型的命令以外,Redis还有阻塞队列的命令,可以让一个程序在执行时被另外一个程序添加到队列。

缓存实战

实际工程中,对于缓存的应用能够有多种的实战方式,包括侵入式硬编码,抽象服务化应用,以及轻量的注解式使用等。本文将主要介绍下注解式方式。

Spring注解缓存

Spring 3.1以后,引入了注解缓存技术,其本质上不是一个具体的缓存实现方案,而是一个对缓存使用的抽象,经过在既有代码中添加少许自定义的各类annotation,即可以达到使用缓存对象和缓存方法的返回对象的效果。Spring的缓存技术具有至关的灵活性,不只可以使用SpEL(Spring Expression Language)来定义缓存的key和各类condition,还提供开箱即用的缓存临时存储方案,也支持和主流的专业缓存集成。其特色总结以下:

  • 少许的配置annotation注释便可使得既有代码支持缓存;
  • 支持开箱即用,不用安装和部署额外的第三方组件便可使用缓存;
  • 支持Spring Express Language(SpEL),能使用对象的任何属性或者方法来定义缓存的key和使用规则条件;
  • 支持自定义key和自定义缓存管理者,具备至关的灵活性和可扩展性。

和Spring的事务管理相似,Spring Cache的关键原理就是Spring AOP,经过Spring AOP实现了在方法调用前、调用后获取方法的入参和返回值,进而实现了缓存的逻辑。而Spring Cache利用了Spring AOP的动态代理技术,即当客户端尝试调用pojo的foo()方法的时候,给它的不是pojo自身的引用,而是一个动态生成的代理类。

图12 Spring动态代理调用图

如图12所示,实际客户端获取的是一个代理的引用,在调用foo()方法的时候,会首先调用proxy的foo()方法,这个时候proxy能够总体控制实际的pojo.foo()方法的入参和返回值,好比缓存结果,好比直接略过执行实际的foo()方法等,都是能够轻松作到的。Spring Cache主要使用三个注释标签,即@Cacheable、@CachePut和@CacheEvict,主要针对方法上注解使用,部分场景也能够直接类上注解使用,当在类上使用时,该类全部方法都将受影响。咱们总结一下其做用和配置方法,如表1所示。

表1

标签类型 做用 主要配置参数说明
@Cacheable 主要针对方法配置,可以根据方法的请求参数对其结果进行缓存 value:缓存的名称,在 Spring 配置文件中定义,必须指定至少一个; key:缓存的 key,能够为空,若是指定要按照 SpEL 表达式编写,若是不指定,则默认按照方法的全部参数进行组合; condition:缓存的条件,能够为空,使用 SpEL 编写,返回 true 或者 false,只有为 true 才进行缓存
@CachePut 主要针对方法配置,可以根据方法的请求参数对其结果进行缓存,和 @Cacheable 不一样的是,它每次都会触发真实方法的调用 value:缓存的名称,在 spring 配置文件中定义,必须指定至少一个; key:缓存的 key,能够为空,若是指定要按照 SpEL 表达式编写,若是不指定,则默认按照方法的全部参数进行组合; condition:缓存的条件,能够为空,使用 SpEL 编写,返回 true 或者 false,只有为 true 才进行缓存
@CacheEvict 主要针对方法配置,可以根据必定的条件对缓存进行清空 value:缓存的名称,在 Spring 配置文件中定义,必须指定至少一个; key:缓存的 key,能够为空,若是指定要按照 SpEL 表达式编写,若是不指定,则默认按照方法的全部参数进行组合; condition:缓存的条件,能够为空,使用 SpEL 编写,返回 true 或者 false,只有为 true 才进行缓存; allEntries:是否清空全部缓存内容,默认为 false,若是指定为 true,则方法调用后将当即清空全部缓存; beforeInvocation:是否在方法执行前就清空,默认为 false,若是指定为 true,则在方法尚未执行的时候就清空缓存,默认状况下,若是方法执行抛出异常,则不会清空缓存

可扩展支持:Spring注解cache可以知足通常应用对缓存的需求,但随着应用服务的复杂化,大并发高可用性能要求下,须要进行必定的扩展,这时对其自身集成的缓存方案可能不太适用,该怎么办?Spring预先有考虑到这点,那么怎样利用Spring提供的扩展点实现咱们本身的缓存,且在不改变原来已有代码的状况下进行扩展?是否在方法执行前就清空,默认为false,若是指定为true,则在方法尚未执行的时候就清空缓存,默认状况下,若是方法执行抛出异常,则不会清空缓存。

这基本可以知足通常应用对缓存的需求,但现实老是很复杂,当你的用户量上去或者性能跟不上,总须要进行扩展,这个时候你或许对其提供的内存缓存不满意了,由于其不支持高可用性,也不具有持久化数据能力,这个时候,你就须要自定义你的缓存方案了,还好,Spring也想到了这一点。

咱们先不考虑如何持久化缓存,毕竟这种第三方的实现方案不少,咱们要考虑的是,怎么利用Spring提供的扩展点实现咱们本身的缓存,且在不改原来已有代码的状况下进行扩展。这须要简单的三步骤,首先须要提供一个CacheManager接口的实现(继承至AbstractCacheManager),管理自身的cache实例;其次,实现本身的cache实例MyCache(继承至Cache),在这里面引入咱们须要的第三方cache或自定义cache;最后就是对配置项进行声明,将MyCache实例注入CacheManager进行统一管理。

酒店商家端自定义注解缓存

注解缓存的使用,能够有效加强应用代码的可读性,同时统一管理缓存,提供较好的可扩展性,为此,酒店商家端在Spring注解缓存基础上,自定义了适合自身业务特性的注解缓存。

主要使用两个标签,即@HotelCacheable、@HotelCacheEvict,其做用和配置方法见表2。

表2

标签类型 做用 主要配置参数说明
@HotelCacheable 主要针对方法配置,可以根据方法的请求参数对其结果进行缓存 domain:做用域,针对集合场景,解决批量更新问题; domainKey:做用域对应的缓存key; key:缓存对象key 前缀; fieldKey:缓存对象key,与前缀合并生成对象key; condition:缓存获取前置条件,支持spel语法; cacheCondition:缓存刷入前置条件,支持spel语法; expireTime:超时时间设置
@HotelCacheEvict 主要针对方法配置,可以根据必定的条件对缓存进行清空 同上

增长做用域的概念,解决商家信息变动下,多重重要信息实时更新的问题。

图13 域缓存处理图

如图13,按旧的方案,当cache0发送变化时,为了保持信息的实时更新,须要手动删除cache一、cache二、cache3等相关处的缓存数据。增长域缓存概念,cache0、cache一、cache二、cache3是以帐号ID为基础,相互存在影响约束的集合体,咱们做为一个域集合,增长域缓存处理,当cache0发送变化时,总体的帐号ID domain域已发生更新,自动影响cache一、cache二、cache3等处的缓存数据。将相关联逻辑缓存统一化,有效提高代码可读性,同时更好服务业务,帐号重点信息可以实时变动刷新,相关服务响应速度提高。

另外,增长了cacheCondition缓存刷入前置判断,有效解决商家业务多重外部依赖场景下,业务降级有损服务下,业务数据一致性保证,不由于缓存的增长影响业务的准确性;自定义CacheManager缓存管理器,能够有效兼容公共基础组件Medis、Cellar相关服务,在对应用程序不作改动的状况下,有效切换缓存方式;同时,统一的缓存服务AOP入口,结合接入Mtconfig统一配置管理,对应用内缓存作好降级准备,一键关闭缓存。几点建议:

  • 上面介绍过Spring Cache的原理是基于动态生成的proxy代理机制来进行切面处理,关键点是对象的引用问题,若是对象的方法是类里面的内部调用(this引用)而不是外部引用的场景下,会致使proxy失败,那么咱们所作的缓存切面处理也就失效了。所以,应避免已注解缓存的方法在类里面的内部调用。
  • 使用的key约束,缓存的key应尽可能使用简单的可区别的元素,如ID、名称等,不能使用list等容器的值,或者使用总体model对象的值。非public方法没法使用注解缓存实现。

总之,注释驱动的Spring Cache可以极大的减小咱们编写常见缓存的代码量,经过少许的注释标签和配置文件,便可达到使代码具有缓存的能力,且具有很好的灵活性和扩展性。可是咱们也应该看到,Spring Cache因为基于Spring AOP技术,尤为是动态的proxy技术,致使其不能很好的支持方法的内部调用或者非public方法的缓存设置,固然这些都是能够解决的问题。

相关文章
相关标签/搜索