目录
缓存简介
缓存应用和实现
高并发缓存问题
合理利用缓存
限流简介
方案一:令牌桶方式(Token Bucket)
方案二:漏桶方式
方案三:计数器方式
压力测试
为什么会有这个话题
基本的容错模式
服务降级
服务熔断
方案:Hystrix简介
Hystrix设计原则
Hystrix特性
Hystrix流程
Hystrix测试说明
方案:Sentinel简介
Sentinel特征
随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器所做的计算也越来越多。但是往往我们的应用服务器资源是有限的,且技术变革是缓慢的,数据库每秒能接受的请求次数也是有限的(或者文件的读写也是有限的),如何能够有效利用有限的资源来提供尽可能大的吞吐量? 一个有效的办法就是引入缓存,打破标准流程,每个环节中请求可以从缓存中直接获取目标数据并返回,从而减少计算量,有效提升响应速度,让有限的资源服务更多的用户。
如图1所示,缓存的使用可以出现在1~4的各个环节中,每个环节的缓存方案与使用各有特点。
图1 互联网应用一般流程
影响缓存命中率的因素:
1.业务场景和业务需求
缓存通常适合读多写少的业务场景,反之的使用意义并不多,命中率会很低。业务需求也决定了实时性的要求,直接影响到过期时间和更新策略,实时性要求越低越适合缓存。
2.缓存的设计(策略和粒度)
通常情况下缓存的粒度越小,命中率越高。比如说缓存一个用户信息的对象,只有当这个用户的信息发生变化的时候才更新缓存,而如果是缓存一个集合的话,集合中任何一个对象发生变化都要重新更新缓存。
当数据发生变化时,直接更新缓存的值比移除缓存或者让缓存过期它的命中率更高,不过这个时候系统的复杂度过高。
3.缓存的容量和基础设施
缓存的容量有限就会容易引起缓存的失效和被淘汰。目前多数的缓存框架和中间件都采用LRU这个算法。同时采用缓存的技术选型也是至关重要的,比如采用本地内置的应用缓存,就比较容易出现单机瓶颈。而采用分布式缓存就更加容易扩展。所以需要做好系统容量规划,系统是否可扩展。
缓存最大空间一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,从而更有效的利用缓存。
虽然从硬件介质上来看,无非就是内存和硬盘两种,但从技术上,可以分成内存、硬盘文件、数据库。
FIFO/LFU/LRU/过期时间/随机
FIFO:最先进入缓存的数据,在缓存空间不足时被清除,为了保证最新数据可用,保证实时性
LFU(Least Frequently Used):最近最不常用,基于访问次数,去除命中次数最少的元素,保证高频数据有效性
LRU(Least Recently Used):最近最少使用,基于访问时间,在被访问过的元素中去除最久未使用的元素,保证热点数据的有效性
一切地方。例如:
上面这是系统层面,在软件系统设计层面,很多地方也用了缓存:
缓存有各类特征,而且有不同介质的区别,那么实际工程中我们怎么去对缓存分类呢? 在目前的应用服务框架中,比较常见的是根据缓存与应用的藕合度,分为local cache(本地缓存)和remote cache(分布式缓存):
本地缓存:指的是在应用中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适;同时,它的缺点也是应为缓存跟应用程序耦合,多个应用程序无法直接的共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。
分布式缓存:指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存。
目前各种类型的缓存都活跃在成千上万的应用服务中,还没有一种缓存方案可以解决一切的业务场景或数据类型,我们需要根据自身的特殊场景和背景,选择最适合的缓存方案。缓存的使用是程序员、架构师的必备技能,好的程序员能根据数据类型、业务场景来准确判断使用何种类型的缓存,如何使用这种缓存,以最小的成本最快的效率达到最优的目的。
编程直接实现缓存 个别场景下,我们只需要简单的缓存数据的功能,而无需关注更多存取、清空策略等深入的特性时,直接编程实现缓存则是最便捷和高效的。
简单代码示例如下:
public void UseLocalCache(){
//一个本地的缓存变量
Map localCacheStoreMap = new HashMap();
List
以局部变量map结构缓存部分业务数据,减少频繁的重复数据库I/O操作。缺点仅限于类的自身作用域内,类间无法共享缓存。
最常用的单例实现静态资源缓存,代码示例如下:
public class CityUtils {
private static final HttpClient httpClient = ServerHolder.createClientWithPool();
private static Map cityIdNameMap = new HashMap();
private static Map districtIdNameMap = new HashMap();
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是现在最流行的纯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的左侧部分描述。
整体数据流转包括这样几类行为:
图4反映了数据在各个层之间的流转,同时也体现了各层数据的一个生命周期。
图4 缓存数据流转图(L1:本地内存层;L2:Terracotta服务节点层)
Ehcache的配置使用如下:
整体上看,Ehcache的使用还是相对简单便捷的,提供了完整的各类API接口。需要注意的是,虽然Ehcache支持磁盘的持久化,但是由于存在两级缓存介质,在一级内存中的缓存,如果没有主动的刷入磁盘持久化的话,在应用异常down机等情形下,依然会出现缓存数据丢失,为此可以根据需要将缓存刷到磁盘,将缓存条目刷到磁盘的操作可以通过cache.flush()方法来执行,需要注意的是,对于对象的磁盘写入,前提是要将对象进行序列化。
主要特性:
注意:Ehcache的超时设置主要是针对整个cache实例设置整体的超时策略,而没有较好的处理针对单独的key的个性的超时设置(有策略设置,但是比较复杂,就不描述了),因此,在使用中要注意过期失效的缓存元素无法被GC回收,时间越长缓存越多,内存占用也就越大,内存泄露的概率也越大。
Guava Cache是Google开源的Java重用工具集库Guava里的一款缓存工具,其主要实现的缓存功能有:
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的整体数据结构后,再来看下针对缓存的相关操作就简单多了:
Guava Cache提供Builder模式的CacheBuilder生成器来创建缓存的方式,十分方便,并且各个缓存参数的配置设置,类似于函数式编程的写法,可自行设置各类参数选型。它提供三种方式加载到缓存中。分别是:
build生成器的两种方式都实现了一种逻辑:从缓存中取key的值,如果该值已经缓存过了则返回缓存中的值,如果没有缓存过可以通过某个方法来获取这个值,不同的地方在于cacheloader的定义比较宽泛,是针对整个cache定义的,可以认为是统一的根据key值load value的方法,而callable的方式较为灵活,允许你在get的时候指定load方法。使用示例如下:
/**
* CacheLoader
*/
public void loadingCache()
{
LoadingCache graphs =CacheBuilder.newBuilder()
.maximumSize(1000).build(new CacheLoader()
{
@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 cache = CacheBuilder.newBuilder()
.maximumSize(1000).build();
String result = cache.get("key", new Callable()
{
public String call()
{
return "result";
}
});
System.out.println(result);
}
总体来看,Guava Cache基于ConcurrentHashMap的优秀设计借鉴,在高并发场景支持和线程安全上都有相应的改进策略,使用Reference引用命令,提升高并发下的数据……访问速度并保持了GC的可回收,有效节省空间;同时,write链和access链的设计,能更灵活、高效的实现多种类型的缓存清理策略,包括基于容量的清理、基于时间的清理、基于引用的清理等;编程式的build生成器管理,让使用者有更多的自由度,能够根据不同场景设置合适的模式。
Spring 3.1之后,引入了注解缓存技术,其本质上不是一个具体的缓存实现方案,而是一个对缓存使用的抽象,通过在既有代码中添加少量自定义的各种annotation,即能够达到使用缓存对象和缓存方法的返回对象的效果。Spring的缓存技术具备相当的灵活性,不仅能够使用SpEL(Spring Expression Language)来定义缓存的key和各种condition,还提供开箱即用的缓存临时存储方案,也支持和主流的专业缓存集成。其特点总结如下:
和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,主要针对方法上注解使用,部分场景也可以直接类上注解使用,当在类上使用时,该类所有方法都将受影响。我们总结一下其作用和配置方法,如下表所示。
标签类型 | 作用 | 主要配置参数说明 |
---|---|---|
@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,其作用和配置方法见下表。
标签类型 | 作用 | 主要配置参数说明 |
---|---|---|
@HotelCacheable | 主要针对方法配置,能够根据方法的请求参数对其结果进行缓存 | domain:作用域,针对集合场景,解决批量更新问题; domainKey:作用域对应的缓存key; key:缓存对象key 前缀; fieldKey:缓存对象key,与前缀合并生成对象key; condition:缓存获取前置条件,支持spel语法; cacheCondition:缓存刷入前置条件,支持spel语法; expireTime:超时时间设置 |
@HotelCacheEvict | 主要针对方法配置,能够根据一定的条件对缓存进行清空 | 同上 |
增加作用域的概念,解决商家信息变更下,多重重要信息实时更新的问题。
图13 域缓存处理图
如图13,按旧的方案,当cache0发送变化时,为了保持信息的实时更新,需要手动删除cache1、cache2、cache3等相关处的缓存数据。增加域缓存概念,cache0、cache1、cache2、cache3是以账号ID为基础,相互存在影响约束的集合体,我们作为一个域集合,增加域缓存处理,当cache0发送变化时,整体的账号ID domain域已发生更新,自动影响cache1、cache2、cache3等处的缓存数据。将相关联逻辑缓存统一化,有效提升代码可读性,同时更好服务业务,账号重点信息能够实时变更刷新,相关服务响应速度提升。
另外,增加了cacheCondition缓存刷入前置判断,有效解决商家业务多重外部依赖场景下,业务降级有损服务下,业务数据一致性保证,不因为缓存的增加影响业务的准确性;自定义CacheManager缓存管理器,可以有效兼容公共基础组件Medis、Cellar相关服务,在对应用程序不做改动的情况下,有效切换缓存方式;同时,统一的缓存服务AOP入口,结合接入Mtconfig统一配置管理,对应用内缓存做好降级准备,一键关闭缓存。几点建议:
总之,注释驱动的Spring Cache能够极大的减少我们编写常见缓存的代码量,通过少量的注释标签和配置文件,即可达到使代码具备缓存的能力,且具备很好的灵活性和扩展性。但是我们也应该看到,Spring Cache由于基于Spring AOP技术,尤其是动态的proxy技术,导致其不能很好的支持方法的内部调用或者非public方法的缓存设置,当然这些都是可以解决的问题。
当数据时效性要求很高时,需要保证缓存中的数据与数据库中的保持一致,而且需要保证缓存节点和副本中的数据也保持一致,不能出现差异现象。这就比较依赖缓存的过期和更新策略。一般会在数据发生更改的时,主动更新缓存中的数据或者移除对应的缓存。
缓存过期后将尝试从后端数据库获取数据,这是一个看似合理的流程。但是,在高并发场景下,有可能多个请求并发的去从数据库获取数据,对后端数据库造成极大的冲击,甚至导致 “雪崩”现象。此外,当某个缓存key在被更新时,同时也可能被大量请求在获取,这也会导致一致性的问题。那如何避免类似问题呢? 我们会想到类似“锁”的机制,在缓存更新或者过期的情况下,先尝试获取到锁,当更新或者从数据库获取完成后再释放锁,其他的请求只需要牺牲一定的等待时间,即可直接从缓存中继续获取数据。
缓存穿透在有些地方也称为“击穿”。很多朋友对缓存穿透的理解是:由于缓存故障或者缓存过期导致大量请求穿透到后端数据库服务器,从而对数据库造成巨大冲击。
这其实是一种误解。真正的缓存穿透应该是这样的:
在高并发场景下,如果某一个key被高并发访问,没有被命中,出于对容错性考虑,会尝试去从后端数据库中获取,从而导致了大量请求达到数据库,而当该key对应的数据本身就是空的情况下,这就导致数据库中并发的去执行了很多不必要的查询操作,从而导致巨大冲击和压力。
可以通过下面的几种常用方式来避免缓存传统问题:
对查询结果为空的对象也进行缓存,如果是集合,可以缓存一个空的集合(非null),如果是缓存单个对象,可以通过字段标识来区分。这样避免请求穿透到后端数据库。同时,也需要保证缓存数据的时效性。这种方式实现起来成本较低,比较适合命中不高,但可能被频繁更新的数据。
对所有可能对应数据为空的key进行统一的存放,并在请求前做拦截,这样避免请求穿透到后端数据库。这种方式实现起来相对复杂,比较适合命中不高,但是更新不频繁的数据。
缓存抖动可以看做是一种比“雪崩”更轻微的故障,但是也会在一段时间内对系统造成冲击和性能影响。一般是由于缓存节点故障导致。业内推荐的做法是通过一致性Hash算法来解决。这里不做过多阐述。
缓存雪崩就是指由于缓存的原因,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难。导致这种现象的原因有很多种,上面提到的“缓存并发”,“缓存穿透”,“缓存颠簸”等问题,其实都可能会导致缓存雪崩现象发生。这些问题也可能会被恶意攻击者所利用。还有一种情况,例如某个时间点内,系统预加载的缓存周期性集中失效了,也可能会导致雪崩。为了避免这种周期性失效,可以通过设置不同的过期时间,来错开缓存过期,从而避免缓存集中失效。
从应用架构角度,我们可以通过限流、降级、熔断等手段来降低影响,也可以通过多级缓存来避免这种灾难。
此外,从整个研发体系流程的角度,应该加强压力测试,尽量模拟真实场景,尽早的暴露问题从而防范。
不合理使用缓存非但不能提高系统的性能,还会成为系统的累赘,甚至风险。
如果缓存中保存的是频繁修改的数据,就会出现数据写入缓存后,应用还来不及读取缓存,数据就已经失效,徒增系统负担。一般来说,数据的读写比在2:1(写入一次缓存,在数据更新前至少读取两次)以上,缓存才有意义。
如果应用系统访问数据没有热点,不遵循二八定律,那么缓存就没有意义。
一般会对缓存的数据设置失效时间,一旦超过失效时间,就要从数据库中重新加载。因此要容忍一定时间的数据不一致,如卖家已经编辑了商品属性,但是需要过一段时间才能被买家看到。还有一种策略是数据更新立即更新缓存,不过这也会带来更多系统开销和事务一致性问题。
缓存会承担大部分数据库访问压力,数据库已经习惯了有缓存的日子,所以当缓存服务崩溃时,数据库会因为完全不能承受如此大压力而宕机,导致网站不可用。这种情况被称作缓存雪崩,发生这种故障,甚至不能简单地重启缓存服务器和数据库服务器来恢复。
实践中,有的网站通过缓存热备份等手段提高缓存可用性:当某台缓存服务器宕机时,将缓存访问切换到热备服务器上。但这种设计有违缓存的初衷,缓存根本就不应该当做一个可靠的数据源来使用。
通过分布式缓存服务器集群,将缓存数据分布到集群多台服务器上可在一定程度上改善缓存的可用性。当一台缓存服务器宕机时,只有部分缓存数据丢失,重新从数据库加载这部分数据不会产生很大的影响。
缓存中存放的是热点数据,热点数据又是缓存系统利用LRU(最近最久未用算法)对不断访问的数据筛选淘汰出来,这个过程需要花费较长的时间。新系统的缓存系统如果没有任何数据,在重建缓存数据的过程中,系统的性能和数据库负载都不太好,那么最好在缓存系统启动时就把热点数据加载好,这个缓存预加载手段叫缓存预热。对于一些元数据如城市地名列表、类目信息,可以在启动时加载数据库中全部数据到缓存进行预热。
如果因为不恰当的业务、或者恶意攻击持续高并发地请求某个不存在的数据,由于缓存没有保存该数据,所有的请求都会落到数据库上,会对数据库造成压力,甚至崩溃。一个简单的对策是将不存在的数据也缓存起来(其value为null)。
每个系统都有服务的上线,所以当流量超过服务极限能力时,系统可能会出现卡死、崩溃的情况,所以就有了降级和限流。限流其实就是:当高并发或者瞬时高并发时,为了保证系统的稳定性、可用性,系统以牺牲部分请求为代价或者延迟处理请求为代价,保证系统整体服务可用。
令牌桶(Token Bucket)、漏桶(leaky bucket)和计数器算法是最常用的三种限流的算法。
应用级限流方式只是单应用内的请求限流,不能进行全局限流。
我们需要分布式限流和接入层限流来进行全局限流。
令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。先有一个木桶,系统按照固定速度,往桶里加入Token,如果桶已经满了就不再添加。当有请求到来时,会各自拿走一个Token,取到Token 才能继续进行请求处理,没有Token 就拒绝服务。
这里如果一段时间没有请求时,桶内就会积累一些Token,下次一旦有突发流量,只要Token足够,也能一次处理,所以令牌桶算法的特点是允许突发流量。
Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
// 将得到类似如下的输出:
0.0
0.198239
0.196083
0.200609
0.199599
0.19961
1、RateLimiter.create(5)表示桶容量为5且每秒新增5个令牌,即每隔200毫秒新增一个令牌;
2、limiter.acquire()表示消费一个令牌,如果当前桶中有足够令牌则成功(返回值为0),如果桶中没有令牌则暂停一段时间,比如发令牌间隔是200毫秒,则等待200毫秒后再去消费令牌(如上测试用例返回的为0.198239,差不多等待了200毫秒桶中才有令牌可用),这种实现将突发请求速率平均为了固定请求速率。如果结构不想等待可以采用tryAcquire立刻返回!
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire(5));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1))
// 将得到类似如下的输出:
0.0
0.98745
0.183553
0.199909
limiter.acquire(5)表示桶的容量为5且每秒新增5个令牌,令牌桶算法允许一定程度的突发,所以可以一次性消费5个令牌,但接下来的limiter.acquire(1)将等待差不多1秒桶中才能有令牌,且接下来的请求也整形为固定速率了。
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire(10));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));
// 将得到类似如下的输出:
0.0
1.997428
0.192273
0.200616
同上边的例子类似,第一秒突发了10个请求,令牌桶算法也允许了这种突发(允许消费未来的令牌),但接下来的limiter.acquire(1)将等待差不多2秒桶中才能有令牌,且接下来的请求也整形为固定速率了。
RateLimiter limiter = RateLimiter.create(2);
System.out.println(limiter.acquire());
Thread.sleep(2000L);
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
// 将得到类似如下的输出:
0.0
0.0
0.0
0.0
0.499876
0.495799
1、创建了一个桶容量为2且每秒新增2个令牌; 2、首先调用limiter.acquire()消费一个令牌,此时令牌桶可以满足(返回值为0); 3、然后线程暂停2秒,接下来的两个limiter.acquire()都能消费到令牌,第三个limiter.acquire()也同样消费到了令牌,到第四个时就需要等待500毫秒了。
此处可以看到我们设置的桶容量为2(即允许的突发量),这是因为SmoothBursty中有一个参数:最大突发秒数(maxBurstSeconds)默认值是1s,突发量/桶容量=速率*maxBurstSeconds,所以本示例桶容量/突发量为2,例子中前两个是消费了之前积攒的突发量,而第三个开始就是正常计算的了。令牌桶算法允许将一段时间内没有消费的令牌暂存到令牌桶中,留待未来使用,并允许未来请求的这种突发.
SmoothBursty通过平均速率和最后一次新增令牌的时间计算出下次新增令牌的时间的,另外需要一个桶暂存一段时间内没有使用的令牌(即可以突发的令牌数)。另外RateLimiter还提供了tryAcquire方法来进行无阻塞或可超时的令牌消费。
因为SmoothBursty允许一定程度的突发,会有人担心如果允许这种突发,假设突然间来了很大的流量,那么系统很可能扛不住这种突发。因此需要一种平滑速率的限流工具,从而系统冷启动后慢慢的趋于平均固定速率(即刚开始速率小一些,然后慢慢趋于我们设置的固定速率)。Guava也提供了SmoothWarmingUp来实现这种需求类似漏桶算法;
SmoothWarmingUp创建方式:RateLimiter.create(doublepermitsPerSecond, long warmupPeriod, TimeUnit unit)
permitsPerSecond表示每秒新增的令牌数,warmupPeriod表示在从冷启动速率过渡到平均速率的时间间隔。
RateLimiter limiter = RateLimiter.create(5,1000, TimeUnit.MILLISECONDS);
for(inti =1; i < 5;i++) {
System.out.println(limiter.acquire());
}
Thread.sleep(1000L);
for(inti =1; i < 5;i++) {
System.out.println(limiter.acquire());
}
// 将得到类似如下的输出:
0.0
0.51767
0.357814
0.219992
0.199984
0.0
0.360826
0.220166
0.199723
0.199555
速率是梯形上升速率的,也就是说冷启动时会以一个比较大的速率慢慢到平均速率;然后趋于平均速率(梯形下降到平均速率)。可以通过调节warmupPeriod参数实现一开始就是平滑固定速率。
水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。
可见这里有两个变量,一个是桶的大小,支持流量突发增多时可以存多少的水(burst),另一个是水桶漏洞的大小(rate)。
因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率.因此,漏桶算法对于存在突发特性的流量来说缺乏效率.
计数器限流算法也是比较常用的,主要用来限制总并发数,比如数据库连接池大小、线程池大小、程序访问并发数等都是使用计数器算法。也是最简单粗暴的算法。
使用AomicInteger来进行统计当前正在并发执行的次数,如果超过域值就简单粗暴的直接响应给用户,说明系统繁忙,请稍后再试或其它跟业务相关的信息。
弊端:使用 AomicInteger 简单粗暴超过域值就拒绝请求,可能只是瞬时的请求量高,也会拒绝请求。
使用Semaphore信号量来控制并发执行的次数,如果超过域值信号量,则进入阻塞队列中排队等待获取信号量进行执行。如果阻塞队列中排队的请求过多超出系统处理能力,则可以在拒绝请求。
相对Atomic优点:如果是瞬时的高并发,可以使请求在阻塞队列中排队,而不是马上拒绝请求,从而达到一个流量削峰的目的。
固定线程池大小,超出固定先线程池和最大的线程数,拒绝线程请求;
给个思路
比如:
@SneakyThrows
public static void test(int clientSize) {
CountDownLatch downLatch = new CountDownLatch(clientSize);
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(clientSize);
IntStream.range(0, clientSize).forEach(i ->
fixedThreadPool.submit(() -> {
RestTemplate restTemplate = new RestTemplate();
restTemplate.getForObject("http://localhost:8080/limit1", ResponseResult.class);
downLatch.countDown();
})
);
downLatch.await();
fixedThreadPool.shutdown();
}
服务之间的依赖关系导致
当用户请求 A、P、H、I 四个服务获取数据时,在正常流量下系统稳定运行,如果某天系统进来大量流量,其中服务 I 出现 CPU、内存占用过高等问题,结果导致服务 I 出现延迟、响应过慢,随着请求的持续增加,服务 I 承受不住压力导致内部错误或资源耗尽,一直不响应,此时更糟糕的是其他服务对 I 有依赖,那么这些依赖 I 的服务一直等待 I 的响应,也会出现请求堆积、资源占用,慢慢扩散到所有微服务,引发雪崩效应。
常见的容错模式主要包含以下几种方式
由于爆炸性的流量冲击,对一些服务进行有策略的放弃,以此缓解系统压力,保证目前主要业务的正常运行。它主要是针对非正常情况下的应急服务措施:当此时一些业务服务无法执行时,给出一个统一的返回结果。
在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:
熔断这一概念来源于电子工程中的断路器(Circuit Breaker)。在互联网系统中,当下游服务因访问压力过大而响应变慢或失败,上游服务为了保护系统整体的可用性,可以暂时切断对下游服务的调用。
在学习服务熔断时,有必要区分下如下几个相关的概念。
多个微服务之间调用的时候,假设微服务A调用微服务B和微服务C,微服务B和微服务C有调用其他的微服务,如果整个链路上某个微服务的调用响应式过长或者不可用,对微服务A的调用就会占用越来越多的系统资源,进而引起系统雪崩,所谓的”雪崩效应”
“断路器”本身是一种开关装置,当某个服务单元发生故障监控(类似熔断保险丝),向调用方法返回一个符合预期的、可处理的备选响应(FallBack),而不是长时间的等待或者抛出调用方法无法处理的异常,这样就保证了服务调用方的线程不会被长时间、不必要地占用,从而避免了故障在分布式系统中的蔓延。乃至雪崩。
熔断机制是应对雪崩效应的一种微服务链路保护机制,当整个链路的某个微服务不可用或者响应时间太长时,会进行服务的降级,进而熔断该节点微服务的调用,快速返回”错误”的响应信息。
Hystrix是一个用于分布式系统的延迟和容错的开源库。在分布式系统里,许多依赖不可避免的调用失败,比如超时、异常等,Hystrix能够保证在一个依赖出问题的情况下,不会导致整个服务失败,避免级联故障,以提高分布式系统的弹性。
上述概念中,我们知道熔断流程通常通过断路器(Curcuit Breaker)模式实现,那断路器模式的熔断流程是怎么样的呢?
基本的断路器(Curcuit Breaker)结构如下:
它有两个基本状态(close和open)和一个基本trip动作:
基本的断路器模式下,保证了断路器在open状态时,保护supplier不会被调用, 但我们还需要额外的措施可以在supplier恢复服务后,可以重置断路器。一种可行的办法是断路器定期探测supplier的服务是否恢复, 一但恢复, 就将状态设置成close。断路器进行重试时的状态为半开(half-open)状态。
服务熔断对服务提供了proxy,防止服务不可能时,出现串联故障(cascading failure),导致雪崩效应。
服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑。
源自博主张善友的观点:
异常处理:调用受熔断器保护的服务的时候,我们必须要处理当服务不可用时的异常情况。这些异常处理通常需要视具体的业务情况而定。比如,如果应用程序只是暂时的功能降级,可能需要切换到其它的可替换的服务上来执行相同的任务或者获取相同的数据,或者给用户报告错误然后提示他们稍后重试。
异常的类型:请求失败的原因可能有很多种。一些原因可能会比其它原因更严重。比如,请求会失败可能是由于远程的服务崩溃,这可能需要花费数分钟来恢复;也可能是由于服务器暂时负载过重导致超时。熔断器应该能够检查错误的类型,从而根据具体的错误情况来调整策略。比如,可能需要很多次超时异常才可以断定需要切换到断开状态,而只需要几次错误提示就可以判断服务不可用而快速切换到断开状态。
日志:熔断器应该能够记录所有失败的请求,以及一些可能会尝试成功的请求,使得的管理员能够监控使用熔断器保护的服务的执行情况。 测试服务是否可用:在断开状态下,熔断器可以采用定期的ping远程的服务或者资源,来判断是否服务是否恢复,而不是使用计时器来自动切换到半断开状态。这种ping操作可以模拟之前那些失败的请求,或者可以使用通过调用远程服务提供的检查服务是否可用的方法来判断。
手动重置:在系统中对于失败操作的恢复时间是很难确定的,提供一个手动重置功能能够使得管理员可以手动的强制将熔断器切换到闭合状态。同样的,如果受熔断器保护的服务暂时不可用的话,管理员能够强制的将熔断器设置为断开状态。 并发问题:相同的熔断器有可能被大量并发请求同时访问。熔断器的实现不应该阻塞并发的请求或者增加每次请求调用的负担。 资源的差异性:使用单个熔断器时,一个资源如果有分布在多个地方就需要小心。比如,一个数据可能存储在多个磁盘分区上(shard),某个分区可以正常访问,而另一个可能存在暂时性的问题。在这种情况下,不同的错误响应如果混为一谈,那么应用程序访问的这些存在问题的分区的失败的可能性就会高,而那些被认为是正常的分区,就有可能被阻塞。
加快熔断器的熔断操作:有时候,服务返回的错误信息足够让熔断器立即执行熔断操作并且保持一段时间。比如,如果从一个分布式资源返回的响应提示负载超重,那么应该等待几分钟后再重试。(HTTP协议定义了”HTTP 503 Service Unavailable”来表示请求的服务当前不可用,他可以包含其他信息比如,超时等)
重复失败请求:当熔断器在断开状态的时候,熔断器可以记录每一次请求的细节,而不是仅仅返回失败信息,这样当远程服务恢复的时候,可以将这些失败的请求再重新请求一次。
如果服务是幂等性的,则恢复重试不会有问题;而如果服务是非幂等性的,则重试会导致数据出现问题。
Spring Cloud Netflix Hystrix就是隔离措施的一种实现,可以设置在某种超时或者失败情形下断开依赖调用或者返回指定逻辑,从而提高分布式系统的稳定性.
防止单个服务的故障,耗尽整个系统服务的容器(比如tomcat)的线程资源,避免分布式环境里大量级联失败。通过第三方客户端访问(通常是通过网络)依赖服务出现失败、拒绝、超时或短路时执行回退逻辑
用快速失败代替排队(每个依赖服务维护一个小的线程池或信号量,当线程池满或信号量满,会立即拒绝服务而不会排队等待)和优雅的服务降级;当依赖服务失效后又恢复正常,快速恢复
提供接近实时的监控和警报,从而能够快速发现故障和修复。监控信息包括请求成功,失败(客户端抛出的异常),超时和线程拒绝。如果访问依赖服务的错误百分比超过阈值,断路器会跳闸,此时服务会在一段时间内停止对特定服务的所有请求
将所有请求外部系统(或请求依赖服务)封装到HystrixCommand或HystrixObservableCommand对象中,然后这些请求在一个独立的线程中执行。使用隔离技术来限制任何一个依赖的失败对系统的影响。每个依赖服务维护一个小的线程池(或信号量),当线程池满或信号量满,会立即拒绝服务而不会排队等待
这时会判断下一次请求的返回情况, 如果请求成功, 断路器切回闭路状态(CLOSED), 否则重新切换到开路状态(OPEN). Hystrix的断路器就像我们家庭电路中的保险丝, 一旦后端服务不可用, 断路器会直接切断请求链, 避免发送大量无效请求影响系统吞吐量, 并且断路器有自我检测并恢复的能力.
服务降级:Fallback相当于是降级操作. 对于查询操作, 我们可以实现一个fallback方法, 当请求后端服务出现异常的时候, 可以使用fallback方法返回的值. fallback方法的返回值一般是设置的默认值或者来自缓存.告知后面的请求服务不可用了,不要再来了。
依赖隔离(采用舱壁模式,Docker就是舱壁模式的一种):在Hystrix中, 主要通过线程池来实现资源隔离. 通常在使用的时候我们会根据调用的远程服务划分出多个线程池.比如说,一个服务调用两外两个服务,你如果调用两个服务都用一个线程池,那么如果一个服务卡在哪里,资源没被释放
后面的请求又来了,导致后面的请求都卡在哪里等待,导致你依赖的A服务把你卡在哪里,耗尽了资源,也导致了你另外一个B服务也不可用了。这时如果依赖隔离,某一个服务调用A B两个服务,如果这时我有100个线程可用,我给A服务分配50个,给B服务分配50个,这样就算A服务挂了,我的B服务依然可以用。
请求缓存:比如一个请求过来请求我userId=1的数据,你后面的请求也过来请求同样的数据,这时我不会继续走原来的那条请求链路了,而是把第一次请求缓存过了,把第一次的请求结果返回给后面的请求。
请求合并:我依赖于某一个服务,我要调用N次,比如说查数据库的时候,我发了N条请求发了N条SQL然后拿到一堆结果,这时候我们可以把多个请求合并成一个请求,发送一个查询多条数据的SQL的请求,这样我们只需查询一次数据库,提升了效率。
Hystrix流程图如下:
Hystrix流程说明:
这里接着前面的Ribbon进行Hystrix集成。说白了你想对一个请求进行熔断,必然不能让客户直接去调用那个请求,你必然要要对别人的请求进行包装一层和拦截,才能做点手脚,比如进行熔断,所以说要在Ribbon上动手脚。因为它是请求发起的地方。 我们刚开始请求一个服务,为了负载均衡进行了拦截一次,现在我们要进行熔断,所以必须跟Ribbon集成一次,再进行请求拦截来熔断。
Sentinel 是阿里中间件团队开源的,面向分布式服务架构的轻量级高可用流量控制组件,主要以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度来帮助用户保护服务的稳定性。
Sentinel 的主要特性:
Sentinel 的开源生态:
Sentinel 分为两个部分: