目录

一、本地缓存Caffeine介绍

二、Caffeine功能与性能

三、Caffeine 配置说明

四、SpringBoot 集成 Caffeine、Redis实现多级缓存


一、本地缓存Caffeine介绍

一般情况下,缓存针对的主要是读操作。当你的功能遇到下面的场景时,就可以选择使用缓存组件进行性能优化:

  • 存在数据热点,缓存的数据能够被频繁使用;
  • 读操作明显比写操作要多;
  • 下游功能存在着比较悬殊的性能差异,下游服务能力有限;
  • 加入缓存以后,不会影响程序的正确性,或者引入不可预料的复杂性;

日常开发中,基本上每个项目中都会使用到Redis、MongoDB等缓存中间件,它能够很好的作为分布式缓存组件提供多个服务间的缓存,但是还是需要网络开销,增加时耗。

除了分布式缓存,其实还有一种缓存 - 本地缓存:直接从本地内存中读取,没有网络开销,在某些场景比远程缓存更合适。Guava cache、ehcache、Caffeine是目前比较流行的本地缓存组件,但Caffeine号称是本地缓存绝对的王者。

Caffeine是一个基于Java8开发的提供了近乎最佳命中率的高性能的缓存库。缓存和ConcurrentMap有点相似,但还是有所区别,最根本的区别是ConcurrentMap将会持有所有加入到缓存当中的元素,直到它们被从缓存当中手动移除。

Caffeine的底层使用了ConcurrentHashMap,支持按照一定的规则或者自定义的规则使缓存的数据过期,然后销毁。在 Spring5 (springboot 2.x) 后,Spring 官方放弃了 Guava,而使用了性能更优秀的 Caffeine 作为默认缓存组件。

二、Caffeine功能与性能

Caffeine提供了多种灵活的构造方法,从而可以创建多种特性的本地缓存。

  1. 自动把数据加载到本地缓存中,并且可以配置异步;
  2. 基于数量剔除策略;
  3. 基于失效时间剔除策略,这个时间是从最后一次操作算起【访问或者写入】;
  4. 异步刷新;
  5. Key会被包装成Weak引用;
  6. Value会被包装成Weak或者Soft引用,从而能被GC掉,而不至于内存泄漏;
  7. 数据剔除提醒;
  8. 写入广播机制;
  9. 缓存访问可以统计;

Caffeine的性能非常好,通过下图观测到,在下面缓存组件中 Caffeine 性能是其中最好的。

java 使用多级缓存背景 springboot 多级缓存_Caffeine本地缓存

java 使用多级缓存背景 springboot 多级缓存_redis_02

 

java 使用多级缓存背景 springboot 多级缓存_Caffeine本地缓存_03

由上面三幅图可见:不管在并发读、并发写还是并发读写的场景下,Caffeine 的性能都大幅领先于其他本地开源缓存组件。

三、Caffeine 配置说明

Caffeine主要提供了以下一些配置:

  • initialCapacity=[integer]: 设置初始缓存的空间大小;
  • maximumSize=[long]: 设置缓存的最大条数;
  • maximumWeight=[long]: 设置缓存的最大权重;
  • expireAfterAccess=[持续时间]: 最后一次写入或者访问后经过多久时间过期;
  • expireAfterWrite=[持续时间]: 最后一次写入后经过多久时间过期;
  • refreshAfterWrite=[持续时间]: 创建缓存或者最后一次更新缓存后经过多久时间间隔,刷新缓存;
  • weakKeys: 打开key的弱引用;
  • weakValues: 打开value的弱引用;
  • softValues: 打开value的软引用;
  • recordStats: 打开统计功能;

注意:

  • weakValues 和 softValues 不可以同时使用;
  • maximumSize 和 maximumWeight 不可以同时使用;
  • expireAfterWrite 和 expireAfterAccess 同时存在时,以expireAfterWrite为准;

四、SpringBoot 集成 Caffeine、Redis实现多级缓存

首先我们要明白为什么要使用多级缓存?

  • 如果只使用redis来做缓存我们会有大量的请求到redis,但是每次请求的数据都是一样的,假如这一部分数据就放在应用服务器本地,那么就省去了请求redis的网络开销,请求速度就会快很多;
  • 如果只使用Caffeine来做本地缓存,我们的应用服务器的内存是有限,并且单独为了缓存去扩展应用服务器是非常不划算。所以,只使用本地缓存也是有很大局限性的;

因此在项目中,我们可以将热点数据放本地缓存,作为一级缓存,将非热点数据放redis缓存,作为二级缓存,减少Redis的查询压力。

使用流程大致如下:

  • 首先从一级缓存(caffeine-本地应用内)中查找数据;
  • 如果没有的话,则从二级缓存(redis-内存)中查找数据;
  • 如果还是没有的话,再从数据库(数据库-磁盘)中查找数据;

java 使用多级缓存背景 springboot 多级缓存_java 使用多级缓存背景_04

 SpringBoot 有两种使用 Caffeine 作为缓存的方式:

  • 方式一:直接引入 Caffeine 依赖,然后使用 Caffeine 方法实现缓存;
  • 方式二:引入 Caffeine 和 Spring Cache 依赖,使用 SpringCache 注解方法实现缓存;

本篇文章我们以第一种方式介绍下如何集成Redis、Caffeine实现多级缓存的。

(一)、Maven 引入相关依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.5.6</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.wsh</groupId>
	<artifactId>springboot_caffeine</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>springboot_caffeine</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>1.8</java.version>
	</properties>
	<dependencies>
		<!--web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
		<!--caffeine-->
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>
		<!--redis-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
		</dependency>
		<!--fastjson-->
		<dependency>
			<groupId>com.alibaba</groupId>
			<artifactId>fastjson</artifactId>
			<version>1.2.72</version>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

(二)、Redis相关配置文件

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    password:
    jedis:
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 500
        min-idle: 0
    lettuce:
      shutdown-timeout: 0

(三)、本地缓存配置类

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

/**
 * 本地缓存Caffeine配置类
 */
@Configuration
public class LocalCacheConfiguration {

    @Bean("localCacheManager")
    public Cache<String, Object> localCacheManager() {
        return Caffeine.newBuilder()
                //写入或者更新5s后,缓存过期并失效, 实际项目中肯定不会那么短时间就过期,根据具体情况设置即可
                .expireAfterWrite(5, TimeUnit.SECONDS)
                // 初始的缓存空间大小
                .initialCapacity(50)
                // 缓存的最大条数,通过 Window TinyLfu算法控制整个缓存大小
                .maximumSize(500)
            	//打开数据收集功能
                .recordStats()
                .build();
    }

}

(四)、Redis缓存配置类

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        //关联
        template.setConnectionFactory(factory);
        //设置key的序列化方式
//        template.setKeySerializer();
        //设置value的序列化方式
//        template.setValueSerializer();
        return template;
    }
}

(五)、定义实体对象

public class User implements Serializable {
    private String id;
    private String name;

    public User(String id, String name) {
        this.id = id;
        this.name = name;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", name='" + name + '\'' +
                '}';
    }
}

(六)、定义Service接口类

public interface UserService {
    void add(User user);

    User getById(String id);

    User update(User user);

    void deleteById(String id);
}

(七)、定义Service接口实现类

import com.alibaba.fastjson.JSON;
import com.github.benmanes.caffeine.cache.Cache;
import com.wsh.springboot_caffeine.entity.User;
import com.wsh.springboot_caffeine.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

@Service
public class UserServiceImpl implements UserService {
    /**
     * 模拟数据库存储数据
     */
    private static HashMap<String, User> userMap = new HashMap<>();
    private final RedisTemplate<String, Object> redisTemplate;
    private final Cache<String, Object> caffeineCache;

    @Autowired
    public UserServiceImpl(RedisTemplate<String, Object> redisTemplate,
                           @Qualifier("localCacheManager") Cache<String, Object> caffeineCache) {
        this.redisTemplate = redisTemplate;
        this.caffeineCache = caffeineCache;
    }

    static {
        userMap.put("1", new User("1", "zhangsan"));
        userMap.put("2", new User("2", "lisi"));
        userMap.put("3", new User("3", "wangwu"));
        userMap.put("4", new User("4", "zhaoliu"));
    }


    @Override
    public void add(User user) {
        // 1.保存Caffeine缓存
        caffeineCache.put(user.getId(), user);

        // 2.保存redis缓存
        redisTemplate.opsForValue().set(user.getId(), JSON.toJSONString(user), 20, TimeUnit.SECONDS);

        // 3.保存数据库(模拟)
        userMap.put(user.getId(), user);
    }

    @Override
    public User getById(String id) {
        // 1.先从Caffeine缓存中读取
        Object o = caffeineCache.getIfPresent(id);
        if (Objects.nonNull(o)) {
            System.out.println("从Caffeine中查询到数据...");
            return (User) o;
        }

        // 2.如果缓存中不存在,则从Redis缓存中查找
        String jsonString = (String) redisTemplate.opsForValue().get(id);
        User user = JSON.parseObject(jsonString, User.class);
        if (Objects.nonNull(user)) {
            System.out.println("从Redis中查询到数据...");

            // 保存Caffeine缓存
            caffeineCache.put(user.getId(), user);
            return user;
        }

        // 3.如果Redis缓存中不存在,则从数据库中查询
        user = userMap.get(id);
        if (Objects.nonNull(user)) {
            // 保存Caffeine缓存
            caffeineCache.put(user.getId(), user);

            // 保存Redis缓存,20s后过期
            redisTemplate.opsForValue().set(user.getId(), JSON.toJSONString(user), 20, TimeUnit.SECONDS);
        }
        System.out.println("从数据库中查询到数据...");
        return user;
    }

    @Override
    public User update(User user) {
        User oldUser = userMap.get(user.getId());
        oldUser.setName(user.getName());
        // 1.更新数据库
        userMap.put(oldUser.getId(), oldUser);

        // 2.更新Caffeine缓存
        caffeineCache.put(oldUser.getId(), oldUser);

        // 3.更新Redis数据库
        redisTemplate.opsForValue().set(oldUser.getId(), JSON.toJSONString(oldUser), 20, TimeUnit.SECONDS);
        return oldUser;
    }

    @Override
    public void deleteById(String id) {
        // 1.删除数据库
        userMap.remove(id);

        // 2.删除Caffeine缓存
        caffeineCache.invalidate(id);

        // 3.删除Redis缓存
        redisTemplate.delete(id);
    }

}
  • caffeineCache.put(user.getId(), user):保存本地缓存;
  • caffeineCache.invalidate(id):移除指定的本地缓存;
  • caffeineCache.getIfPresent(id): 从本地缓存中获取值,如果缓存中不存指定的值,则方法将返回 null;
  • caffeineCache.get(id, Function<>): 从本地缓存中获取值,该方法还支持将一个参数为 key 的 Function 作为参数传入。如果缓存中不存在该 key,则该函数将用于提供默认值,该值在计算后插入缓存中,如果缓存的元素无法生成或者在生成的过程中抛出异常而导致生成元素失败,则返回null。如下:
//手动加载
caffeineCache.get(id, new Function<String, Object>() {
            @Override
            public Object apply(String s) {
                return "hello world";
            }
        });
或者简写成下面这样:
caffeineCache.get(id, s -> "hello world");
  • caffeine LoadingCache同步加载:
//同步加载数据指的是,在get不到数据时最终会调用build构造时提供的CacheLoader对象中的load函数,如果返回值则将其插入缓存中,并且返回
LoadingCache<Integer, Integer> loadingCache = Caffeine.newBuilder()
    .expireAfterWrite(20, TimeUnit.SECONDS)
    .maximumSize(500)
    .build(new CacheLoader<Integer, Integer>() {
        @Override
        public Integer load(Integer key) {
            return key;
        }
    });
  • caffeine AsyncCache异步加载:
// 使用executor设置线程池
AsyncCache<String, String> asyncCache = Caffeine.newBuilder()
    .expireAfterWrite(20, TimeUnit.SECONDS)
    .maximumSize(500)
    .executor(Executors.newFixedThreadPool(5)) //当然也可以使用自定义线程池实现
    .buildAsync();

CompletableFuture<String> completableFuture = asyncCache.get("1", new Function<String, String>() {
    @Override
    public String apply(String s) {
        //执行所在的线程是ForkJoinPool线程池提供的线程
        return "hello world";
    }
});
completableFuture.get();

(八)、单元测试

  • 查询
@Test
void testSelect() {
    System.out.println("=============第一次查询:=============");
    User user = userService.getById("1");
    System.out.println(user);
    System.out.println("=============第二次查询:=============");
    user = userService.getById("1");
    System.out.println(user);
    System.out.println("=============第三次查询:=============");
    try {
        //休眠7s后再次查询,主要是模拟Caffeine过期
        TimeUnit.SECONDS.sleep(7);
        user = userService.getById("1");
        System.out.println(user);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

执行结果如下:

=============第一次查询:=============
从数据库中查询到数据...
User{id=1, name='zhangsan'}
=============第二次查询:=============
从Caffeine中查询到数据...
User{id=1, name='zhangsan'}
=============第三次查询:=============
从Redis中查询到数据...
User{id=1, name='zhangsan'}

可以看到,第一次查询,因为Caffeine和Redis都没有对应的数据,所以从数据库中查询,然后第二次查询,Caffeine中有数据库了,所以从Caffeine本地缓存中直接获取,休眠一段时间,使Caffeine过期之后,进行第三次查询,这次则从Redis中获取,因为Redis我们设置的过期时间比Caffeine长。

  • 新增
@Test
void testAdd() {
    userService.add(new User("5", "田七"));
    System.out.println("=============第一次查询:=============");
    User user = userService.getById("5");
    System.out.println(user);
    System.out.println("=============第二次查询:=============");
    try {
        //休眠7s后再次查询,主要是模拟Caffeine过期
        TimeUnit.SECONDS.sleep(7);
        user = userService.getById("5");
        System.out.println(user);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

执行结果如下:

=============第一次查询:=============
从Caffeine中查询到数据...
User{id=5, name='田七'}
=============第二次查询:=============
从Redis中查询到数据...
User{id=5, name='田七'}

可以看到,第一次查询,因为新增完之后,往Caffeine中存放了数据,所以第一次是从Caffeine查询的,休眠一段时间,使Caffeine过期之后,进行第二次查询,这次则从Redis中获取,因为Redis我们设置的过期时间比Caffeine长。

  • 修改
@Test
void testUpdate() {
    userService.update(new User("1", "zs"));
    System.out.println("=============第一次查询:=============");
    User user = userService.getById("1");
    System.out.println(user);
    System.out.println("=============第二次查询:=============");
    try {
        //休眠7s后再次查询,主要是模拟Caffeine过期
        TimeUnit.SECONDS.sleep(7);
        user = userService.getById("1");
        System.out.println(user);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

执行结果如下:

=============第一次查询:=============
从Caffeine中查询到数据...
User{id=1, name='zs'}
=============第二次查询:=============
从Redis中查询到数据...
User{id=1, name='zs'}

可以看到,第一次查询,因为修改完之后,更新了Caffeine中的数据,所以第一次是从Caffeine查询的,休眠一段时间,使Caffeine过期之后,进行第二次查询,这次则从Redis中获取,因为Redis我们设置的过期时间比Caffeine长。

  • 删除
@Test
void testDelete() {
    System.out.println("=============第一次查询:=============");
    User user = userService.getById("1");
    System.out.println(user);
    userService.deleteById("1");
    System.out.println("=============第二次查询:=============");
    User user2 = userService.getById("1");
    System.out.println(user2);
}

执行结果如下:

=============第一次查询:=============
从数据库中查询到数据...
User{id=1, name='zhangsan'}
=============第二次查询:=============
从数据库中查询到数据...
null

可以看到,第一次查询,因为删除完之后,同时也删除了Caffeine和Redis中的数据,所以第一次是从数据库中查询的,接着进行第二次查询也是从数据库进行查询,只是查询不到对应的数据。

需要说明的是,上述只是简单的同步保存、更新、删除数据库、Caffeine、Redis中的数据,生产环境难免会遇到三者之间数据不一致的情况,这是读者需要特别注意的地方,本文主要演示Caffeine如何配合Redis进行多级缓存使用,读者可自行思考一下如何保证三者的数据尽可能不出现不一致的情况。

五、Caffeine的软引用与弱引用

  • 软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存;
  • 弱引用:弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存;

key 支持弱引用,而 value 则支持弱引用和软引用。需要注意的是,AsyncCache 不支持软引用和弱引用。

// 软引用,当进行GC的时候进行回收
Caffeine.newBuilder()
    .softValues()
    .build(); 

// 弱引用,当key和缓存元素都不再存在其他强引用的时候回收
Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build();