·  阅读 37909

如图,Caffeine是当前最优秀的内存缓存框架,不论读还是写的效率都远高于其他缓存,而且在Spring5开始的默认缓存实现就将Caffeine代替原来的Google Guava

<!-- https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.0.3</version>
</dependency>

手动创建缓存

        Cache<Object, Object> cache = Caffeine.newBuilder()
                //初始数量
                .initialCapacity(10)
                //最大条数
                .maximumSize(10)
                //PS:expireAfterWrite和expireAfterAccess同时存在时,以expireAfterWrite为准。
                //最后一次写操作后经过指定时间过期
                .expireAfterWrite(1, TimeUnit.SECONDS)
                //最后一次读或写操作后经过指定时间过期
                .expireAfterAccess(1, TimeUnit.SECONDS)
                //监听缓存被移除
                .removalListener((key, val, removalCause) -> { })
                //记录命中
                .recordStats()
                .build();
        cache.put("1","张三");
        System.out.println(cache.getIfPresent("1"));
        System.out.println(cache.get("2",o -> "默认值"));

自动创建缓存

LoadingCache<String, String> loadingCache = Caffeine.newBuilder()
        //创建缓存或者最近一次更新缓存后经过指定时间间隔,刷新缓存;refreshAfterWrite仅支持LoadingCache
        .refreshAfterWrite(10, TimeUnit.SECONDS)
        .expireAfterWrite(10, TimeUnit.SECONDS)
        .expireAfterAccess(10, TimeUnit.SECONDS)
        .maximumSize(10)
        //根据key查询数据库里面的值
        .build(key -> new Date().toString());

异步获取缓存

关于JDK8 CompletableFuture 说明

AsyncLoadingCache<String, String> asyncLoadingCache = Caffeine.newBuilder()
        //创建缓存或者最近一次更新缓存后经过指定时间间隔刷新缓存;仅支持LoadingCache
        .refreshAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterAccess(1, TimeUnit.SECONDS)
        .maximumSize(10)
        //根据key查询数据库里面的值
        .buildAsync(key -> {
            Thread.sleep(1000);
            return new Date().toString();
//异步缓存返回的是CompletableFuture
CompletableFuture<String> future = asyncLoadingCache.get("1");
future.thenAccept(System.out::println);

PS:可以使用.executor()自定义线程池

记录命中数据

LoadingCache<String, String> cache = Caffeine.newBuilder()
        //创建缓存或者最近一次更新缓存后经过指定时间间隔,刷新缓存;refreshAfterWrite仅支持LoadingCache
        .refreshAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterAccess(1, TimeUnit.SECONDS)
        .maximumSize(10)
        //开启记录缓存命中率等信息
        .recordStats()
        //根据key查询数据库里面的值
        .build(key -> {
            Thread.sleep(1000);
            return new Date().toString();
cache.put("1", "小明");
cache.get("1");
 * hitCount :命中的次数
 * missCount:未命中次数
 * requestCount:请求次数
 * hitRate:命中率
 * missRate:丢失率
 * loadSuccessCount:成功加载新值的次数
 * loadExceptionCount:失败加载新值的次数
 * totalLoadCount:总条数
 * loadExceptionRate:失败加载新值的比率
 * totalLoadTime:全部加载时间
 * evictionCount:丢失的条数
System.out.println(cache.stats());

PS:会影响性能,生产环境下建议不开启

先了解一下常见的淘汰策略

  • LRU 最近最少使用,淘汰最长时间没有被使用的页面。
  • LRU 最不经常使用,淘汰一段时间内,使用次数最少的页面
  • FIFO 先进先出
  • LRU的优点:LRU相比于 LFU 而言性能更好一些,因为它算法相对比较简单,不需要记录访问频次,可以更好的应对突发流量。

    LRU的缺点:虽然性能好一些,但是它通过历史数据来预测未来是局限的,它会认为最后到来的数据是最可能被再次访问的,从而给与它最高的优先级。有些非热点数据被访问过后,占据了高优先级,它会在缓存中占据相当长的时间,从而造成空间浪费。

    LFU的优点:LFU根据访问频次访问,在大部分情况下,热点数据的频次肯定高于非热点数据,所以它的命中率非常高。

    LFU的缺点:LFU 算法相对比较复杂,性能比 LRU 差。有问题的是下面这种情况,比如前一段时间微博有个热点话题热度非常高,就比如那种可以让微博短时间停止服务的,于是赶紧缓存起来,LFU 算法记录了其中热点词的访问频率,可能高达十几亿,而过后很长一段时间,这个话题已经不是热点了,新的热点也来了,但是,新热点话题的热度没办法到达十几亿,也就是说访问频次没有之前的话题高,那之前的热点就会一直占据着缓存空间,长时间无法被剔除。

    而Caffeine 采用W-TinyLFU淘汰算法,结合LRU与LFU达到更佳的命中率与性能,具体参考: www.cnblogs.com/zhaoxinshan…

    4种淘汰方式与例子

    Caffeine有4种缓存淘汰设置

  • 大小 (会使用上面说到的W-TinyLFU算法进行淘汰)
  • 权重 (大小与权重 只能二选一)
  • 引用 (不常用,本文不介绍)
  • import com.github.benmanes.caffeine.cache.Cache;
    import com.github.benmanes.caffeine.cache.Caffeine;
    import com.github.benmanes.caffeine.cache.Scheduler;
    import com.github.benmanes.caffeine.cache.Weigher;
    import lombok.extern.slf4j.Slf4j;
    import org.junit.Test;
    import java.util.concurrent.TimeUnit;
     * @author yejunxi
     * @date 2021/7/23
    @Slf4j
    public class CacheTest {
         * 缓存大小淘汰
        @Test
        public void maximumSizeTest() throws InterruptedException {
            Cache<Integer, Integer> cache = Caffeine.newBuilder()
                    //超过10个后会使用W-TinyLFU算法进行淘汰
                    .maximumSize(10)
                    .evictionListener((key, val, removalCause) -> {
                        log.info("淘汰缓存:key:{} val:{}", key, val);
                    .build();
            for (int i = 1; i < 20; i++) {
                cache.put(i, i);
            Thread.sleep(500);//缓存淘汰是异步的
            // 打印还没被淘汰的缓存
            System.out.println(cache.asMap());
         * 权重淘汰
        @Test
        public void maximumWeightTest() throws InterruptedException {
            Cache<Integer, Integer> cache = Caffeine.newBuilder()
                    //限制总权重,若所有缓存的权重加起来>总权重就会淘汰权重小的缓存
                    .maximumWeight(100)
                    .weigher((Weigher<Integer, Integer>) (key, value) -> key)
                    .evictionListener((key, val, removalCause) -> {
                        log.info("淘汰缓存:key:{} val:{}", key, val);
                    .build();
            //总权重其实是=所有缓存的权重加起来
            int maximumWeight = 0;
            for (int i = 1; i < 20; i++) {
                cache.put(i, i);
                maximumWeight += i;
            System.out.println("总权重=" + maximumWeight);
            Thread.sleep(500);//缓存淘汰是异步的
            // 打印还没被淘汰的缓存
            System.out.println(cache.asMap());
         * 访问后到期(每次访问都会重置时间,也就是说如果一直被访问就不会被淘汰)
        @Test
        public void expireAfterAccessTest() throws InterruptedException {
            Cache<Integer, Integer> cache = Caffeine.newBuilder()
                    .expireAfterAccess(1, TimeUnit.SECONDS)
                    //可以指定调度程序来及时删除过期缓存项,而不是等待Caffeine触发定期维护
                    //若不设置scheduler,则缓存会在下一次调用get的时候才会被动删除
                    .scheduler(Scheduler.systemScheduler())
                    .evictionListener((key, val, removalCause) -> {
                        log.info("淘汰缓存:key:{} val:{}", key, val);
                    .build();
            cache.put(1, 2);
            System.out.println(cache.getIfPresent(1));
            Thread.sleep(3000);
            System.out.println(cache.getIfPresent(1));//null
         * 写入后到期
        @Test
        public void expireAfterWriteTest() throws InterruptedException {
            Cache<Integer, Integer> cache = Caffeine.newBuilder()
                    .expireAfterWrite(1, TimeUnit.SECONDS)
                    //可以指定调度程序来及时删除过期缓存项,而不是等待Caffeine触发定期维护
                    //若不设置scheduler,则缓存会在下一次调用get的时候才会被动删除
                    .scheduler(Scheduler.systemScheduler())
                    .evictionListener((key, val, removalCause) -> {
                        log.info("淘汰缓存:key:{} val:{}", key, val);
                    .build();
            cache.put(1, 2);
            Thread.sleep(3000);
            System.out.println(cache.getIfPresent(1));//null
    

    另外还有一个refreshAfterWrite()表示x秒后自动刷新缓存可以配合以上的策略使用

    private static int NUM = 0;
    @Test
    public void refreshAfterWriteTest() throws InterruptedException {
        LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
                .refreshAfterWrite(1, TimeUnit.SECONDS)
                //模拟获取数据,每次获取就自增1
                .build(integer -> ++NUM);
        //获取ID=1的值,由于缓存里还没有,所以会自动放入缓存
        System.out.println(cache.get(1));// 1
        // 延迟2秒后,理论上自动刷新缓存后取到的值是2
        // 但其实不是,值还是1,因为refreshAfterWrite并不是设置了n秒后重新获取就会自动刷新
        // 而是x秒后&&第二次调用getIfPresent的时候才会被动刷新
        Thread.sleep(2000);
        System.out.println(cache.getIfPresent(1));// 1
        //此时才会刷新缓存,而第一次拿到的还是旧值
        System.out.println(cache.getIfPresent(1));// 2
    

    在实际开发中如何配置淘汰策略最优呢,根据我的经验常用的还是以大小淘汰为主

    配置:设置 maxSize、refreshAfterWrite,不设置 expireAfterWrite/expireAfterAccess

    优缺点:因为设置expireAfterWrite当缓存过期时会同步加锁获取缓存,所以设置expireAfterWrite时性能较好,但是某些时候会取旧数据,适合允许取到旧数据的场景

    配置:设置 maxSize、expireAfterWrite/expireAfterAccess,不设置 refreshAfterWrite

    优缺点:与上面相反,数据一致性好,不会获取到旧数据,但是性能没那么好(对比起来),适合获取数据时不耗时的场景

    与Srping Boot集成

    <!--缓存-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <!-- https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine -->
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
        <version>3.0.3</version>
    </dependency>
    

    配置方式有2种

  • yml 不推荐,因为淘汰策略是公用的,不可以给每一个缓存配置不一样的淘汰策略,此处不演示
  • 使用@Configuration
  • 此处演示第二种配置方式

  • 开启缓存 @EnableCaching
  • @Configuration
    public class CaffeineConfig {
        @Bean
        public CacheManager caffeineCacheManager() {
            List<CaffeineCache> caffeineCaches = new ArrayList<>();
            //可自行在yml或使用枚举设置多个缓存,不同名字的缓存的不同配置
            caffeineCaches.add(new CaffeineCache("cache1",
                    Caffeine.newBuilder()
                            .expireAfterWrite(10, TimeUnit.SECONDS)
                            .build())
            SimpleCacheManager cacheManager = new SimpleCacheManager();
            cacheManager.setCaches(caffeineCaches);
            return cacheManager;
    

    直接可以使用Spring 缓存注解,@Cacheable、@CacheEvict、@CachePut等,此处也不作详解

    @Service
    @Slf4j
    public class StudentService {
        @Cacheable(value = "cache1")
        public String getNameById(int id) {
            log.info("从DB获取数据:id=" + id);
            return new Date().toString();
    

    配合Redis做二级缓存

    缓存的解决方案一般有三种

  • 本地内存缓存,如Caffeine、Ehcache; 适合单机系统,速度最快,但是容量有限,而且重启系统后缓存丢失
  • 集中式缓存,如Redis、Memcached; 适合分布式系统,解决了容量、重启丢失缓存等问题,但是当访问量极大时,往往性能不是首要考虑的问题,而是带宽。现象就是 Redis 服务负载不高,但是由于机器网卡带宽跑满,导致数据读取非常慢
  • 第三种方案就是结合以上2种方案的二级缓存应运而生,以内存缓存作为一级缓存、集中式缓存作为二级缓存
  • 市面上已有成熟的框架,开源中国官方开源的工具:J2Cache

    大致原理就是这样

    配合spring boot使用参考:

    <!-- 可以不引入caffine,j2cache默认使用2.x版本 -->
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
        <version>3.0.3</version>
    </dependency>
    <dependency>
        <groupId>net.oschina.j2cache</groupId>
        <artifactId>j2cache-spring-boot2-starter</artifactId>
        <version>2.8.0-release</version>
    </dependency>
    <dependency>
        <groupId>net.oschina.j2cache</groupId>
        <artifactId>j2cache-core</artifactId>
        <version>2.8.0-release</version>
        <exclusions>
            <exclusion>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-simple</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    

    bootstrap.yml

    j2cache:
      config-location: classpath:/j2cache-${spring.profiles.active}.properties
      # 开启对spring cahce的支持
      open-spring-cache: true
      # jedis 或 lettuce 对应在j2cache.properties 配置
      redis-client: lettuce
      # 是否允许null值
      allow-null-values: true
      # 是否开始二级缓存
      l2-cache-open: true
      #  如下配置在application.properties,可以选择缓存清除的模式
      #  * 缓存清除模式
      #  * active:主动清除,二级缓存过期主动通知各节点清除,优点在于所有节点可以同时收到缓存清除
      #  * passive:被动清除,一级缓存过期进行通知各节点清除一二级缓存
      #  * blend:两种模式一起运作,对于各个节点缓存准确性以及及时性要求高的可以使用(推荐使用前面两种模式中一种)
      cache-clean-mode: passive
    

    新增2个properties配置文件(不支持yml):

    j2cache-test.properties

    #J2Cache configuration
    # caffeine 本地缓存定义文件
    caffeine.properties=/caffeine.properties
    j2cache.broadcast=net.oschina.j2cache.cache.support.redis.SpringRedisPubSubPolicy
    j2cache.L1.provider_class=caffeine
    j2cache.L2.provider_class=net.oschina.j2cache.cache.support.redis.SpringRedisProvider
    j2cache.L2.config_section=redis
    # 序列化方式
    j2cache.serialization=json
    #########################################
    # Redis connection configuration
    #########################################
    #########################################
    # Redis Cluster Mode
    # single -> single redis server
    # sentinel -> master-slaves servers
    # cluster -> cluster servers (数据库配置无效,使用 database = 0)
    # sharded -> sharded servers  (密码、数据库必须在 hosts 中指定,且连接池配置无效 ;
    # 例子:redis://user:password@127.0.0.1:6379/0)
    # sharded需要指定cluster name  :redis.cluster_name = mymaster
    #########################################
    redis.mode=single
    # redis通知节点删除本地缓存通道名
    redis.channel=j2cache
    # redis缓存key前缀
    redis.namespace=j2cache
    ## connection
    #redis.hosts = 127.0.0.1:26378,127.0.0.1:26379,127.0.0.1:26380
    redis.hosts=127.0.0.1:6379
    redis.timeout=2000
    redis.password=xfish311
    redis.database=1
    

    caffeine.properties:

    #########################################
    # Caffeine configuration 定义本地缓存
    # [name] = size, xxxx[s|m|h|d] (过期时间)
    #########################################
    default = 1000, 30m
    cache1 = 1000, 30m
    

    完毕。以上配置是本人整理后的配置,具体使用还需要查阅官方文档。

    另外对二级缓存原理有兴趣建议去看另一个项目,源码相比j2cache写得比较容易理解,属于阉割版的二级缓存:github.com/pig-mesh/mu…

    分类:
    后端
    标签: