引言
缓存是存储在内存中的KV数据结构,分为分布式缓存和本地缓存。
分布式缓存方案中,一般应用进程和缓存进程不在同一台服务器,通过RPC或HTTP进行通信,可以实现应用服务和缓存的完全解耦,支持大量的数据存储,
分布式缓存常见有redis,memcache等。
本地缓存方案中的应用进程和缓存进程在同一个进程,没有网络开销,访问速度快,但受限于内存,不适合存储大量数据。本地缓存主要有Guava cache,Caffeine,Encache等,还可以通过HashMap自实现一套本地缓存机制。
今天重点来聊一聊本地缓存的应用。
Guava Cache
使用Guava Cache缓存本地数据,首先需要引入对应的工具包
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
本地缓存数据,并支持强制刷新。
@Component
public class MyGuavaCache {
private LoadingCache<String, Field> fieldCache = CacheBuilder.newBuilder()
.maximumSize(1000) // 设置最大容量
.refreshAfterWrite(24, TimeUnit.HOURS) //设置过期刷新间隔
.build(
new CacheLoader<String, Field>() { // 这是自动刷新
@Override
public Field load(String key) throws Exception {
return getFromDb(key);
}
}
);
/**
* 从db取数
*/
private Field getFromDb(String key) {
return dbRepositiory.getByKey(key);
}
/**
* 提供给外部调用获取结果
*/
public Map<String, Field> get(String key) {
try {
return fieldCache.get(key); // 过期会自动执行load获取数据
} catch (ExecutionException e) {
throw new UncheckedExecutionException(e);
}
}
/**
* 手动强制缓存失效
* @param key
*/
public void invalidate(String key) {
fieldCache.invalidate(key);
}
}
Guava cache缓存支持最大容量限制,可以指定插入时间和访问时间的过期删除策略,且是线程安全,是基于LRU算法实现。在文章的结尾,会简单介绍一下LRU算法。
Caffeine
Caffeine内部采用了一种结合LRU、LFU优点的算法:W-TinyLFU,在性能上比Guava cache更加优秀,Caffeine的API基本和Guava cache一样。下面是Caffeine的三种加载方式:
/**
* 手动加载
*/
Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)
.build();
cache.get("key", k -> createData("key"));
/**
* 同步加载
*/
LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(this::createData);
/**
* 异步加载
*/
AsyncCache<String,String> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)
.buildAsync();
CompletableFuture<String> data = cache.get(key , k -> createData(k));
Caffeine的过期设置也几乎和Guava cache一致
LoadingCache<String,String> cache = Caffeine.newBuilder()
//限制最大数量
.maximumSize(1000)
//基于权重
.maximumWeight(1000)
//指定计算权重的方式
.weigher(this::caculateWeight)
//缓存在写入多久后失效
.expireAfterWrite(1000,TimeUnit.SECONDS)
//缓存在访问多久后失效
.expireAfterAccess(1000,TimeUnit.SECONDS)
//多种时间过期策略组合使用
.expireAfter(new Expiry<String, String>() {
public long expireAfterCreate(Key key, Graph graph, long currentTime) {
long seconds = graph.creationDate().plusHours(5)
.minus(System.currentTimeMillis(), ChronoUnit.MILLIS).getSeconds();
return TimeUnit.SECONDS.toNanos(seconds);
}
public long expireAfterUpdate(Key key, Graph graph,long currentTime, long currentDuration) {
return currentDuration;
}
public long expireAfterRead(Key key, Graph graph,long currentTime, long currentDuration) {
return currentDuration;
}
})
.build(this::load);
Encache
Encache是一个纯Java的进程内缓存框架,是Hibernate中默认de CacheProvider。同Caffeine和Guava Cache相比,Encache的功能更加丰富,扩展性更强,支持多种缓存淘汰算法,包括LRU、LFU和FIFO,缓存支持堆内存储、堆外存储、磁盘存储(支持持久化)三种,支持多种集群方案,解决数据共享问题。
Encache在性能上不及Caffeine和Guava Cache,其中Caffeine性能最优,在本地缓存方案中,比较推荐Caffeine作为本地缓存工具,另外使用redis或者memcache作为分布式缓存,构造多级缓存体系,保证性能和可靠性。
缓存算法
缓存算法有FIFO先进先出、LRU最近最久未使用、LFU最近最少使用
FIFO
FIFO先进先出,是最简单的一种算法,最先进入的数据,将最先被淘汰,同队列的机制一样。
LRU
LRU 最近最久未使用,在很多分布式缓存系统(如Redis, Memcached)中都有广泛使用。LRU(Least recently used,最近最久未使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是如果数据最近时间被访问过,那么将来被访问的几率也更高。常用链表保存缓存数据,算法详细实现:
1)新数据会被插入到链表头部
2)缓存命中时,会将数据移到链表头部
3)当链表满的时候,链表尾部数据将会被丢弃
缓存污染问题
当存在热点数据时,LRU算法很好,但偶发性的、周期性的批量操作,会导致LRU命中率急剧下降,缓存污染情况也比较严重。
实现较简单,命中时需要遍历链表,找到命中的数据块索引,然后将数据移动到头部。
备注:【缓存污染指将不常用的数据移动到缓存中,降低缓存效率的现象】
LRU-K算法
K代表最近使用的次数,为解决LRU算法中缓存污染问题,将判断标准改为最近使用过K次(LRU相当于LRU-1),需要多维护一个队列,记录所有缓存被访问的历史,当访问次数达到K的时候,才将数据放到缓存;有限淘汰第K次访问时间距离当前时间最大的数据
算法实现:
1)数据第一次被访问时,加入到访问历史列表
2)数据在历史列表里没有达到K次访问,按一定规则淘汰,比如FIFO(先进先出),LRU(最近最少使用)
3)当历史队列数据访问达到K次后,将数据迁移到缓存队列,并从历史队列移除,
4)缓存数据被再次访问后,重新排序
5)缓存队列,优先淘汰末尾的数据,即倒数第K次访问距离限制最久的数据,
LRU-K具有LRU的优点,同时避免了LRU的缺点:缓存污染。
实际应用中LRU-2是综合各种因素后的最优选择,更大K值命中率虽然高,但适应性差,需要大量数据访问才能清楚历史访问记录。
LRU-K 降低了缓存污染的问题,命中率比LRU高;
LRU-K队列是一个优先级队列,算法复杂度和代价比较高;
需要额外记录被访问过还没缓存的数据,内存消耗要高一些,尤其数据量较大的情况;另外LRU-K需要基于时间进行排序,CPU消耗也会更高一些。
LFU
Least Frequently Used 最近最少使用
与LRU算法相比,从时间上来说,首先淘汰最长时间未被使用的数据
LFU则是淘汰近一段时间内被访问次数最少的数据
例如:一段时间T,主存块为3,若数据走向为1 2 1 2 1 3 4,当数据4过来时,主存块满触发淘汰策略,
按LRU算法,数据2被淘汰(数据2最久未被使用)
按LFU算法应换数据3(十分钟内,数据3只访问了一次)
所以LRU关键是看最后一次被使用到发生调度的时间长短,
而LFU关键是看一定时间段被使用的频率