作者简介:
熊明辉,美团点评酒旅事业群酒店住宿研发团队B端商家业务平台负责人,主导构建商家业务平台系统,支撑美团点评酒店住宿业务的飞速发展需求。曾任职于联想集团、百度。
导语:在网络分层应用服务中,缓存的使用已比较普及,本文将结合作者实际工作经验总结,讲述在不同的场景下如何选择和使用适用的缓存框架,以达到提升服务质量,优化系统架构的目的。
一般而言,现在互联网模式(一个网站或一个应用),整体流程可以概括描述为 浏览器→应用服务器→数据库或文件(存储)→应用服务器→浏览器,这是一个标准流程,通过浏览器(或App界面)发起请求,经过服务器、数据库计算整合后反馈浏览器呈现内容。随着互联网的普及,内容信息越来越复杂,使用者和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器所做的计算也越来越多。但是往往我们的应用服务器资源是有限的,且技术变革是缓慢的,数据库每秒能接受的请求次数也是有限的(或者文件的读写也是有限的),如何能够有效利用有限的资源来提供尽可能大的吞吐量?一个有效的办法就是减少计算量,缩短请求流程——这就是缓存。缓存的出现就是打破上述的标准流程,其中的任何一个环节都可以被截断,请求可以从缓存中直接获取目标数据并返回。通过这种打破常规的方式,有效减少计算量,缩短请求流程,有效提升响应速度,节省硬件资源,让有限的资源服务更多的用户。
如图1所示,缓存的使用可以出现在 1-4的各个环节中,每个环节的缓存方案与使用各有特点。
图1 网络应用一般流程
缓存特征
根据面向对象的软件思维来看,缓存就是一个对象类型,那么必然有它的属性:
命中率
命中率=返回正确结果数/请求缓存次数,命中率问题是缓存中的一个非常重要的问题,它是衡量缓存有效性的重要指标。命中率越高,表明缓存的使用率越高。
最大元素(或最大空间)
缓存中可以存放的最大元素的数量,一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,从而更有效的时候缓存。
清空策略
如上描述,缓存的存储空间有限制,当缓存空间被用满时,如何保证在稳定服务的同时有效提升命中率?这就由缓存清空策略来处理,设计适合自身数据特征的情况策略能有效提升命中率。常见的一般策略有:
a. FIFO(first in first out)
先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。
b. LFU(less frequently used)
最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。
c. LRU(least recently used)
最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。
除此之外,还有一些简单策略比如:
根据过期时间判断,清理过期时间最长的元素;
根据过期时间判断,清理最近要过期的元素;
随机清理;
根据关键字(或元素内容)长短清理等。
缓存介质
(从硬件介质上来看,无非就是内存和硬盘两种)从技术上划分,可以分成几种,内存、硬盘文件、数据库。
-
内存:将缓存存储于内存中是最快的选择,无需额外的I/O开销,但是内存的缺点是没有持久化落地物理磁盘,一旦应用异常break down,重新启动数据很难或者无法复原。
-
硬盘:一般来说,很多缓存框架会结合使用内存和硬盘,在内存分配空间满了或是在异常的情况下,可以被动或主动的将内存空间数据持久化到硬盘中,达到释放空间或备份数据的目的。
-
数据库:前面我们有提到,增加缓存的策略的目的之一就是为了减少数据库的I/O压力。现在使用数据库做缓存介质是不是又回到了老问题上了?其实,数据库也有很多种类型,像那些不支持SQL,只是简单的key、value的存储结构的特殊数据库(如berkleydb),响应速度和吞吐量都远远高于我们常用的关系型数据库等。
在目前的应用服务框架中,我们对缓存的分类划分更常用的是根据缓存与应用的耦合程度,划分为local cache(本地缓存)和remote cache(分布式缓存):
-
Local cache:指的是在应用中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适;同时,它的缺点也是应为缓存跟应用程序耦合,多个应用程序无法直接的共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。
-
Remote cache::指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存。
目前各种类型的缓存都活跃在成千上万的应用服务中,还没有一种缓存方案可以解决一切的业务场景或数据类型,我们需要根据自身的特殊场景和背景,选择最适合的缓存方案。缓存的使用是程序员、架构师的必备技能,好的程序员能根据数据类型、业务场景来准确判断使用何种类型的缓存,如何使用这种缓存,以最小的成本最快的效率达到最优的目的。
本地缓存
编程式缓存实现
a. 成员变量或局部变量实现
简单代码示例如图2所示。
图2 简单代码示例图
以局部变量map结构缓存部分业务数据,减少频繁的重复数据库I/O操作。缺点仅限于类的自身作用域内,类间无法共享缓存。
b. 静态变量实现
最常用的单例实现静态资源缓存,代码示例如下:
public class CityUtils {
private static final HttpClient httpClient = ServerHolder.createClientWithPool();
private static Mapstring=""> cityIdNameMap = new HashMapstring="">();
private static Mapstring=""> districtIdNameMap = new HashMapstring="">();
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;
}
}
业务中常用的城市基础基本信息判断,通过静态变量一次获取缓存内存中,减少频繁的I/O读取,静态变量实现类间可共享,进程内可共享,缓存的实时性稍差。
为了解决本地缓存数据的实时性问题,目前大量使用的是结合ZooKeeper的自动发现机制,实时变更本地静态变量缓存:
MtConfig基础组件,采用的就是类似原理,使用静态变量缓存,结合ZooKeeper的统一管理,做到自动动态更新缓存,如图3所示。
图3 Mtconfig实现图
这类的缓存实现,优点是直接的在heap区内读写,最快也最方便;缺点同样是受heap区域影响,缓存的数据量非常有限,同时缓存时间受GC影响。主要满足单机场景下的小数据量缓存需求,同时对缓存数据的变更无需太敏感感知,如上一般配置管理、基础静态数据等场景。
EhCache
Ehcache是现在最流行的纯Java开源缓存框架,配置简单、结构清晰、功能强大,是一个非常轻量级的缓存实现,我们常用的Hibernate里面就集成了相关缓存功能。
图4 ehcache框架图
从图4中我们可以了解到,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等传播方式进行节点间通信,如图4的左侧部分描述。
整体数据流转包括这样几类行为:
图5反应了数据在各个层之间的流转,同时也体现了各层数据的一个生命周期。 以下看下ehcache的配置使用:
<ehcache>
<diskstore path="java.io.tmpdir">
<defaultcache maxelementsinmemory="10000" eternal="false" overflowtodisk="true" timetoidleseconds="0" timetoliveseconds="0" diskpersistent="false" diskexpirythreadintervalseconds="120">