在前文我们介绍了如何使用Redis或者Caffeine来做缓存。
通过使用redis和Caffeine来做缓存,我们会发现一些问题。
至此我们是不是有一个想法了,两个一起用。将热点数据放本地缓存(一级缓存),将非热点数据放redis缓存(二级缓存)。
Spring 本来就提供了Cache的支持,最核心的就是实现Cache和CacheManager接口。
主要是实现对缓存的操作,如增删查等。
public interface Cache { String getName(); Object getNativeCache(); <T> T get(Object key, Class<T> type); <T> T get(Object key, Callable<T> valueLoader); void put(Object key, Object value); ValueWrapper putIfAbsent(Object key, Object value); void evict(Object key); void clear(); ... }
根据缓存名称来管理Cache,核心方法就是通过缓存名称获取Cache。
public interface CacheManager { Cache getCache(String name); Collection<String> getCacheNames(); }
通过上面的两个接口我的大致思路是,写一个LayeringCache来实现Cache接口,LayeringCache类中集成对Caffeine和redis的操作。 写一个LayeringCacheManager来管理LayeringCache就行了。
这里的redis缓存使用的是我扩展后的RedisCache详情请看:
LayeringCache类,因为需要集成对Caffeine和Redis的操作,所以至少需要有name(缓存名称)、CaffeineCache和CustomizedRedisCache三个属性,还增加了一个是否使用一级缓存的开关usedFirstCache。在LayeringCache类的方法里面分别去调用操作一级缓存的和操作二级缓存的方法就可以了。
完整代码:
/** * @author yuhao.wang */ public class LayeringCache extends AbstractValueAdaptingCache { Logger logger = LoggerFactory.getLogger(LayeringCache.class); /** * 缓存的名称 */ private final String name; /** * 是否使用一级缓存 */ private boolean usedFirstCache = true; /** * redi缓存 */ private final CustomizedRedisCache redisCache; /** * Caffeine缓存 */ private final CaffeineCache caffeineCache; /** * @param name 缓存名称 * @param prefix 缓存前缀 * @param redisOperations 操作Redis的RedisTemplate * @param expiration redis缓存过期时间 * @param preloadSecondTime redis缓存自动刷新时间 * @param allowNullValues 是否允许存NULL,默认是false * @param usedFirstCache 是否使用一级缓存,默认是true * @param forceRefresh 是否强制刷新(走数据库),默认是false * @param caffeineCache Caffeine缓存 */ public LayeringCache(String name, byte[] prefix, RedisOperations<? extends Object, ? extends Object> redisOperations, long expiration, long preloadSecondTime, boolean allowNullValues, boolean usedFirstCache, boolean forceRefresh, com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache) { super(allowNullValues); this.name = name; this.usedFirstCache = usedFirstCache; this.redisCache = new CustomizedRedisCache(name, prefix, redisOperations, expiration, preloadSecondTime, forceRefresh, allowNullValues); this.caffeineCache = new CaffeineCache(name, caffeineCache, allowNullValues); } @Override public String getName() { return this.name; } @Override public Object getNativeCache() { return this; } public CustomizedRedisCache getSecondaryCache() { return this.redisCache; } @Override public ValueWrapper get(Object key) { ValueWrapper wrapper = null; if (usedFirstCache) { // 查询一级缓存 wrapper = caffeineCache.get(key); logger.debug("查询一级缓存 key:{},返回值是:{}", key, wrapper); } if (wrapper == null) { // 查询二级缓存 wrapper = redisCache.get(key); logger.debug("查询二级缓存 key:{},返回值是:{}", key, wrapper); } return wrapper; } @Override public <T> T get(Object key, Class<T> type) { T value = null; if (usedFirstCache) { // 查询一级缓存 value = caffeineCache.get(key, type); logger.debug("查询一级缓存 key:{},返回值是:{}", key); } if (value == null) { // 查询二级缓存 value = redisCache.get(key, type); caffeineCache.put(key, value); logger.debug("查询二级缓存 key:{},返回值是:{}", key); } return value; } @Override public <T> T get(Object key, Callable<T> valueLoader) { T value = null; if (usedFirstCache) { // 查询一级缓存,如果一级缓存没有值则调用getForSecondaryCache(k, valueLoader)查询二级缓存 value = (T) caffeineCache.getNativeCache().get(key, k -> getForSecondaryCache(k, valueLoader)); } else { // 直接查询二级缓存 value = (T) getForSecondaryCache(key, valueLoader); } return value; } @Override public void put(Object key, Object value) { if (usedFirstCache) { caffeineCache.put(key, value); } redisCache.put(key, value); } @Override public ValueWrapper putIfAbsent(Object key, Object value) { if (usedFirstCache) { caffeineCache.putIfAbsent(key, value); } return redisCache.putIfAbsent(key, value); } @Override public void evict(Object key) { // 删除的时候要先删除二级缓存再删除一级缓存,否则有并发问题 redisCache.evict(key); if (usedFirstCache) { caffeineCache.evict(key); } } @Override public void clear() { redisCache.clear(); if (usedFirstCache) { caffeineCache.clear(); } } @Override protected Object lookup(Object key) { Object value = null; if (usedFirstCache) { value = caffeineCache.get(key); logger.debug("查询一级缓存 key:{},返回值是:{}", key); } if (value == null) { value = redisCache.get(key); logger.debug("查询二级缓存 key:{},返回值是:{}", key); } return value; } /** * 查询二级缓存 * * @param key * @param valueLoader * @return */ private <T> Object getForSecondaryCache(Object key, Callable<T> valueLoader) { T value = redisCache.get(key, valueLoader); logger.debug("查询二级缓存 key:{},返回值是:{}", key, value); return value; } }
因为我们需要在CacheManager中来管理缓存,所以我们需要在CacheManager定义一个容器来存储缓存。在这里我们新建一个ConcurrentMap<String, Cache> cacheMap来存在缓存,CacheManager的两个方法getCache和getCacheNames都通过操作这个cacheMap来实现。Map<String, FirstCacheSetting> firstCacheSettings和Map<String, SecondaryCacheSetting> secondaryCacheSettings属性是针对每一个缓存的特殊配置,如一级缓存的过期时间配置,二级缓存的过期时间和自动刷新时间配置。剩下的属性就不一一介绍了,可直接看下面的源码。
这可以说是CacheManager最核心的方法,所有CacheManager操作都围绕这个方法进行。
@Override public Cache getCache(String name) { Cache cache = this.cacheMap.get(name); if (cache == null && this.dynamic) { synchronized (this.cacheMap) { cache = this.cacheMap.get(name); if (cache == null) { cache = createCache(name); this.cacheMap.put(name, cache); } } } return cache; }
从这段逻辑我们可以看出这个方法就是根据名称获取缓存,如果没有找到并且动态创建缓存的开关dynamic为true的话,就调用createCache方法动态的创建缓存。
去创建一个LayeringCache
protected Cache createCache(String name) { return new LayeringCache(name, (usePrefix ? cachePrefix.prefix(name) : null), redisOperations, getSecondaryCacheExpirationSecondTime(name), getSecondaryCachePreloadSecondTime(name), isAllowNullValues(), getUsedFirstCache(name), getForceRefresh(name), createNativeCaffeineCache(name)); }
在创建缓存的时候我们会调用getSecondaryCacheExpirationSecondTime、getSecondaryCachePreloadSecondTime和getForceRefresh等方法去获取二级缓存的过期时间、自动刷新时间和是否强制刷新(走数据库)等值,这些都在secondaryCacheSettings属性中获取;调用createNativeCaffeineCache方法去创建一个一级缓存Caffeine的实例。createNativeCaffeineCache在这个方法里面会调用getCaffeine方法动态的去读取一级缓存的配置,并根据配置创建一级缓存,如果没有找到特殊配置,就使用默认配置,而这里的特殊配置则在firstCacheSettings属性中获取。
动态的获取一级缓存配置,并创建对应Caffeine对象。
private Caffeine<Object, Object> getCaffeine(String name) { if (!CollectionUtils.isEmpty(firstCacheSettings)) { FirstCacheSetting firstCacheSetting = firstCacheSettings.get(name); if (firstCacheSetting != null && StringUtils.isNotBlank(firstCacheSetting.getCacheSpecification())) { // 根据缓存名称获取一级缓存配置 return Caffeine.from(CaffeineSpec.parse(firstCacheSetting.getCacheSpecification())); } } return this.cacheBuilder; }private Caffeine<Object, Object> getCaffeine(String name) { if (!CollectionUtils.isEmpty(firstCacheSettings)) { FirstCacheSetting firstCacheSetting = firstCacheSettings.get(name); if (firstCacheSetting != null && StringUtils.isNotBlank(firstCacheSetting.getCacheSpecification())) { // 根据缓存名称获取一级缓存配置 return Caffeine.from(CaffeineSpec.parse(firstCacheSetting.getCacheSpecification())); } } return this.cacheBuilder; }
我们借用了RedisCacheManager的setExpires(Map<String, Long> expires)方法的思想。用setFirstCacheSettings和setSecondaryCacheSettings方法对一级缓存和二级缓存的特殊配置进行设值。
/** * 根据缓存名称设置一级缓存的有效时间和刷新时间,单位秒 * * @param firstCacheSettings */ public void setFirstCacheSettings(Map<String, FirstCacheSetting> firstCacheSettings) { this.firstCacheSettings = (!CollectionUtils.isEmpty(firstCacheSettings) ? new ConcurrentHashMap<>(firstCacheSettings) : null); } /** * 根据缓存名称设置二级缓存的有效时间和刷新时间,单位秒 * * @param secondaryCacheSettings */ public void setSecondaryCacheSettings(Map<String, SecondaryCacheSetting> secondaryCacheSettings) { this.secondaryCacheSettings = (!CollectionUtils.isEmpty(secondaryCacheSettings) ? new ConcurrentHashMap<>(secondaryCacheSettings) : null); }
完整代码:
/** * @author yuhao.wang */ @SuppressWarnings("rawtypes") public class LayeringCacheManager implements CacheManager { // 常量 static final int DEFAULT_EXPIRE_AFTER_WRITE = 60; static final int DEFAULT_INITIAL_CAPACITY = 5; static final int DEFAULT_MAXIMUM_SIZE = 1_000; private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<String, Cache>(16); /** * 一级缓存配置 */ private Map<String, FirstCacheSetting> firstCacheSettings = null; /** * 二级缓存配置 */ private Map<String, SecondaryCacheSetting> secondaryCacheSettings = null; /** * 是否允许动态创建缓存,默认是true */ private boolean dynamic = true; /** * 缓存值是否允许为NULL */ private boolean allowNullValues = false; // Caffeine 属性 /** * expireAfterWrite:60 * initialCapacity:5 * maximumSize: 1_000 */ private Caffeine<Object, Object> cacheBuilder = Caffeine.newBuilder() .expireAfterWrite(DEFAULT_EXPIRE_AFTER_WRITE, TimeUnit.SECONDS) .initialCapacity(DEFAULT_INITIAL_CAPACITY) .maximumSize(DEFAULT_MAXIMUM_SIZE); // redis 属性 /** * 操作redis的RedisTemplate */ private final RedisOperations redisOperations; /** * 二级缓存使用使用前缀,默认是false,建议设置成true */ private boolean usePrefix = false; private RedisCachePrefix cachePrefix = new DefaultRedisCachePrefix(); /** * redis缓存默认时间,默认是0 永不过期 */ private long defaultExpiration = 0; public LayeringCacheManager(RedisOperations redisOperations) { this(redisOperations, Collections.<String>emptyList()); } public LayeringCacheManager(RedisOperations redisOperations, Collection<String> cacheNames) { this(redisOperations, cacheNames, false); } public LayeringCacheManager(RedisOperations redisOperations, Collection<String> cacheNames, boolean allowNullValues) { this.allowNullValues = allowNullValues; this.redisOperations = redisOperations; setCacheNames(cacheNames); } @Override public Cache getCache(String name) { Cache cache = this.cacheMap.get(name); if (cache == null && this.dynamic) { synchronized (this.cacheMap) { cache = this.cacheMap.get(name); if (cache == null) { cache = createCache(name); this.cacheMap.put(name, cache); } } } return cache; } @Override public Collection<String> getCacheNames() { return Collections.unmodifiableSet(this.cacheMap.keySet()); } @SuppressWarnings("unchecked") protected Cache createCache(String name) { return new LayeringCache(name, (usePrefix ? cachePrefix.prefix(name) : null), redisOperations, getSecondaryCacheExpirationSecondTime(name), getSecondaryCachePreloadSecondTime(name), isAllowNullValues(), getUsedFirstCache(name), getForceRefresh(name), createNativeCaffeineCache(name)); } /** * Create a native Caffeine Cache instance for the specified cache name. * * @param name the name of the cache * @return the native Caffeine Cache instance */ protected com.github.benmanes.caffeine.cache.Cache<Object, Object> createNativeCaffeineCache(String name) { return getCaffeine(name).build(); } /** * 使用该CacheManager的当前状态重新创建已知的缓存。 */ private void refreshKnownCaches() { for (Map.Entry<String, Cache> entry : this.cacheMap.entrySet()) { entry.setValue(createCache(entry.getKey())); } } /** * 在初始化CacheManager的时候初始化一组缓存。 * 使用这个方法会在CacheManager初始化的时候就会将一组缓存初始化好,并且在运行时不会再去创建更多的缓存。 * 使用空的Collection或者重新在配置里面指定dynamic后,就可重新在运行时动态的来创建缓存。 * * @param cacheNames */ public void setCacheNames(Collection<String> cacheNames) { if (cacheNames != null) { for (String name : cacheNames) { this.cacheMap.put(name, createCache(name)); } this.dynamic = cacheNames.isEmpty(); } } /** * 设置是否允许Cache的值为null * * @param allowNullValues */ public void setAllowNullValues(boolean allowNullValues) { if (this.allowNullValues != allowNullValues) { this.allowNullValues = allowNullValues; refreshKnownCaches(); } } /** * 获取是否允许Cache的值为null * * @return */ public boolean isAllowNullValues() { return this.allowNullValues; } /** * 在生成key的时候是否是否使用缓存名称来作为缓存前缀。默认是false,但是建议设置成true。 * * @param usePrefix */ public void setUsePrefix(boolean usePrefix) { this.usePrefix = usePrefix; } protected boolean isUsePrefix() { return usePrefix; } /** * 设置redis默认的过期时间(单位:秒) * * @param defaultExpireTime */ public void setSecondaryCacheDefaultExpiration(long defaultExpireTime) { this.defaultExpiration = defaultExpireTime; } /** * 根据缓存名称设置一级缓存的有效时间和刷新时间,单位秒 * * @param firstCacheSettings */ public void setFirstCacheSettings(Map<String, FirstCacheSetting> firstCacheSettings) { this.firstCacheSettings = (!CollectionUtils.isEmpty(firstCacheSettings) ? new ConcurrentHashMap<>(firstCacheSettings) : null); } /** * 根据缓存名称设置二级缓存的有效时间和刷新时间,单位秒 * * @param secondaryCacheSettings */ public void setSecondaryCacheSettings(Map<String, SecondaryCacheSetting> secondaryCacheSettings) { this.secondaryCacheSettings = (!CollectionUtils.isEmpty(secondaryCacheSettings) ? new ConcurrentHashMap<>(secondaryCacheSettings) : null); } /** * 获取过期时间 * * @return */ public long getSecondaryCacheExpirationSecondTime(String name) { if (StringUtils.isEmpty(name)) { return 0; } SecondaryCacheSetting secondaryCacheSetting = null; if (!CollectionUtils.isEmpty(secondaryCacheSettings)) { secondaryCacheSetting = secondaryCacheSettings.get(name); } Long expiration = secondaryCacheSetting != null ? secondaryCacheSetting.getExpirationSecondTime() : defaultExpiration; return expiration < 0 ? 0 : expiration; } /** * 获取自动刷新时间 * * @return */ private long getSecondaryCachePreloadSecondTime(String name) { // 自动刷新时间,默认是0 SecondaryCacheSetting secondaryCacheSetting = null; if (!CollectionUtils.isEmpty(secondaryCacheSettings)) { secondaryCacheSetting = secondaryCacheSettings.get(name); } Long preloadSecondTime = secondaryCacheSetting != null ? secondaryCacheSetting.getPreloadSecondTime() : 0; return preloadSecondTime < 0 ? 0 : preloadSecondTime; } /** * 获取是否使用二级缓存,默认是true */ public boolean getUsedFirstCache(String name) { SecondaryCacheSetting secondaryCacheSetting = null; if (!CollectionUtils.isEmpty(secondaryCacheSettings)) { secondaryCacheSetting = secondaryCacheSettings.get(name); } return secondaryCacheSetting != null ? secondaryCacheSetting.getUsedFirstCache() : true; } /** * 获取是否强制刷新(走数据库),默认是false */ public boolean getForceRefresh(String name) { SecondaryCacheSetting secondaryCacheSetting = null; if (!CollectionUtils.isEmpty(secondaryCacheSettings)) { secondaryCacheSetting = secondaryCacheSettings.get(name); } return secondaryCacheSetting != null ? secondaryCacheSetting.getForceRefresh() : false; } public void setCaffeineSpec(CaffeineSpec caffeineSpec) { Caffeine<Object, Object> cacheBuilder = Caffeine.from(caffeineSpec); if (!ObjectUtils.nullSafeEquals(this.cacheBuilder, cacheBuilder)) { this.cacheBuilder = cacheBuilder; refreshKnownCaches(); } } private Caffeine<Object, Object> getCaffeine(String name) { if (!CollectionUtils.isEmpty(firstCacheSettings)) { FirstCacheSetting firstCacheSetting = firstCacheSettings.get(name); if (firstCacheSetting != null && StringUtils.isNotBlank(firstCacheSetting.getCacheSpecification())) { // 根据缓存名称获取一级缓存配置 return Caffeine.from(CaffeineSpec.parse(firstCacheSetting.getCacheSpecification())); } } return this.cacheBuilder; } }
一级缓存配置类
public class FirstCacheSetting { /** * 一级缓存配置,配置项请点击这里 {@link CaffeineSpec#configure(String, String)} * @param cacheSpecification */ public FirstCacheSetting(String cacheSpecification) { this.cacheSpecification = cacheSpecification; } private String cacheSpecification; public String getCacheSpecification() { return cacheSpecification; } }
二级缓存的特殊配置类
/** * @author yuhao.wang */ public class SecondaryCacheSetting { /** * @param expirationSecondTime 设置redis缓存的有效时间,单位秒 * @param preloadSecondTime 设置redis缓存的自动刷新时间,单位秒 */ public SecondaryCacheSetting(long expirationSecondTime, long preloadSecondTime) { this.expirationSecondTime = expirationSecondTime; this.preloadSecondTime = preloadSecondTime; } /** * @param usedFirstCache 是否启用一级缓存,默认true * @param expirationSecondTime 设置redis缓存的有效时间,单位秒 * @param preloadSecondTime 设置redis缓存的自动刷新时间,单位秒 */ public SecondaryCacheSetting(boolean usedFirstCache, long expirationSecondTime, long preloadSecondTime) { this.expirationSecondTime = expirationSecondTime; this.preloadSecondTime = preloadSecondTime; this.usedFirstCache = usedFirstCache; } /** * @param expirationSecondTime 设置redis缓存的有效时间,单位秒 * @param preloadSecondTime 设置redis缓存的自动刷新时间,单位秒 * @param forceRefresh 是否使用强制刷新(走数据库),默认false */ public SecondaryCacheSetting(long expirationSecondTime, long preloadSecondTime, boolean forceRefresh) { this.expirationSecondTime = expirationSecondTime; this.preloadSecondTime = preloadSecondTime; this.forceRefresh = forceRefresh; } /** * @param expirationSecondTime 设置redis缓存的有效时间,单位秒 * @param preloadSecondTime 设置redis缓存的自动刷新时间,单位秒 * @param usedFirstCache 是否启用一级缓存,默认true * @param forceRefresh 是否使用强制刷新(走数据库),默认false */ public SecondaryCacheSetting(long expirationSecondTime, long preloadSecondTime, boolean usedFirstCache, boolean forceRefresh) { this.expirationSecondTime = expirationSecondTime; this.preloadSecondTime = preloadSecondTime; this.usedFirstCache = usedFirstCache; this.forceRefresh = forceRefresh; } /** * 缓存有效时间 */ private long expirationSecondTime; /** * 缓存主动在失效前强制刷新缓存的时间 * 单位:秒 */ private long preloadSecondTime = 0; /** * 是否使用二级缓存,默认是true */ private boolean usedFirstCache = true; /** * 是否使用强刷新(走数据库),默认是false */ private boolean forceRefresh = false; public long getPreloadSecondTime() { return preloadSecondTime; } public long getExpirationSecondTime() { return expirationSecondTime; } public boolean getUsedFirstCache() { return usedFirstCache; } public boolean getForceRefresh() { return forceRefresh; } }
在上面我们定义好了LayeringCacheManager和LayeringCache接下来就是使用了。
新建一个配置类CacheConfig,在这里指定一个LayeringCacheManager的Bean。我那的缓存就生效了。完整代码如下:
/** * @author yuhao.wang */ @Configuration @EnableConfigurationProperties(CacheProperties.class) public class CacheConfig { // redis缓存的有效时间单位是秒 @Value("${redis.default.expiration:3600}") private long redisDefaultExpiration; // 查询缓存有效时间 @Value("${select.cache.timeout:1800}") private long selectCacheTimeout; // 查询缓存自动刷新时间 @Value("${select.cache.refresh:1790}") private long selectCacheRefresh; @Autowired private CacheProperties cacheProperties; @Bean @Primary public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) { LayeringCacheManager layeringCacheManager = new LayeringCacheManager(redisTemplate); // Caffeine缓存设置 setFirstCacheConfig(layeringCacheManager); // redis缓存设置 setSecondaryCacheConfig(layeringCacheManager); return layeringCacheManager; } private void setFirstCacheConfig(LayeringCacheManager layeringCacheManager) { // 设置默认的一级缓存配置 String specification = this.cacheProperties.getCaffeine().getSpec(); if (StringUtils.hasText(specification)) { layeringCacheManager.setCaffeineSpec(CaffeineSpec.parse(specification)); } // 设置每个一级缓存的过期时间和自动刷新时间 Map<String, FirstCacheSetting> firstCacheSettings = new HashMap<>(); firstCacheSettings.put("people", new FirstCacheSetting("initialCapacity=5,maximumSize=500,expireAfterWrite=10s")); firstCacheSettings.put("people1", new FirstCacheSetting("initialCapacity=5,maximumSize=50,expireAfterAccess=10s")); layeringCacheManager.setFirstCacheSettings(firstCacheSettings); } private void setSecondaryCacheConfig(LayeringCacheManager layeringCacheManager) { // 设置使用缓存名称(value属性)作为redis缓存前缀 layeringCacheManager.setUsePrefix(true); //这里可以设置一个默认的过期时间 单位是秒 layeringCacheManager.setSecondaryCacheDefaultExpiration(redisDefaultExpiration); // 设置每个二级缓存的过期时间和自动刷新时间 Map<String, SecondaryCacheSetting> secondaryCacheSettings = new HashMap<>(); secondaryCacheSettings.put("people", new SecondaryCacheSetting(selectCacheTimeout, selectCacheRefresh)); secondaryCacheSettings.put("people1", new SecondaryCacheSetting(selectCacheTimeout, selectCacheRefresh, true)); secondaryCacheSettings.put("people2", new SecondaryCacheSetting(false, selectCacheTimeout, selectCacheRefresh)); secondaryCacheSettings.put("people3", new SecondaryCacheSetting(selectCacheTimeout, selectCacheRefresh, false, true)); layeringCacheManager.setSecondaryCacheSettings(secondaryCacheSettings); } /** * 显示声明缓存key生成器 * * @return */ @Bean public KeyGenerator keyGenerator() { return new SimpleKeyGenerator(); } }
在cacheManager中指定Bean的时候,我们通过调用LayeringCacheManager 的setFirstCacheSettings和setSecondaryCacheSettings方法为缓存设置一级缓存和二级缓存的特殊配置。
剩下的就是在Service方法上加注解了,如:
@Override @Cacheable(value = "people1", key = "#person.id", sync = true)//3 public Person findOne1(Person person, String a, String[] b, List<Long> c) { Person p = personRepository.findOne(person.getId()); logger.info("为id、key为:" + p.getId() + "数据做了缓存"); return p; }
@Cacheable的sync属性建议设置成true。
最后通过jmeter测试,50个线程,使用多级缓存,比只使用redis级缓存性能提升2倍多,只是用redis吞吐量在1243左右,使用多级缓存后在2639左右。
源码地址: https://github.com/wyh-spring-ecosystem-student/spring-boot-student/tree/releases
spring-boot-student-cache-redis-caffeine 工程