引言

缓存是存储在内存中的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关键是看一定时间段被使用的频率