使用 Caffeine Cache 做本地缓存

3,710 阅读5分钟

什么是Caffeine Cache

  • Caffeine Cache 是一款 JAVA 本地缓存中间件
  • Caffeine Cache 可以通过自定义的配置,自动移除“不常用”的数据,以保持内存的合理占用。
  • Caffeine Cache 另一个特点是,使用了Window TinyLfu 回收策略,提供了一个近乎最佳的命中率, 该策略优于 LFU、LRU 等算法。
  • 从 Spring 2.0 开始,Spring 使用 Caffeine Cache 替代了 Guava Cache。

不同的缓存淘汰算法

  • FIFO:先进先出,在这种淘汰算法中,先进入缓存的会先被淘汰,会导致命中率很低。
  • LRU:最近最少使用算法,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。
  • LFU:最近最少频率使用,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。

LRU\LFU 算法的局限性

  • LRU 可以很好的应对突发流量的情况,因为他不需要累计数据频率。但LRU通过历史数据来预测未来是局限的,它会认为最后到来的数据是最可能被再次访问的,从而给与它最高的优先级。
  • LFU 算法局限性 : 假设我们缓存了一个热点新闻,他在初始几天内点击率非常高,在缓存中的命中率也非常高。但一周后热度过去了,该新闻也无人点击访问了,可以由于前期的点击率访问量太高,导致使用 LFU 算法无法将其移除缓存。

TinyLFU 算法

TinyLFU维护了近期访问记录的频率信息,作为一个过滤器,当新记录来时,只有满足TinyLFU要求的记录才可以被插入缓存。

如前所述,作为现代的缓存,它需要解决两个挑战:

  • 如何避免维护频率信息的高开销。
    • 使用 (Count–Min Sketch)算法解决。
  • 如何反应随时间变化的访问模式。
    • 解决这个问题是让记录尽量保持相对的“新鲜”(Freshness Mechanism),并且当有新的记录插入时,可以让它跟老的记录进行“PK”,输者就会被淘汰,这样一些老的、不再需要的记录就会被剔除。

统计频率 Count–Min Sketch 算法

Caffeine 对这个算法的实现在 FrequencySketch 类。Caffeine 使用了一个一维的数组;如果是数值类型的话,这个数需要用 int 或 long 来存储,但是 Caffeine 认为缓存的访问频率不需要用到那么大,只需要 15 就足够,一般认为达到 15 次的频率算是很高的了,而且 Caffeine 还有另外一个机制来使得这个频率进行衰退减半。如果最大是 15 的话,那么只需要 4 个 bit 就可以满足了,一个 long 有 64bit,可以存储 16 个这样的统计数,使得存储效率提高了 16 倍.

保新机制

为了让缓存保持“新鲜”,剔除掉过往频率很高但之后不经常的缓存,Caffeine 有一个新鲜度机制。就是当整体的统计计数达到某一个值时,那么所有记录的频率统计除以 2。

//size变量就是所有记录的频率统计之,即每个记录加1,这个size都会加1
//sampleSize一个阈值,从FrequencySketch初始化可以看到它的值为maximumSize的10倍
if (added && (++size == sampleSize)) {
 reset();
}

void reset() {
 int count = 0;
 for (int i = 0; i < table.length; i++) {
 count += Long.bitCount(table[i] & ONE_MASK);
 table[i] = (table[i] >>> 1) & RESET_MASK;
 }
 size = (size >>> 1) - (count >>> 2);
}

使用方式

基础用法

    /**
     * 基础用法
     */
    @Test
    public void basic() throws InterruptedException {
        Cache<String, Object> cache = Caffeine.newBuilder()
                .initialCapacity(100)//初始大小
                .maximumSize(300)//最大数量
                .expireAfterWrite(3, TimeUnit.SECONDS)//数据过期时间
                .build();

        cache.put("testKey", "testValue");
        System.out.println("getIfPresent -- " + cache.getIfPresent("testKey"));
        TimeUnit.SECONDS.sleep(4);
        System.out.println("getIfPresent -- " + cache.getIfPresent("testKey"));
    }

输出结果 image.png

监听缓存过期事件

    /**
     * 监听缓存过期事件
     */
    @Test
    public void removalListener() throws InterruptedException {
        Cache<String, Object> cache = Caffeine.newBuilder()
                .initialCapacity(1)//初始大小
                .maximumSize(3)//最大数量
                .expireAfterWrite(3, TimeUnit.SECONDS)//数据过期时间
                .removalListener((k, v, c) -> {
                    System.out.println("Remove:" + k);
                })
                .build();

        cache.put("testKey", "testValue");
        System.out.println("getIfPresent -- " + cache.getIfPresent("testKey"));
        for (int i = 0; i < 5; i++) {
            cache.put("testKey - " + i, "testValue - " + i);
            TimeUnit.MILLISECONDS.sleep(1000);
        }
        System.out.println("getIfPresent -- " + cache.getIfPresent("testKey"));
    }

输出结果 image.png

根据操作事件,自定义缓存过期时间

    public class MyExpireAfter implements Expiry<String, Object> {

        @Override
        public long expireAfterCreate(@NonNull String key, @NonNull Object value, long currentTime) {
            System.out.println(key + " -- expireAfterCreate");
            return 100000 * 1000 * 3;
        }

        @Override
        public long expireAfterUpdate(@NonNull String key, @NonNull Object value, long currentTime, @NonNegative long currentDuration) {
            System.out.println(key + " -- expireAfterUpdate");
            return 100000 * 1000 * 3;
        }

        @Override
        public long expireAfterRead(@NonNull String key, @NonNull Object value, long currentTime, @NonNegative long currentDuration) {
            System.out.println(key + " -- expireAfterRead");
            return 100000 * 1000 * 3;
        }
    }
    

   /**
     * 根据操作事件,自定义缓存过期时间
     */
    @Test
    public void expireAfter() {
        Cache<String, Object> cache = Caffeine.newBuilder().initialCapacity(1)//初始大小
                .maximumSize(3)//最大数量
                .expireAfter(new MyExpireAfter())
                .build();

        System.out.println("--开始测试写入数据");
        cache.put("testKey", "testValue");

        System.out.println("--开始测试读取数据");
        System.out.println("getIfPresent -- " + cache.getIfPresent("testKey"));


        System.out.println("--开始测试更新策略,连续写入10个相同的数据");
        for (int i = 0; i < 10; i++) {
            cache.put("testKey", "testValue");
        }

    }

输出内容 image.png

Caffine Cache 的其他配置

过期策略

Caffeine 为我们提供了三种过期策略 ,分别是:

  • 基于大小(size-based)
  • 基于时间(time-based)
  • 基于引用(reference-based)
    @Test
    public void expirationStrategy() {
        //基于大小(size-based)
        //超过容量后淘汰
        Caffeine.newBuilder()
                .maximumSize(3)
                .build();

        //基于时间(time-based)
        //在最后一次写入缓存后开始计时,在指定的时间后过期。
        Caffeine.newBuilder()
                .maximumSize(3)
                .expireAfterWrite(3, TimeUnit.SECONDS)
                .build();
        //在最后一次读或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该key,那么这个缓存将一直不会过期。
        Caffeine.newBuilder()
                .maximumSize(3)
                .expireAfterAccess(3, TimeUnit.SECONDS)
                .build();
        //在expireAfter中需要自己实现Expiry接口
        Caffeine.newBuilder()
                // 最大容量为1
                .maximumSize(1)
                .expireAfter(new Expiry<String, String>() {
                    @Override
                    public long expireAfterCreate(@NonNull String key, @NonNull String value, long currentTime) {
                        return currentTime;
                    }

                    @Override
                    public long expireAfterUpdate(@NonNull String key, @NonNull String value, long currentTime, @NonNegative long currentDuration) {
                        return currentTime;
                    }
                    
                    @Override
                    public long expireAfterRead(@NonNull String key, @NonNull String value, long currentTime, @NonNegative long currentDuration) {
                        return currentTime;
                    }
                })
                .build();

        // 基于引用(reference-based)
        // 当key和value都没有引用时驱逐缓存
        Caffeine.newBuilder()
                .weakKeys()
                .weakValues()
                .build();
        // 当垃圾收集器需要释放内存时驱逐
        Caffeine.newBuilder()
                .softValues()
                .build();
    }

删除策略

  • 单个删除:Cache.invalidate(key)
  • 批量删除:Cache.invalidateAll(keys)
  • 删除所有缓存项:Cache.invalidateAll

这是一些常用配置和写法,还有一些更高级的功能,例如:基于注解的缓存配置、异步加载缓存、缓存写入外部存储、统计数据等,会整理后再写一期文章发布。