Cache
guava cache是一个本地缓存。
优点
- 线程安全的缓存,与ConcurrentMap相似,但前者增加了更多的元素失效策略,后者只能显示的移除元素。
- 提供了三种基本的缓存回收方式:基于容量回收、定时回收和基于引用回收。定时回收有两种:按照写入时间,最早写入的最先回收;按照访问时间,最早访问的最早回收。
- 监控缓存加载/命中情况。
- 集成了多部操作,调用get方式,可以在未命中缓存的时候,从其他地方获取数据源(DB,redis),并加载到缓存中。
缺点
- Guava Cache的超时机制不是精确的。
public static void main(String[] args) throws ExecutionException, InterruptedException{
//缓存接口这里是LoadingCache,LoadingCache在缓存项不存在时可以自动加载缓存
LoadingCache<Integer,Student> studentCache
//CacheBuilder的构造函数是私有的,只能通过其静态方法newBuilder()来获得CacheBuilder的实例
= CacheBuilder.newBuilder()
//设置并发级别为8,并发级别是指可以同时写缓存的线程数
.concurrencyLevel(8)
//设置写缓存后8秒钟过期
.expireAfterWrite(8, TimeUnit.SECONDS)
//设置缓存容器的初始容量为10
.initialCapacity(10)
//设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项
.maximumSize(100)
//设置要统计缓存的命中率
.recordStats()
//设置缓存的移除通知
.removalListener(new RemovalListener<Object, Object>() {
@Override
public void onRemoval(RemovalNotification<Object, Object> notification) {
System.out.println(notification.getKey() + " was removed, cause is " + notification.getCause());
}
})
//build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存
.build(
new CacheLoader<Integer, Student>() {
@Override
public Student load(Integer key) throws Exception {
System.out.println("load student " + key);
Student student = new Student();
student.setId(key);
student.setName("name " + key);
return student;
}
}
);
for (int i=0;i<20;i++) {
//从缓存中得到数据,由于我们没有设置过缓存,所以需要通过CacheLoader加载缓存数据
Student student = studentCache.get(1);
System.out.println(student);
//休眠1秒
TimeUnit.SECONDS.sleep(1);
}
System.out.println("cache stats:");
//最后打印缓存的命中率等 情况
System.out.println(studentCache.stats().toString());
}
常用方法:
- V getIfPresent(Object key) 获取缓存中key对应的value,如果缓存没命中,返回null。
- V get(K key) throws ExecutionException 获取key对应的value,若缓存中没有,则调用LocalCache的load方法,从数据源中加载,并缓存。
- void put(K key, V value) 如果缓存有值,覆盖,否则,新增
- void putAll(Map m);循环调用单个的方法
- void invalidate(Object key); 删除缓存
- void invalidateAll(); 清楚所有的缓存,相当远map的clear操作。
- long size(); 获取缓存中元素的大概个数。为什么是大概呢?元素失效之时,并不会实时的更新size,所以这里的size可能会包含失效元素。
- CacheStats stats(); 缓存的状态数据,包括(未)命中个数,加载成功/失败个数,总共加载时间,删除个数等。
- asMap()方法获得缓存数据的ConcurrentMap快照
- cleanUp()清空缓存
- refresh(Key) 刷新缓存,即重新取缓存数据,更新缓存
- ImmutableMap getAllPresent(Iterable keys) 一次获得多个键的缓存值
核心类
- CacheBuilder:类,缓存构建器。构建缓存的入口,指定缓存配置参数并初始化本地缓存。
- CacheBuilder在build方法中,会把前面设置的参数,全部传递给LocalCache,它自己实际不参与任何计算。这种初始化参数的方法值得借鉴,代码简洁易读。
- CacheLoader:抽象类。用于从数据源加载数据,定义load、reload、loadAll等操作。
- Cache:接口,定义get、put、invalidate等操作,这里只有缓存增删改的操作,没有数据加载的操作。
- AbstractCache:抽象类,实现Cache接口。其中批量操作都是循环执行单次行为,而单次行为都没有具体定义。
- LoadingCache:接口,继承自Cache。定义get、getUnchecked、getAll等操作,这些操作都会从数据源load数据。
- AbstractLoadingCache:抽象类,继承自AbstractCache,实现LoadingCache接口。
- LocalCache:类。整个guava cache的核心类,包含了guava cache的数据结构以及基本的缓存的操作方法。
- LocalManualCache:LocalCache内部静态类,实现Cache接口。其内部的增删改缓存操作全部调用成员变量 localCache(LocalCache类型)的相应方法。
- LocalLoadingCache:LocalCache内部静态类,继承自LocalManualCache类,实现LoadingCache接口。 其所有操作也是调用成员变量localCache(LocalCache类型)的相应方法。
- CacheStats:缓存加载/命中统计信息。
RateLimiter
限流的定义如下:
In computer networks, rate limiting is used to control the rate of traffic sent or received by a network interface controller and is used to prevent DoS attacks.
通过控制数据的网络数据的发送或接收速率来防止可能出现的DOS攻击。而实际的软件服务过程中,限流也可用于API服务的保护。由于提供服务的计算机资源(包括CPU、内存、磁盘及网络带宽等)是有限的,则其提供的API服务的QPS也是有限的,限流工具就是通过限流算法对API访问进行限制,保证服务不会超过其能承受的负载压力。
常用限流算法
- Token bucket-令牌桶
- Leaky bucket-漏桶
- Fixed window counter-固定窗口计数
- Sliding window log-滑动窗口日志
- Sliding window counter-滑动窗口计数
固定窗口算法
固定窗口计数法思想比较简单,只需要确定两个参数:计数周期T及周期内最大访问(调用)数N。请求到达时使用以下流程进行操作:
固定窗口计数实现简单,并且只需要记录上一个周期起始时间与周期内访问总数,几乎不消耗额外的存储空间。
算法缺陷
固定窗口计数缺点也非常明显,在进行周期切换时,上一个周期的访问总数会立即置为0,这可能导致在进行周期切换时可能出现流量突发,
假设在两个周期T0中a时刻有n1个访问同时到达,周期T1中b时刻有n2个访问同时到达,且n1和n2均小于设定的最高访问次数N(否则会触发限流)。
根据以上假设可以推断,限流器不会限流,n1+n2次访问均可以通过。
根据观察可发现,在
t
t
t的时间内,出现了
n
1
+
n
2
n1+n2
n1+n2次请求,且
n
1
+
n
2
n1+n2
n1+n2是可能大于
N
N
N的,所以在实际使用过程中,固定窗口计数器存在突破限额N的可能。
举例,限制QPS为10,某用户在周期切换的前后的0.1秒内,分两次发送10次请求,根据算法规则此20次请求可通过限流器,则0.1面秒请求数20,超过每秒最多10次请求的限制。
滑动窗口计数
为解决固定窗口计数带来的周期切换处流量突发问题,可以使用滑动窗口计数。滑动窗口计算本质上也是固定窗口计数,区别在于将计数周期进行细化。
滑动窗口计数法与股固定窗口计数法相比较,除了计数周期T及周期内最大访问(调用)数N两个参数,增加一个参数M,用于设置周期T内的滑动窗口数。限流流程如下:
周期切换问题
滑动窗口针对周期进行了细分,不存在周期到后计数直接重置为0的情况,故不会出现跨周期的流量限制问题。
漏桶限流
简单说明为:人为设定漏桶流出速度及漏桶的总容量,在请求到达时判断当前漏桶容量是否已满,不满则可将请求存入桶中,否则抛弃请求。程序以设定的速率取出请求进行处理。
根据描述,需要确定参数为漏桶流出速度r及漏桶容量N,流程如下:
漏桶算法主要特点在于可以保证无论收到请求的速率如何,真正抵达服务方接口的请求速率最大为r,能够对输入的请求进行平滑处理。
漏桶算法的缺点也非常明显,由于其只能以特定速率处理请求,则如何确定该速率就是核心问题,如果速率设置太小则会浪费性能资源,设置太大则会造成资源不足。并且由于速率的设置,无论输入速率如何波动,均不会体现在服务端,即使资源有空余,对于突发请求也无法及时处理,故对有突发请求处理需求时,不宜选择该方法。
令牌桶限流
令牌桶限流的实现原理在wiki有详细说明。简单总结为:设定令牌桶中添加令牌的速率,并且设置桶中最大可存储的令牌,当请求到达时,向桶中请求令牌(根据应用需求,可能为1个或多个),若令牌数量满足要求,则删除对应数量的令牌并通过当前请求,若桶中令牌数不足则触发限流规则。
根据描述需要设置的参数为,令牌添加速率r,令牌桶中最大容量N,流程如下:
令牌桶算法通过设置令牌放入速率可以控制请求通过的平均速度,且由于设置的容量为N的桶对令牌进行缓存,可以容忍一定流量的突发。
Guava中RateLimiter的令牌桶实现
使用方法
1.引入相关的依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>19.0</version>
</dependency>
2.编写相关的Demo
public class RateLimiterTest {
public static void main(String[] args) throws InterruptedException {
RateLimiter limiter = RateLimiter.create(10);// 代码1
Thread.currentThread().sleep(1000);//步骤1
if (limiter.tryAcquire(20))//代码2
System.out.println("======== Time1:" + System.currentTimeMillis() / 1000);
Thread.currentThread().sleep(1001);
if (limiter.tryAcquire(1))//代码3
System.out.println("======== Time2:" + System.currentTimeMillis() / 1000);
if (limiter.tryAcquire(5))
System.out.println("======== Time3:" + System.currentTimeMillis() / 1000);
}
}
3.运行结果
场景1:
======== Time1:1533114071
======== Time2:1533114072
场景2:修改代码:去掉步骤1,运行结果如下:
======== Time1:1533114155
场景3:修改相关代码如下:
public class RateLimiterTest {
public static void main(String[] args) throws InterruptedException {
RateLimiter limiter = RateLimiter.create(10);// 代码1
Thread.currentThread().sleep(2000);
if (limiter.tryAcquire(21))//代码2
System.out.println("======== Time1:" + System.currentTimeMillis() / 1000);
Thread.currentThread().sleep(1001);
if (limiter.tryAcquire(1))//代码3
System.out.println("======== Time2:" + System.currentTimeMillis() / 1000);
if (limiter.tryAcquire(5))
System.out.println("======== Time3:" + System.currentTimeMillis() / 1000);
}
}
结果如下:
======== Time1:1533114623
下面我们来分析这三种情况产生的原因,顺便也分析下RateLimiter中的令牌桶算法是如何实现的。
在分析之前,说明一点,我之前一直以为令牌桶算法,是定时器机制,定时往桶里面放令牌,但是有些时候并不是这样的。先声明一下。
我们来分析下代码:
代码行1:
RateLimiter limiter = RateLimiter.create(10);
这行代码,我们知道是创建一个每秒产生10个permit的速率器
代码行2:
limiter.tryAcquire(20) //尝试从速率器中获取20个permit,获取成功 true;失败 false
代码行3:
limiter.tryAcquire(1) //尝试从速率器中获取1个permit,获取成功 true;失败 false
为什么相同的代码,不同的休眠时间导致不同的结果呢?
RateLimiter 速率器,通过预支将来的令牌来进行限制频控,什么意思呢?打个比方:速率器相当于工厂,获取令牌许可的线程相当于经销商,经销商过来取货,工厂每天的生产的货品是一定的(100吨/天),A经销商来取货,第一天取了200吨货,工厂没有这么多货,怎么办呢?为了留住这个经销商,厂长做了决定,给200吨,现在的100吨先给你,明天的100吨也给你,然后把200吨货品的提取清单给了A经销商,A很满意的离开了。过了一会,B来了,B要10吨货物,这个时候,厂长就没有那么好说话了(谁让大客户已经到手了呢?),说10吨货物可以,你后天来吧,明天和今天的活已经都卖完了。这个时候通过这种方式,来限制一天只卖/生产100吨的货物。
代码分析
RateLimiter limiter = RateLimiter.create(10);
调用的是:
@VisibleForTesting
static RateLimiter create(SleepingStopwatch stopwatch, double permitsPerSecond) {
RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds 注意 这里的maxBurstSeconds指定的是1s 直接影响后面的maxPermit*/);
rateLimiter.setRate(permitsPerSecond);//见下文代码
return rateLimiter;
}
setRate(permitsPerSecond)如下:
public final void setRate(double permitsPerSecond) {
checkArgument(
permitsPerSecond > 0.0 && !Double.isNaN(permitsPerSecond), "rate must be positive");
synchronized (mutex()) {
doSetRate(permitsPerSecond, stopwatch.readMicros());//stopwatch.readMirco 获取的是创建以来的系统时间 这里调用SmoothRateLimiter.doSetRate()
}
}
SmoothRateLimiter.doSetRate()
@Override
final void doSetRate(double permitsPerSecond, long nowMicros) {
resync(nowMicros);//你可以认为这边是重设相关的nextFreeTicketMicros和storedPermits 这个函数是相关计算频控的重要组成部分 ------1
double stableIntervalMicros = SECONDS.toMicros(1L) / permitsPerSecond;
this.stableIntervalMicros = stableIntervalMicros;
doSetRate(permitsPerSecond, stableIntervalMicros);//这个函数是RateLimiter创建时候 初始化maxpermits和StorePermits的相关部分 也是一个重要的部分 ---2
}
我们来看1的实现:
/**
* Updates {@code storedPermits} and {@code nextFreeTicketMicros} based on the current time.
* 基于当前的时间 计算相关的storedPermits和nextFreeTicketMicros
* storedPermits:当前存储的令牌数
* nextFreeTicketMicros:下次可以获取令牌的时间 其实这么讲不太准确 应该说是,上次令牌获取之后预支到下次可以获取令牌的最早时间
* 此处再创建的时候 nextFreeTicketMicros基本就是创建时候的系统时间
*/
void resync(long nowMicros) {
// if nextFreeTicket is in the past, resync to now
if (nowMicros > nextFreeTicketMicros) {
storedPermits = min(maxPermits,
storedPermits
+ (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros());
nextFreeTicketMicros = nowMicros;
}
}
我们可以看到,我们这里通过计算当前时间和下次可以获取令牌的时间,相互计算差值,然后除以一个令牌产生的时间间隔,来计算当前时段可以产生多少令牌,然后和我们的 maxPermits来取最小值,由此我们可以看到storedPermits最多只能存储maxPermits数量的令牌,这也是令牌桶大小所限制的。
我们再来看2代码的实现:
@Override
void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
double oldMaxPermits = this.maxPermits;
maxPermits = maxBurstSeconds * permitsPerSecond;//设置最大可存储的令牌数 这里的maxBurstSeconds 就是之前设置的1s 所以maxPermits数值上等于我们设置的permitsPerSecond
if (oldMaxPermits == Double.POSITIVE_INFINITY) {
// if we don't special-case this, we would get storedPermits == NaN, below
storedPermits = maxPermits;
} else {
storedPermits = (oldMaxPermits == 0.0)
? 0.0 // initial state
: storedPermits * maxPermits / oldMaxPermits;
}
}
到这里我们的初始化RateLimiter结束了。我们来明确其中的几个概念:
- maxPermits:最大存储的令牌数,即令牌桶的大
- storedPermits:已存储的令牌数<=maxPermits,当然这个是通过计算算出来的
- nextFreeTicketMicros:上次获取令牌时预支的最早能够再次获取令牌的时间
- nowMicros:当前系统时间
我们接下来看如何获取令牌:
代码2:
limiter.tryAcquire(20)
具体的代码实现如下:
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {//timeout = 0 unit=MICROSECONDS
long timeoutMicros = max(unit.toMicros(timeout), 0);
checkPermits(permits);//校验参数
long microsToWait;
synchronized (mutex()) {//互斥量
long nowMicros = stopwatch.readMicros();
if (!canAcquire(nowMicros, timeoutMicros)) {//此处判断当前时间是否大于等于上次预支最早时间 ----1
return false;
} else {
microsToWait = reserveAndGetWaitLength(permits, nowMicros);//当前线程获取到permit需要等待的时间 ---2
}
}
stopwatch.sleepMicrosUninterruptibly(microsToWait);//线程等待 获取permit
return true;
}
我们来看1的实现部分:
private boolean canAcquire(long nowMicros, long timeoutMicros) {
return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
}
@Override
final long queryEarliestAvailable(long nowMicros) {
return nextFreeTicketMicros;
}
有此可见,如果当前时间+超时时间>=预支的最早时间,那么是可以获取许可的,反之则不能获取许可
再看代码2的实现:
final long reserveAndGetWaitLength(int permits, long nowMicros) {
long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
return max(momentAvailable - nowMicros, 0);//计算需要等待的时间
}
SmoothRateLimiter.reserveEarliestAvailable()
@Override
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
resync(nowMicros);//这里是重设相关的storedPermits和nenextFreeTicketMicros 这个在前文我们讲过 需要注意的是 这边的nextFreeTicketMicros设置的是nowMicros 可能会有人有疑问,nextFreeTicketMicros不是预支的最早获取permit的时间吗?怎么是nowMicros了呢?我们下面看
long returnValue = nextFreeTicketMicros;//这里返回的其实就是nowMiscros
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);//本次能消费的最多的permit
double freshPermits = requiredPermits - storedPermitsToSpend;//需要预支的permit
long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);//预支的生产的时间
try {
this.nextFreeTicketMicros = LongMath.checkedAdd(nextFreeTicketMicros, waitMicros);//这里就是重设了预支下次能够获取permit的最早时间了 这边将waitMiscros加上了
} catch (ArithmeticException e) {
this.nextFreeTicketMicros = Long.MAX_VALUE;
}
this.storedPermits -= storedPermitsToSpend;//扣除本地消费的permit
return returnValue;//返回当前时间
}
这样就完成了前后两个permit之间获取的的联动性,并不是有一个定时任务往中间放permit,而是直接预支的后面消费者的时间来进行控制的,这样有一个好处就是,第一次获取permit的时候,其实可以获取N多个permit,并不做限制,只是这么多的permit会导致后面消费者卡死在那边,当然,消费者在timeOut范围内获取不到permit也就直接返回了。