缓存那些事儿之【本地缓存篇】

文章摘要:代码调优,SQL调优,DB服务器扩容该做的都做了,接下来该如何优化呢?

一般来说,一个业务平台系统的整体流程可以基本概括为如下图所示,用户请求从UI(浏览器或者客户端)到网络转发,经过应用服务的业务逻辑处理,再到存储(文件系统或数据库),然后经过渲染将UI呈现给用户。

随着业务逻辑越来越复杂,用户数和访问量的激增,我们的系统需要支撑更多的并发量,同时对应用服务器和数据库服务器的计算能力和IO读写能力要求也越来越高。然而,我们的服务器硬件资源总是有限,无法根据业务的发展不断进行扩容和升级。经过SQL优化、数据库的读写分离、分区表的分库分表等方式的优化改造后,数据库的IO读写能力已经趋于达到一定的瓶颈。那么,如何在已有的硬件条件下更进一步的提高系统可支撑的并发量呢?一个比较有效的办法就是引入缓存技术,将原来直接访问数据库的高并发量进行缓冲,从缓存中获取目标数据后返回,降低数据库的读写IO,有效提升系统响应能力。

一、缓存的几个关键要素

1、缓存命中率

缓存的命中率=返回正确结果数/总共请求缓存次数,命中率问题是缓存技术中的一个非常重要的问题,它是衡量缓存有效性的重要指标。命中率越高,表明缓存的使用率越高。

2、缓存容量空间

缓存的容量主要指的是,可以存放最大元素的数量。一旦缓存中元素数量超过这个值,那么将会触发缓存启动清理策略。其中,根据不同的业务场景,合理地设置缓存容量往往可以一定程度上提高缓存的命中率。

3、缓存清空策略

一般的缓存清空策略主要有以下几种:

(a)FIFO(first in firstout)

前进先出策略,最先进入缓存的数据在缓存容量空间不够的情况下会被优先清理,以释放空间加载新的数据。该策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。

(b)LFU(less frequently used)

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

(c)LRU(leastrecently used)

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

二、缓存分类与应用场景

1.本地缓存

主要指的是在业务应用工程中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,获取缓存中的数据非常高效,基本没有网络开销,在单体业务应用中不需要集群支持或者集群场景下各节点无需互相通知或者共享缓存内容的场景下使用本地缓存较合适,比较适用于缓存业务应用中不常变化的局数据;同时,它的缺点也是由于本地缓存与业务应用服务耦合过于紧密,多个应用程序无法直接共享缓存内容,在大规模集群系统中的各节点都需要维护自己单独的缓存,对每台服务器内存来说是一种浪费;此外,本地缓存一般不做持久化,遇到服务器宕机、重启、进程Crash等异常情况无法及时同步到磁盘上,写入缓存的中内容比较容易丢失。

2.分布式缓存

通常指的是与业务应用相分离且部署在集群服务器上的缓存服务,业界用的比较多的通常是redis,memcached组件。该类缓存最大的优势在于自身就是一个独立的应用服务,除了可以具备普通缓存的特性以外,还与业务应用相互隔离,多个应用服务可直接共享缓存内容。从系统服务架构、部署方式的角度来考虑,引入分布式缓存组件也增加了一定的复杂度,这也是前期做系统架构设计、规划时候需要考虑的一个问题。

利用缓存技术是提高各类业务系统吞吐量和并发量的重要手段之一。我们需要结合不同的业务场景、带来的复杂度以及系统建设成本等因素进行综合考虑,选择最适合的缓存方案以达到最优的目的。

二、本地缓存的几种设计应用方案

上面尽说的都是一些本地缓存和分布式缓存的概念,可能内容相对干涩。下面将直接利用Java代码、配置文件、数据结构图、流程图等方式,分别从自定义构建本地缓存、Ehcache、Google Guava Cache这常用的三种本地缓存设计构建的设计方案出发,让大家对本地缓存有一个更为系统性的深刻理解。

1.编程自定义构建本地缓存

对于自定义本地缓存的构建而言,基本的流程可以概括为,在系统启动后,【构建本地缓存】—>【定时任务触发/其他事件触发动态刷新本地缓存】—>【用本地缓存获取目标数据】—>【未命中/命中的缓存逻辑处理】—>【执行后续流程逻辑处理】—【清理缓存内容】,直至系统终止,具体的流程图可以参考如下:

在个别业务场景下,我们一般只需要利用JDK自带java.util包下的HashMap或者ConcurrentHashMap数据结构即可实现一个非常轻量级的本地缓存来保存一些程序或任务经常需要访问获取的局数据,例如资源产品规格、产品计费、产品模板、资源配额等数据,而无需关注更多存取、清空策略、内存优化等深入的特性。这里需要说明的是,直接编程实现自定义缓存则是最便捷和高效的。

(1)类局部变量实现本地缓存

public class ProductInfoLocalCache {

        private final Logger logger = LoggerFactory.getLogger(this.getClass());

        //key值为产品id,value值为产品信息对象

        private Map productInfoCacheMap = newConcurrentHashMap<>();

        @Autowired

        private ProductInfoDao productInfoDao;

        /**

        *构建本地缓存

        */

        public void buildLocalCacheMap() {

                if (productInfoCacheMap.isEmpty()) {

                logger.info("开始加载产品信息数据到本地缓存中");

                List productInfoList =productInfoDao.getAllProductInfos();

                for (ProductInfo productInfo : productInfoList) {

                    productInfoCacheMap.put(productInfo.getId(), productInfo);

                }

        }

}

/**

*根据产品id,从缓存中获取产品信息

*@param productId

*@return

*/

public ProductInfo getProductInfoFromLocalCache(String productId) {

            ProductInfo objectCache = productInfoCacheMap.get(productId);

            if (objectCache != null) {

                //命中则直接从本地缓存中获取

                return objectCache;

        }else{

        //未命中则从DB中获取

        ProductInfo objectDB = productInfoDao.getProductInfoById(productId);

        return objectDB;

        }

        return null;

}

/**

*清空本地缓存

*/

public void cleanLocalCacheMap() {

        logger.info("开始清空缓存中的数据");

        productInfoCacheMap.clear();

    }

}

以成员变量map结构缓存部分业务数据,减少频繁地对数据库进行读操作。缺点仅限于类的自身作用域内,类间无法共享缓存。

(2)静态变量实现

public class ProductInfoLocalCache{

        private final Logger logger = LoggerFactory.getLogger(this.getClass());

        //spring上下文

        private static ApplicationContext ac;

        private static ProductInfoDao productInfoDao;

        //本地缓存resTemplateId2ProductInfoMap,key为资源模板id,value为ProductInfo对象

        privatestatic Map resTemplateId2ProductInfoMap = new HashMap();

        static {

            ac = newClassPathXmlApplicationContext("classpath:spring/applicationContext.xml");

            productInfoDao= (ProductInfoDao)ac.getBean("ProductInfoDao");

            try {

                ListproductInfoList = productInfoDao.getAllProductInfos();

                for(ProductInfo productInfo : productInfoList) {

                        resTemplateId2ProductInfoMap.put(productInfo.getResTemplateId(),productInfo);

                }

            }catch (Exception e) {

            throw new RuntimeException("Init ProductInfoLocalCacheError!", e);

            }

}

/**

*根据产品id,从缓存中获取产品信息

* @param productId

* @return

*/

public static ProductInfogetProductInfoFromLocalCache(String resourceTempId) {

            ProductInfo objectCache =resTemplateId2ProductInfoMap.get(resourceTempId);

            if (objectCache != null) {

                //命中则直接从本地缓存中获取

                return objectCache;

            }else{

                //未命中则从DB中获取

                ProductInfo objectDB =productInfoDao.getProductInfoById(resourceTempId);

                return objectDB;

            }

            return null;

    }

}

如上面的代码所示,通过静态变量一次获取缓存内存中,减少频繁从DB读取,静态变量实现类间可共享,进程内可共享,但是本地缓存的实时性稍差,基于该特点本地缓存中存放的内容是不经常变动的局数据,诸如产品信息、产品规格、产品计费等。一般来说,通过将Quatrz或者Timer等定时任务的缓存刷新时间频率作为配置项设置在DB中,通过调节配置项来提高本地缓存的更新频率。另外,为了提高本地缓存数据实时性的问题,也可以结合Zookeeper的自动发现机制,实时变更本地缓冲变量信息内容。

2.Ehcache

Ehcache是现在最流行的纯Java开源缓存框架,配置简单、结构清晰、功能强大,是一个非常轻量级的缓存实现,常用的ORM框架—Hibernate中就集成了相关缓存功能。下图为Ehcache的框架结构图:

(1)Ehcache的特点:

a.快速轻量:Ehcache的jar包的大小才几百kb,方便集成至各种服务工程中,且其API使用方便集成、部署起来的成本比较小。

b.伸缩性:缓存在内存和磁盘存储可以伸缩到GB的数量级,Ehcache为大容量数据存储做过优化。大内存的情况下,所有进程可以支持数百GB的吞吐。为高并发和多核CPU服务器做优化。Ehcache的线程机制设计采用了Doug Lea的想法来获得较高的性能。

c.灵活性:支持基于Cache和基于Element的过期策略,每个Cache的存活时间都是可以设置和控制的。提供了LRU、LFU和FIFO缓存淘汰算法,Ehcache 1.2引入了最少使用和先进先出缓存淘汰算法,构成了完整的缓存淘汰算法。提供内存和磁盘存储,Ehcache和大多数缓存解决方案一样,提供高性能的内存和磁盘存储。动态、运行时缓存配置,存活时间、空闲时间、内存和磁盘存放缓存的最大数目都是可以在运行时修改的。

d.可扩展性:缓存监听器可以插件化。Ehcache 1.2提供了CacheManagerEventListener和CacheEventListener接口,实现可以插件化,并且可以在ehcache.xml里配置。

e.数据持久化:在VM重启后,持久化到磁盘的存储可以复原数据。ehcache是第一个引入缓存数据持久化存储的开源Java缓存框架。缓存的数据可以在机器重启后从磁盘上重新获得。根据需要将缓存刷到磁盘。将缓存条目刷到磁盘的操作可以通过cache.flush()方法来执行,这大大方便了Ehcache的使用。

f.支持JMX:Ehcache的JMX功能是默认开启的,你可以监控和管理如下的MBean:CacheManager、Cache、CacheConfiguration、CacheStatistics。

g.分布式缓存特性:从Ehcache 1.2开始,支持高性能的分布式缓存,兼具灵活性和扩展性。

(2)Ehcache的核心定义:

a.cache

manager:缓存管理器,以前是只允许单例的,不过现在也可以多实例了。

b.cache:缓存管理器内可以放置若干cache,存放数据的实质,所有cache都实现了Ehcache接口。

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

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

从上述Ehcache的特性中可以看到,整个Ehcache提供了对JSR、JMX等的标准支持,能够非常方便地进行集成、移植和扩展,同时对各类对象有较完善的监控管理机制。它的缓存介质涵盖堆内存、堆外内存(也称之为BigMemeory)和磁盘。Ehcache最初是为独立的本地缓存框架组件,在后期的发展中,结合Terracotta服务阵列模型,可以支持分布式缓存集群,主要有RMI、JGroups、JMS和Cache

Server等传播方式进行节点间通信。由于本文侧重于对本地缓存重点进行介绍,Ehcache的分布式缓存技术方案在后续篇幅会进行介绍。

(3)Ehcache的在Spring工程中的应用示例

Step1.添加Ehcache的配置文件:

其中,maxElementsInMemory:内存中最大缓存对象数;eternal:true表示对象永不过期,此时会忽略timeToIdleSeconds和timeToLiveSeconds属性,默认为false;overflowToDisk:true表示当内存缓存的对象数目达到了maxElementsInMemory界限后,会把溢出的对象写到硬盘缓存中;timeToIdleSeconds:设定允许对象处于空闲状态的最长时间,以秒为单位。当对象自从最近一次被访问后,如果处于空闲状态的时间超过了timeToIdleSeconds属性值,这个对象就会过期,Ehcache将把它从缓存中清空。只有当eternal属性为false,该属性才有效。如果该属性值为0,则表示对象可以无限期地处于空闲状态;timeToLiveSeconds:设定对象允许存在于缓存中的最长时间,以秒为单位。当对象自从被存放到缓存中后,如果处于缓存中的时间超过了timeToLiveSeconds属性值,这个对象就会过期,Ehcache将把它从缓存中清除。只有当eternal属性为false,该属性才有效。如果该属性值为0,则表示对象可以无限期地存在于缓存中,timeToLiveSeconds必须大于timeToIdleSeconds属性,才有意义。

Step2.在Spring上下文application.xml中添加的配置如下:

Step3.通过在所需缓存管理器方法上添加如下的注解,@Cacheable(value="cacheTest",key="#param"),即可使用上面配置文件中声明的cacheTest缓存。

因此整体上看,Ehcache的集成和使用还是相对比较简单便捷的,提供了完整的各类API接口。需要注意的是,虽然Ehcache支持磁盘的持久化,但是由于存在L1/L2两级缓存介质,在L1的一级缓存中,如果没有主动的刷入磁盘持久化的话,在应用服务器异常down机的情况下,依然会出现缓存数据丢失,为此可以根据需要将缓存刷到磁盘,将缓存条目刷到磁盘的操作可以通过cache.flush()方法来执行。

不过,需要注意的是,Ehcache的超时设置主要是针对整个cache实例设置整体的超时策略,而没有较好的处理针对单独的key做个性的超时设置。因此,在使用中要注意过期失效的缓存元素无法被GC回收,时间越长缓存越多,内存占用也就越大,内存泄露的概率也越大。

3.Google Guava Cache

说到Google Guava工具包,用过Java语言进行应用开发的同学一定都对它比较了解。Google

Guava主要包括了collections、caching、primitives support、concurrency libraries、common annotations、string processing、I/O等等。这些高质量的API可以让开发者的Java代码更加简洁、高效和优雅。

(1)Guava Cache的主要特点

在本篇幅,将主要为读者讲解下Google Guava工具包中的缓存Cache以及如何利用它集成并构建业务应用服务的本地缓存实现。说到Guava

Cache,其主要的特点是:

a.自动将entry节点加载进缓存结构中;

b.当缓存的数据超过设置的最大值时,使用LRU算法移除;

c.具备根据entry节点上次被访问或者写入时间计算它的过期机制;

d.缓存的key被封装在WeakReference引用内;

e.缓存的Value被封装在WeakReference或SoftReference引用内;

f.统计缓存使用过程中命中率、异常率、未命中率等统计数据;

(2)Guava Cache的数据内存模型

在本文前面的章节—“编程自定义构建本地缓存”中,对ConcurrentHashMap构建本地缓存做过简要介绍。这里,Guava

Cache秉承了ConcurrentHashMap的设计思路与理念,使用多个segments方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。Cache类似于Map,它是存储键值对的集合,不同点在于它还需要处理evict、expire、dynamic load等逻辑,需要一些额外信息来实现这些操作。为此,需要将方法与数据的关联封装。如图下图所示cache的内存数据模型,可以看到使用ReferenceEntry接口来封装一个键值对,而用ValueReference来封装Value值,之所以用Reference命令,是因为Cache要支持WeakReference

Key和SoftReference、WeakReference value。(对于WeakReference和SoftReference不是太熟悉的同学可以自己查阅下相关Java资料)

ReferenceEntry:是对一个键值对节点的抽象,包含了key和值的ValueReference抽象,Guava Cache由多个Segment组成,而每个Segment包含一个ReferenceEntry数组,每个ReferenceEntry数组项都是一条ReferenceEntry链。如上面的Cache内存结构图所示,除了在ReferenceEntry数组项中组成的链,在一个Segment中,所有ReferenceEntry还组成access链(accessQueue)和write链(writeQueue)。ReferenceEntry可以是强引用类型的key,也可以弱类型引用类型的key。为了优化系统的内存使用量,还可以根据配置expireAfterWrite、expireAfterAccess、maximumSize等参数来决定是否需要writeQueue和accessQueue队列。

ValueReference:因为Guava Cache支持强引用的Value、SoftReference Value以及WeakReference Value,且其对应着的三个实现类分别为:StrongValueReference、SoftValueReference、WeakValueReference。为了支持动态加载机制,还有一个LoadingValueReference类,在需要动态加载一个key的值时,先把该值封装在LoadingValueReference中,以表达该key对应的值已经在加载了。如果其他线程也要查询该key对应的值,就能得到该引用,并且等待改值加载完成,从而保证该值只被加载一次,在该值加载完成后,将LoadingValueReference替换成其他ValueReference类型。

WriteQueue/AccessQueue队列:为了实现LRU算法,Guava Cache在Segment中添加了两条链:write链(writeQueue)和access链(accessQueue),这两条链都是一个双向链表,通过ReferenceEntry中的previousInWriteQueue、nextInWriteQueue和previousInAccessQueue、nextInAccessQueue链接而成且以队列的数据结构形式表达。WriteQueue和AccessQueue中提供了offer、add、remove、poll等方法。

(3)GuavaCache的使用方法

Guava Cache提供Builder模式的CacheBuilder构建器来创建本地缓存的方式,十分方便高效,同时可以利用其提供的各种缓存参数根据不同的业务应用场景进行灵活配置,类似于函数式编程的写法。它提供三种方式加载到缓存中。分别是:

a.在构建缓存的时候,使用build方法内部调用CacheLoader方法加载数据;

b.Callable、Callback方式加载数据;

c.使用简单直接的方式,通过Cache.put加载数据。但自动加载是肯定首选,因为它可以更方便的推断所有缓存内容的一致性;

CacheBuilder构建器的两种方式都大致实现了统一的一种缓存逻辑:从缓存中取key的值,如果该值已经缓存过了则返回缓存中的值。如果没有命中则可以通过某个方法来获取这个值,不同的地方在于Cacheloader的定义比较宽泛,是针对整个cache定义的,可以认为是统一的根据key值load value的方法。而Callable的方式较为灵活,允许你在get的时候指定load方法。如下示例给出的是Cacheloader的应用代码如下:

//Cacheloader方式

public class OrderLocalCacheV1 extends AbstractLocalCache{

/**

*构造函数用于初始化localCache

*/

publicOrderLocalCacheV1() {

        //在构造函数中初始该块localCache配置项和命中加载策略

        localCache= CacheBuilder.newBuilder().refreshAfterWrite(10, TimeUnit.MINUTES)//设置给定时间内没有被读/写访问的时间,则回收。

        .expireAfterWrite(10,TimeUnit.HOURS)//设置缓存过期时间

        .maximumSize(10000).//设置缓存个数

        build(

            newCacheLoader() {

            @Override

            /**当本地缓存命没有中时,调用load方法获取结果并将结果缓存**/

            publicOrderRecord load(String keyStr) {

            //实例代码,Dao类从DB中获取对象

            OrderRecordobj = dao.queryFromDb(keyStr);

            //这里为了防止因为未命中而查询DB返回null值而guava抛出异常,因此没有查询到直接返回一个new对象

            if(obj!= null){

                    returnobj;

                }else

            returnnew OrderRecord();

        }});

}

@Override

publicOrderRecord getValueFromCache(String[] args) {

            //根据具体的业务组成Cache的key值

            StringkeyStr = args[0] + Constant.LOCAL_CACHE_MAP_SPLIT_TAG +

                                args[1]+ Constant.LOCAL_CACHE_MAP_SPLIT_TAG +

                                args[2]+ Constant.LOCAL_CACHE_MAP_SPLIT_TAG +

                                args[3];

            OrderRecordretObj = null;

            try {

            retObj= localCache.get(keyStr);

            }catch(Exception ex){

            log.error("从本地缓存[OrderLocalCacheV1]中获取value时候出现异常:",ex);

        }

        returnretObj;

    }

}

由以上可以看出,Guava Cache基于ConcurrentHashMap的设计理念,在高并发场景支持和线程安全上都有相应的改进策略,使用Reference引用,提升高并发下的数据访问速度并保持了JVM GC的高效回收,有效节约内存空间的使用;其中,write/access链队列的数据结构设计,能更灵活地实现多种类型的缓存清理策略,包括基于容量的清理、基于时间的清理、基于引用的清理等;编程式的build构建器管理,让使用者拥有更多的自由度,能够根据不同业务场景设置合适的模式。

本文从应用缓存的原因出发,概括地介绍了本地缓存和分布式缓存的区别和不同点,详细介绍了利用本地缓存构建大型分布式系统的几种不同技术方案细节,主要包括自定义构建本地缓存、Encache缓存框架、Guava Cache工具包。后续将主要介绍分布式缓存redis的深度应用技术。限于笔者的才疏学浅,可能对几种方案的理解不够深入,如有阐述不合理之处还望留言一起探讨。�

排版更好的可以见:个人微信版网页版

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏杨建荣的学习笔记

Python多线程并发的简单测试

之前也写了一些简单的Python程序,对于多线程的并发一直没有涉及,今天决定先突破一下,把这个部分的内容先快速的掌握,然后在这个基础上细化改进。 我的...

40111
来自专栏LeoXu的博客

[翻译]使用 Velocity 构建一个稳定安全的Web应用

<p> draft document -- 2003年6月11日 </p> <p> 作为一名web开发者,任何时候当你构建一个Web应用时,有责任确保你的应用程...

1032
来自专栏Python中文社区

那些年在win下填过的Django坑

專 欄 ❈ JacobYRJ,Python中文社区专栏作者 Python语言爱好者,目前在做Django项目。 Github博客:https://JacobY...

1857
来自专栏FreeBuf

网卡厂商自动识别工具(附源代码)

在渗透测试过程中,为了推测局域网内主机品牌,一个简单易行的办法就是通过ARP协议获取主机MAC地址列表,再通过互联网手动查询其所属厂商。笔者分享的工具实现了在A...

2316
来自专栏Albert陈凯

3.4 Spark通信机制

3.4 Spark通信机制 前面介绍过,Spark的部署模式可以分为local、standalone、Mesos、YARN等。 本节以Spark部署在stan...

4115
来自专栏韩伟的专栏

浅析“远程对象调用”

远程对象调用,是一种业界成熟的分布式服务器系统模型。这套模型提供了强大的分布式程序架构能力,并且能方便的置入统一的运维特性能力:容灾、扩容、负载均衡。

7390
来自专栏Albert陈凯

3.4 Spark通信机制

3.4 Spark通信机制 前面介绍过,Spark的部署模式可以分为local、standalone、Mesos、YARN等。 本节以Spark部署在stan...

3585
来自专栏aoho求索

snowflake升级版全局id生成

1. 背景 分布式系统或者微服务架构基本都采用了分库分表的设计,全局唯一id生成的需求变得很迫切。 传统的单体应用,使用单库,数据库中自增id可以很方便实现。分...

54011
来自专栏九彩拼盘的叨叨叨

node.js 第三方模块

3523
来自专栏我的安全视界观

【一起玩蛇】Python代码审计中的那些器I

27412

扫码关注云+社区

领取腾讯云代金券