关于后端:本地缓存无冕之王Caffeine-Cache

2次阅读

共计 12801 个字符,预计需要花费 33 分钟才能阅读完成。

本文已收录至 GitHub,举荐浏览 👉 Java 随想录

微信公众号:Java 随想录

原创不易,重视版权。转载请注明原作者和原文链接

在惯例的软件开发流程中,缓存的重要性日益凸显。它不仅为用户带来了更迅速的反馈工夫,还能在大多数状况下无效加重零碎负荷。

本篇文章将详述一个本地缓存框架:「Caffeine Cache」。

Caffeine Cache 以其高性能和可扩展性博得「本地缓存之王」的名称,它是一个 Java 缓存库。它的设计指标是优化计算速度、内存效率和实用性,以合乎古代软件开发者的需要。

Spring Boot 1.x 版本中的默认本地缓存是 Guava Cache。在 Spring5(SpringBoot 2.x)后,Spring 官网放弃了 Guava Cache 作为缓存机制,而是使用性能更优良的 Caffeine 作为默认缓存组件,这对于 Caffeine 来说是一个很大的必定。

接下来,咱们会具体介绍 Caffeine Cache 的个性和利用,并将这个高效的缓存工具无缝集成到你的我的项目中。

淘汰算法

在解析 Caffeine Cache 之前,咱们首先要了解缓存淘汰算法。一个优良的淘汰算法能使效率大幅晋升,因为缓存过程总会随同着数据的淘汰。

  • FIFO(First In First Out)先进先出:

以时序为基准,先进入缓存的数据会被先淘汰。当缓存满时,把最早放入缓存的数据淘汰掉。

长处:实现简略,对于某些不常反复申请的利用成果较好。

毛病:并未思考到数据项的拜访频率和拜访工夫,可能淘汰的是最近和频繁拜访的数据。

  • LRU(Least Recently Used)最近最久未应用:

此算法依据数据的历史拜访记录来进行决策,最久未被拜访的数据将被淘汰。LRU 通过保护一个所有缓存项的链表,新数据插入到链表的头部,如果缓存满了,就会从链表尾部开始移除数据。

长处:LRU 思考了最近的数据拜访模式,对于局部性原理的体现优良,简略实用。

毛病:不能体现数据的拜访频率,如果数据最近被拜访过,即便拜访频度低也不会被淘汰。比方,如果一个数据在一分钟的前 59 秒被频繁拜访,而在最初一秒无任何拜访,然而有一批冷门数据在最初一秒进入缓存,那么热点数据可能会被淘汰掉。

  • LFU(Least Frequently Used)最近起码频率应用:

其基本原理是对每个在缓存中的对象进行计数,记录其被拜访的次数。当缓存满了须要淘汰某些对象时,LFU 算法会优先淘汰那些被拜访次数起码的对象。

长处:LFU 可能较好地解决长期拜访稳固、频率较高的状况,因为这样能够确保频繁拜访的对象不容易被淘汰。

毛病:对于一些临时高频拜访但之后不再拜访的对象,LFU 无奈无效解决。因为这些对象的拜访次数曾经十分高,之后即便不再拜访,也不容易被淘汰,可能造成缓存空间的节约。并且 LFU 须要保护所有对象的拜访计数,这可能会耗费比拟多的存储空间和计算资源。

  • W-TinyLFU(Window Tiny Least Frequently Used):

Caffeine 应用的就是 Window TinyLfu 淘汰策略,此策略提供了一个近乎最佳的命中率。

看名字就能大略猜出来,它是 LFU 的变种,它在面临缓存换页(即缓存空间有余而须要替换旧缓存项)问题时,通过统计频率信息来抉择最佳的候选项进行替换。

工作原理:

  1. 频率滤波:W-TinyLFU 应用一个小型的滑动窗口记录最近拜访过的对象,以捕捉对象的应用频率。这个窗口内的数据被插入到一个 LFU 计数器中,该计数器基于频率革除起码应用的对象。
  2. 突发性适应:W-TinyLFU 还蕴含一个 Admission Window,用于对新增加到缓存的项进行跟踪,以便可能解决忽然呈现的新热点数据。
  3. 替换策略:当缓存满了,且有新的元素须要退出时,W-TinyLFU 应用频率信息抉择起码应用的条目进行替换。如果新条目标应用频率较高,那么将替换掉应用频率较低的老条目;如果新项的应用频率较低,则可能会被回绝。

相较于传统的 LRU 和 LFU 策略,W-TinyLFU 具备以下长处:

  1. 均衡了最近性和频率:与 LRU 相比,W-TinyLFU 不仅思考了最近应用的状况,还计算了缓存的热门水平。与 LFU 相比,它不会让长时间以前十分热门但当初很少应用的数据占据大量的空间。
  2. 计数器限度:TinyLFU 应用一个固定大小的计数滤波器来跟踪拜访频率,这使得其内存占用远低于传统的 LFU 策略。
  3. 适应性强:W-TinyLFU 能够更好地适应工作负载的变动,因为它对频率的计数有一个工夫窗口。这使得 W-TinyLFU 可能防止过多地器重晚期的拜访模式,并能更快地适应最近的拜访模式。
  4. 防止缓存净化:因为它保护了一个 admission window,它能够防止一次性的、大规模的申请可能带来的缓存净化。

毛病:

  1. 须要保护额定的频率信息,减少了一些开销。
  2. 不如 LRU 算法实现简略。对于不同的应用场景,须要调整参数以获得最佳性能。

总的来说,W-TinyLFU 是一个复杂性高、灵活性强的缓存算法,对于辨认和解决长期和突发的热数据体现良好,但相比于更简略的算法如 LRU,它须要更多的资源和精密的配置。

Cache 类型

Caffeine 共提供了四种类型的 Cache,对应着四种加载策略。

Cache

最一般的一种缓存,无需指定加载形式,须要手动调用 put() 进行加载。须要留神的是,put()办法对于已存在的 key 将进行笼罩。

在获取缓存值时,如果想要在缓存值不存在时,原子地将值写入缓存,则能够调用 get(key, k -> value) 办法,该办法将防止写入竞争。

多线程状况下,当应用 get(key, k -> value) 时,如果有另一个线程同时调用本办法进行竞争,则后一线程会被阻塞,直到前一线程更新缓存实现;而若另一线程调用 getIfPresent() 办法,则会立刻返回 null,不会被阻塞。

Cache<String, String> cache = Caffeine.newBuilder().build();

cache.getIfPresent("1");    // null
cache.get("1", k -> 1);    // 1
cache.getIfPresent("1");   //1
cache.set("1", "2");
cache.getIfPresent("1");   //2

Loading Cache

LoadingCache 是一种主动加载的缓存。其和一般缓存不同的中央在于,当缓存不存在或已过期时,若调用 get()办法,则会主动调用 CacheLoader.load()办法加载最新值,调用 getAll()办法将遍历所有的 key 调用 get(),除非实现了 CacheLoader.loadAll()办法。

应用 LoadingCache 时,须要指定 CacheLoader,并实现其中的 load()办法供缓存缺失时主动加载。

多线程状况下,当两个线程同时调用get(),则后一线程将被阻塞,直至前一线程更新缓存实现。

LoadingCache<String, Object> cache = Caffeine.newBuilder().build(new CacheLoader<String, Object>() {
            @Override
            public @Nullable Object load(@NonNull String s) {return "从数据库读取";}

            @Override
            public @NonNull Map<@NonNull String, @NonNull Object> loadAll(@NonNull Iterable<? extends @NonNull String> keys) {return null;}
        });

cache.getIfPresent("1"); // null
cache.get("1");          // 从数据库读取
cache.getAll(keys);      // null

LoadingCache 特地实用,咱们能够在 load 办法里配置逻辑,缓存不存在的时候去数据库加载,能够实现多级缓存。

Async Cache

AsyncCache 是 Cache 的一个变体,其响应后果均为CompletableFuture

通过这种形式,AsyncCache 对异步编程进行了适配。默认状况下,缓存计算应用 ForkJoinPool.commonPool() 作为线程池,如果想要指定线程池,则能够笼罩并实现 Caffeine.executor(Executor) 办法。

多线程状况下,当两个线程同时调用 get(key, k -> value),则会返回「同一个 CompletableFuture 对象」。因为返回后果自身不进行阻塞,能够依据业务设计自行抉择阻塞期待或者非阻塞。

import com.github.benmanes.caffeine.cache.AsyncCache;
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

public class CaffeineExample {private final Executor executor = Executors.newSingleThreadExecutor();

    CacheLoader<String, String> loader = new CacheLoader<>() {
        @Override
        public String load(String key) throws Exception {
            // 模仿数据加载过程
            Thread.sleep(1000);
            return key.toUpperCase();}

        @Override
        public CompletableFuture<String> asyncLoad(String key, Executor executor) {return CompletableFuture.supplyAsync(() -> {
                try {return load(key);
                } catch (Exception e) {throw new RuntimeException(e);
                }
            }, executor);
        }
    };

    AsyncCache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .executor(executor)
            .buildAsync(loader);

    public void test() throws Exception {
        // 异步获取
        CompletableFuture<String> future = cache.get("hello");
        System.out.println(future.get());  // 输入 HELLO
    }

    public static void main(String[] args) throws Exception {new CaffeineExample().test();}
}

Async Loading Cache

看名字就晓得,显然这是 Loading Cache 和 Async Cache 的性能组合。Async Loading Cache 反对以异步的形式,对缓存进行主动加载

以下是如何创立一个 Async Loading Cache 的缓存示例:

import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
import com.github.benmanes.caffeine.cache.Caffeine;

// 创立 AsyncLoadingCache
AsyncLoadingCache<String, Data> cache = Caffeine.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .buildAsync(this::loadData);

// 定义加载办法
private CompletableFuture<Data> loadData(String key) {
    // 这只是一个示例,能够依据理论状况来执行数据加载操作
    return CompletableFuture.supplyAsync(() -> {
        // 从数据库或其余中央加载数据
        Data data = ... ;
        return data;
    });
}

// 应用缓存
public void useCache(String key) {cache.get(key).thenAccept(data -> {
        // 在这里,data 是从 loadData 办法返回的对象
        // 能够对 data 进行解决
       ...
    });
}

Async Loading Cache 也特地实用,有些业务场景咱们 Load 数据的工夫会比拟长,这时候就能够应用 Async Loading Cache,防止 Load 数据阻塞。

驱赶策略

Caffeine 提供了 3 种回收策略:基于大小回收,基于工夫回收,基于援用回收。

基于大小的过期形式

基于大小的回收策略有两种形式:一种是基于缓存大小,一种是基于权重。

// 依据缓存的计数进行驱赶
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10000)
    .build(new CacheLoader<String, Object>() {

            @Override
            public @Nullable Object load(@NonNull String s) {return "从数据库读取";}

            @Override
            public @NonNull Map<@NonNull String, @NonNull Object> loadAll(@NonNull Iterable<? extends @NonNull String> keys) {return null;}
        });


// 依据缓存的权重来进行驱赶,权重低的会被优先驱赶
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
    .maximumWeight(10000)
    .weigher(new Weigher<String, Object>() {
                    @Override
                    public @NonNegative int weigh(@NonNull String s, @NonNull Object o) {return 0;}
                })
    .build(new CacheLoader<String, Object>() {
            @Override
            public @Nullable Object load(@NonNull String s) {return "从数据库读取";}

            @Override
            public @NonNull Map<@NonNull String, @NonNull Object> loadAll(@NonNull Iterable<? extends @NonNull String> keys) {return null;}
        });

留神:maximumWeight 与 maximumSize 不能够同时应用,这是因为它们都是用来限度缓存大小的机制。二者之间须要做出抉择。

基于工夫的过期形式

// 基于不同的到期策略进行退出
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .expireAfter(new Expiry<String, Object>() {
        @Override
        public long expireAfterCreate(String key, Object value, long currentTime) {return TimeUnit.SECONDS.toNanos(seconds);
        }

        @Override
        public long expireAfterUpdate(@Nonnull String s, @Nonnull Object o, long l, long l1) {return 0;}

        @Override
        public long expireAfterRead(@Nonnull String s, @Nonnull Object o, long l, long l1) {return 0;}
    }).build(key -> function(key));

Caffeine 提供了三种定时驱赶策略:

  • expireAfterAccess(long, TimeUnit):在最初一次拜访或者写入后开始计时,在指定的工夫后过期。如果始终有申请拜访该 key,那么这个缓存将始终不会过期。
  • expireAfterWrite(long, TimeUnit):在最初一次写入缓存后开始计时,在指定的工夫后过期。
  • expireAfter(Expiry):自定义策略,过期工夫由 Expiry 实现单独计算。

基于援用的过期形式

Java 中四种援用类型

援用类型 被垃圾回收工夫 用处 生存工夫
强援用 Strong Reference 从来不会 对象的个别状态 JVM 进行运行时终止
软援用 Soft Reference 在内存不足时 对象缓存 内存不足时终止
弱援用 Weak Reference 在垃圾回收时 对象缓存 gc 运行后终止
虚援用 Phantom Reference 从来不会 能够用虚援用来跟踪对象被垃圾回收器回收的流动,当一个虚援用关联的对象被垃圾收集器回收之前会收到一条零碎告诉 JVM 进行运行时终止
// 当 key 和 value 都没有援用时驱赶缓存
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> function(key));

// 当垃圾收集器须要开释内存时驱赶
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
    .softValues()
    .build(key -> function(key));

留神:AsyncLoadingCache 不反对弱援用和软援用,并且 Caffeine.weakValues()和 Caffeine.softValues()不能够一起应用。

写入内部存储

CacheWriter 办法能够将缓存中所有的数据写入到第三方,防止数据在服务重启的时候失落。

LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .writer(new CacheWriter<String, Object>() {@Override public void write(String key, Object value) {// 写入到内部存储}
        @Override public void delete(String key, Object value, RemovalCause cause) {// 删除内部存储}
    })
    .build(key -> function(key));

统计

Caffeine 内置了数据收集性能,通过 Caffeine.recordStats() 办法,能够关上数据收集。这样 Cache.stats() 办法将会返回以后缓存的一些统计指标,例如:

  • hitRate:查问缓存的命中率。
  • evictionCount:被驱赶的缓存数量。
  • averageLoadPenalty:新值被载入的均匀耗时。
Cache<String, String> cache = Caffeine.newBuilder().recordStats().build();
cache.stats(); // 获取统计指标

SpringBoot 集成 Caffeine Cache

在 Caffeine Cache 的介绍完结后,接下来介绍如何在我的项目中顺利集成 Caffeine Cache。

话不多说,间接开始上手实际吧。

首先在 pom.xml 文件中增加 Spring Boot Starter Cache 和 Caffeine 的 Maven 依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
        <dependency>
             <groupId>com.github.ben-manes.caffeine</groupId>
             <artifactId>caffeine</artifactId>
             <version>3.1.1</version>
        </dependency>
</dependencies>

其次,创立一个配置类,并创立一个 CacheManager Bean:

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class CachingConfig {

    @Bean
    public CacheManager cacheManager() {CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(caffeineCacheBuilder());
        return cacheManager;
    }

    Caffeine<Object, Object> caffeineCacheBuilder() {return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(500)
                .expireAfterAccess(10, TimeUnit.MINUTES)
                .weakKeys()
                .recordStats();}
}

Caffeine 类应用了建造者模式,次要有如下配置参数:

  • initialCapacity:缓存初始容量。
  • maximumSize:设置缓存的最大条目数。当缓存达到这个大小时,它会开始进行革除。
  • maximumWeight:设置缓存的最大权重。须要同时定义一个 Weigher<K,V> 来如何计算缓存条目标权重。
  • weigher:定义了如何计算每个缓存条目标权重。
  • expireAfterAccess:设置在特定时间段后拜访缓存项后,会使其过期。
  • expireAfterWrite:设置在特定时间段后写入(或批改)缓存项后,会使其过期。
  • 此办法定义了写入缓存项后的特定时间段,之后该缓存项将被异步刷新。
  • refreshAfterWrite:此办法定义了写入缓存项后的特定时间段,之后该缓存项将被刷新。
  • weakKeys:设置缓存 key 为弱援用,在 GC 时能够间接淘汰。
  • weakValues:设置 value 为弱援用,在 GC 时能够间接淘汰。
  • softValues:设置缓存 value 为软援用,在内存溢出前能够间接淘汰。
  • recordStats:启用缓存的统计数据,比方命中率等。
  • removalListener:设置缓存淘汰监听器。当缓存中的某个条目被移除时会被调用。能够用来记录日志、触发某个操作。

除了配置 Bean 的形式,还能够用配置文件的形式:

spring:
  cache:
    type: caffeine
    cache-names:
    - userCache
    caffeine:
      spec: maximumSize=1024,refreshAfterWrite=60s

配置结束之后,就能够间接配合注解进行应用了:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class SomeService {

    @Autowired
    private SomeRepository someRepository;

    @Cacheable("items")
    public Item getItem(int id) {return someRepository.findById(id);
    }
}

在这个例子中,咱们在 getItem 办法上增加了 @Cacheable 注解,每次调用该办法时,Spring 首先查找名 item 的 cache 中是否有对应 id 的条目。如果有,就返回缓存的值,否则调用办法并将后果存入 cache。

注解应用形式

注解应用形式,相干的罕用注解包含:

  • @Cacheable:示意该办法反对缓存。当调用被注解的办法时,如果对应的键曾经存在缓存,则不再执行办法体,而从缓存中间接返回。当办法返回 null 时,将不进行缓存操作。
  • @CachePut:示意执行该办法后,其值将作为最新后果更新到缓存中。每次都会执行该办法
  • @CacheEvict:示意执行该办法后,将触发缓存革除操作。
  • @Caching:用于组合前三个注解,例如:
@Caching(cacheable = @Cacheable("users"),
         evict = {@CacheEvict("cache2"), @CacheEvict(value = "cache3", allEntries = true)})
public User find(Integer id) {return null;}

这类注解也能够应用在类上,示意该类的所有办法都反对对应的缓存注解。

其中,最罕用的是 @Cacheable,@Cacheable 注解罕用的属性如下:

  • cacheNames/value:缓存组件的名字,即 cacheManager 中缓存的名称。
  • key:缓存数据时应用的 key。默认应用办法参数值,也能够应用 SpEL 表达式进行编写。
  • keyGenerator:和 key 二选一应用。
  • cacheManager:指定应用的缓存管理器。
  • condition:在办法执行开始前查看,在合乎 condition 的状况下,进行缓存。
  • unless:在办法执行实现后查看,在合乎 unless 的状况下,不进行缓存。
  • sync:是否应用同步模式。若应用同步模式,在多个线程同时对一个 key 进行 load 时,其余线程将被阻塞。

Spring Cache 还反对 Spring Expression Language (SpEL) 表达式。你能够通过 SpEL 在缓存名称或键中插入动静值。

举几个例子:

@Cacheable(value = "books", key = "#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

它将应用传递给 findBook 办法的 isbn 参数的值。

你也能够用更简单的 SpEL 表达式,例如:

@Cacheable(value = "books", key = "#root.args[0]")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

#root.args[0] 指的是办法调用的第一个参数,也就是 isbn

还有蕴含条件表达式的例子:

@Cacheable(value = "books", key = "#isbn", condition = "#checkWarehouse == true")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

只有当 checkWarehouse 参数为 true 时,才会利用缓存。

缓存同步模式

@Cacheable默认的行为模式是不同步的。这意味着如果你在多线程环境中应用它,并且有两个或更多的线程同时申请雷同的数据,那么可能会呈现缓存击穿的状况。也就是说,所有申请都会达到数据库,因为在第一个申请填充缓存之前,其余所有申请都不会发现缓存项。

Spring 4.1 引入了一个新属性 sync 来解决这个问题。如果设置@Cacheable(sync=true),则只有一个线程将执行该办法并将后果增加到缓存,其余线程将期待。

以下是代码示例:

@Service
public class BookService {@Cacheable(cacheNames="books", sync=true)
    public Book findBook(ISBN isbn) {// 这里是一些查找书籍的慢速办法,如数据库查问,API 调用等}
}

在这个例子中,无论有多少线程尝试应用雷同的 ISBN 查找雷同的书,只有一个线程会理论执行 findBook 办法并将后果存储在名为 ”books” 的缓存中。其余线程将期待,而后从缓存中获取后果,而不须要执行 findBook 办法。

在不同的 Caffeine 配置下,同步模式体现不同:

Caffeine 缓存类型 是否开启同步 多线程读取不存在 / 已驱赶的 key 多线程读取待刷新的 key
Cache 各自独立执行被注解办法。
Cache 线程 1 执行被注解办法,线程 2 被阻塞,直至缓存更新实现。
LoadingCache 线程 1 执行load(),线程 2 被阻塞,直至缓存更新实现。 线程 1 应用老值立刻返回,并异步更新缓存值;线程 2 立刻返回,不进行更新。
LoadingCache 线程 1 执行被注解办法,线程 2 被阻塞,直至缓存更新实现 线程 1 应用老值立刻返回,并异步更新缓存值;线程 2 立刻返回,不进行更新。

本篇文章的内容至此告一段落,最初做个小总结,心愿这篇文章可能给你带来播种和思考。

在这篇文章中,咱们深入探讨了 Caffeine Cache 以及其淘汰算法的外部工作原理。咱们还具体介绍了如何在 SpringBoot 应用程序中集成 Caffeine Cache。心愿读者通过本文能深刻了解 Caffeine Cache 的劣势并在实践中无效利用。

总的来说,Caffeine Cache 不仅提供了弱小的缓存性能,还有一个高效的淘汰策略。这使得它在解决大量数据或高并发申请时成为十分好的抉择。而且,因为其与 SpringBoot 的良好兼容性,你能够方便快捷地在 SpringBoot 我的项目中应用它。

最初,咱们须要记住,尽管 Caffeine Cache 是一个弱小的工具,但正确无效的应用的确须要对其淘汰算法有深刻的了解。因而,倡议继续关注并钻研这个畛域的最新进展,以便更好地利用 Caffeine Cache 晋升你的利用性能。


感激浏览,如果本篇文章有任何谬误和倡议,欢送给我留言斧正。

老铁们,关注我的微信公众号「Java 随想录」,专一分享 Java 技术干货,文章继续更新,能够关注公众号第一工夫浏览。

一起交流学习,期待与你共同进步!

正文完
 0