最近使用缓存很多,所以想要对缓存做一次总结
缓存
使用缓存前,首先要考虑是否真的需要缓存
最近我们项目的一个接口被发现超时,排查发现接口的入参常常为空全查数据,接口里除了全查数据库还有递归等数据处理。这时候其实大部分请求都是需要同样的全量的数据,所以可以引入缓存。
缓存可以分为进程内缓存和分布式缓存两种
进程内缓存:ConcurrentHashMap + caffeine
分布式缓存:redis
缓存最好不要使用单一的缓存,多级缓存是更好的选择
concurrentHashMap
concurrentHashMap融合了hashtable和hashMap,支持在多线程下同步操作,但是concurrentHashMap的锁粒度更小,解决了同步时锁整个数据结构的问题
Timer是java自带的定时器,可以在将来某个时间执行任务
final关键字可以保证concurrentHashMap不被重新赋值,但是concurrentHashMap是可变的数据结构,可以添加修改删除包含的条目
package org.spring.common.cache;
import java.util.Date;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;import java.util.concurrent.ConcurrentHashMap;
public class LocalCacheUtil {
/**
* 默认有效时长,单位:秒
*/
private static final int DEFUALT_TIMEOUT = 3600 * 1000;
private static final long SECOND_TIME = 1000;
private static final Map<String, Object> map;
private static Timer timer;
/**
* 初始化
*/
static {
timer = new Timer();
map = new ConcurrentHashMap<>();
}
/**
* 私有构造函数,工具类不允许实例化
*/
private LocalCacheUtil() {}
/**
* 清除缓存任务类
*/
static class CleanWorkerTask extends TimerTask {
private String key;
public CleanWorkerTask(String key) {
this.key = key;
}
public void run() {
LocalCacheUtil.remove(key);
}
}
/**
* 增加缓存
*
* @param key
* @param value
*/
public static void put(String key, Object value) {
map.put(key, value);
timer.schedule(new CleanWorkerTask(key), DEFUALT_TIMEOUT);
}
/**
* 增加缓存
*
* @param key
* @param value
* @param timeout 有效时长
*/
public static void put(String key, Object value, int timeout) {
map.put(key, value);
timer.schedule(new CleanWorkerTask(key), timeout * SECOND_TIME);
}
/**
* 增加缓存
*
* @param key
* @param value
* @param expireTime 过期时间
*/
public static void put(String key, Object value, Date expireTime) {
map.put(key, value);
timer.schedule(new CleanWorkerTask(key), expireTime);
}
/**
* 获取缓存
*
* @param key
* @return
*/
public static Object get(String key) {
return map.get(key);
}
/**
* 查询缓存是否包含key
*
* @param key
* @return
*/
public static boolean containsKey(String key) {
return map.containsKey(key);
}
/**
* 删除缓存
*
* @param key
*/
public static void remove(String key) {
map.remove(key);
}
/**
* 返回缓存大小
*
* @return
*/
public static int size() {
return map.size();
}
/**
* 清除所有缓存
*
* @return
*/
public static void clear() {
if (size() > 0) {
map.clear();
}
// 取消延时任务,重新创建Timer
timer.cancel();
timer = new Timer();
}
}
这里虽然简单实现了缓存功能,但是进程中的缓存更新,以及多线程下的复合操作的锁处理都存在问题,那就来学习一下优秀的进程缓存caffeine是怎么实现的!
这里插一句,问个问题,多线程下的被final和static修饰的concurrenthashmap,在进行读写操作的时候还需不需要用synchronized 修饰?
这个问题后面我总结锁的时候再讨论!
caffeine
caffeine是基于concurrentHashMap实现的缓存框架,官方教程可以自己去GitHub - ben-manes/caffeine: A high performance caching library for Java学习
大概的实现如图所示:
缓存的实现内部包含一个ConcurrentHashMap
Scheduler清空数据的定时器,不设置则不会主动清空过期数据
Executor是指运行异步任务用的线程池,不设置就使用默认的线程池
引入依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.0</version>
</dependency>
例子
CacheUtil
public class CacheUtil {
private static final Cache<String, Notice> cache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).build();
public static Notice get(String key) {
Notice notice = cache.getIfPresent(key);
return notice;
}
public static void set(String key, Notice value) {
cache.put(key, value);
}
}
查询使用
Notice detail = CacheUtil.get("notice");
if (detail == null) {
detail = noticeService.getOne(Condition.getQueryWrapper(notice));
CacheUtil.set("notice", detail);
}
其实用起来比较简单,创建一个固定的Cache(也就是一个功能更丰富的ConcurrentHashMap),取和存值可以直接使用固定类型的value,至于是一个业务使用一个Cache还是多个业务共用可以根据业务需求来设计。
但是仅仅这样使用,和自己做一个内存容器有什么区别呢?
再回过头来看Caffeine的创建的各种参数,maximumSize最大条目数,expireAfterWrite(1,TimeUnit.HOURS)过期时间,weighter((key.value) -> value.length())权重,其实这些就是caffeine的特殊用法,设置的都是缓存淘汰策略。
缓存淘汰
Caffeine有四种缓存淘汰设置,基于大小、基于权重、基于时间、基于引用(用的较少),其中大小和权重只能二选一。
这里的淘汰操作都是异步执行的,所以会损失一部分的时效性,没有那么准确的数据数量控制。
基于大小
maximumSize 允许缓存的最大条数
Cache<Integer, String> cache = Caffeine.newBuilder() .maximumSize(1000L) // 限制最大缓存条数 .build();
缓存条数满了之后,新的缓存写入,旧数据采取LFU算法,最不常被使用的数据被删除
基于权重
maximumWeight 允许最大的权重
Cache<Integer, String> cache = Caffeine.newBuilder() .maximumWeight(1000L) // 限制最大权重值 .weigher((key, value) -> (String.valueOf(value).length() / 1000) + 1) .build();
weighter((key,value) -> (String.valueOf(value).length() / 1000) + 1),也就是基于值的长度计算权重值,这里要注意最大条数和最大权重是相互排斥的,不能共用
基于时间
| 方式 | 具体说明 |
|---|---|
| expireAfterWrite | 基于创建时间进行过期处理 |
| expireAfterAccess | 基于最后访问时间进行过期处理 |
| expireAfter | 基于个性化定制的逻辑来实现过期处理(可以定制基于新增、读取、更新等场景的过期策略,甚至支持为不同记录指定不同过期时间) |
Cache<String, User> userCache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.SECONDS) .build();
Cache<String, User> userCache = Caffeine.newBuilder() .expireAfterAccess(1, TimeUnit.SECONDS) .build();
基于创建时间,则到达过期时间即会被淘汰;基于最后访问时间,则一直被访问即不会被淘汰;
淘汰监听器
recordStats()记录统计;
removalListener()移除监听
LoadingCache<String,String> cache = Caffeine.newBuilder()
.maximumSize(5)
.recordStats()
.expireAfterWrite(2, TimeUnit.SECONDS)
.removalListener((String key, String value, RemovalCause cause) -> {
System.out.printf("Key %s was removed (%s)%n", key, cause);
})
.build(key -> UUID.randomUUID().toString());
for (int i = 0; i < 15; i++) {
cache.get(i+"");
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
}
//因为evict是异步线程去执行,为了看到效果稍微停顿一下
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
}
缓存统计
public static void demo(){
LoadingCache<Integer,String> cache = Caffeine.newBuilder()
.maximumSize(10)
.expireAfterWrite(10, TimeUnit.SECONDS)
.recordStats()
.build(key -> {
if(key % 6 == 0 ){
return null;
}
return UUID.randomUUID().toString();
});
for (int i = 0; i < 20; i++) {
cache.get(i);
printStats(cache.stats());
}
for (int i = 0; i < 10; i++) {
cache.get(i);
printStats(cache.stats());
}
}
private static void printStats(CacheStats stats){
System.out.println("---------------------");
System.out.println("stats.hitCount():"+stats.hitCount());//命中次数
System.out.println("stats.hitRate():"+stats.hitRate());//缓存命中率
System.out.println("stats.missCount():"+stats.missCount());//未命中次数
System.out.println("stats.missRate():"+stats.missRate());//未命中率
System.out.println("stats.loadSuccessCount():"+stats.loadSuccessCount());//加载成功的次数
System.out.println("stats.loadFailureCount():"+stats.loadFailureCount());//加载失败的次数,返回null
System.out.println("stats.loadFailureRate():"+stats.loadFailureRate());//加载失败的百分比
System.out.println("stats.totalLoadTime():"+stats.totalLoadTime());//总加载时间,单位ns
System.out.println("stats.evictionCount():"+stats.evictionCount());//驱逐次数
System.out.println("stats.evictionWeight():"+stats.evictionWeight());//驱逐的weight值总和
System.out.println("stats.requestCount():"+stats.requestCount());//请求次数
System.out.println("stats.averageLoadPenalty():"+stats.averageLoadPenalty());//单次load平均耗时
}
缓存加载
当缓存未命中或者更新后,需要加载时,可以选择两种加载方式
手动加载
String val1 = cache.getIfPresent("hello");
if (val1 == null) {
cache.put(key, value);
}
在业务中获取对应的数据,若为null,则手动更新
自动加载
private static void demo() {
LoadingCache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.SECONDS)
.maximumSize(500)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
return createExpensiveGraph(key);
}
});
String val1 = cache.get("hello");
}
private static String createExpensiveGraph(String key){
根据id获取更新数据的方法
}
同步加载
同步加载在get获取不到时,使用加载方法获取到的值,直接加载至缓存中
LoadingCache<Integer, Integer> loadingCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(1000)
.build(new CacheLoader<Integer, Integer>() {
@Override
public Integer load(Integer key) {
return key;
}
});
异步加载
使用线程池异步加载数据
// 使用executor设置线程池
AsyncCache<String, String> asyncCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(1000)
.executor(Executors.newFixedThreadPool(5)) //当然也可以使用自定义线程池实现,阿里规约不允许使用Executors创建线程池
.buildAsync();
CompletableFuture<String> completableFuture = asyncCache.get("1", new Function<String, String>() {
@Override
public String apply(String s) {
//执行所在的线程是ForkJoinPool线程池提供的线程
return key;
}
});
completableFuture.get();
如果只是使用caffeine来做全局内存中的缓存,设计好淘汰策略,制定好缓存的前缀使用即可
/**
* 全局缓存工具类
*/
public class CacheUtil {
private static final Cache<Object, Object> cache;
static {
cache = Caffeine.newBuilder()
.initialCapacity(100) // 初始容量
.maximumSize(1000) // 最大容量
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后过期时间
.build();
}
// 防止实例化
private CacheUtil() {
}
public static Cache<Object, Object> getCache() {
return cache;
}
}
redis
redis如何使用缓存就不多讲了,在一篇文章说清楚redis中有讲解
多级缓存
caffeine + redis 实现多级缓存
先说使用场景,尽管内存缓存在微服务场景中需要考虑多实例的缓存一致性问题,但是超热点数据、外部API调用结果、字典数据、计算报表等场景使用多级缓存是非常便利的。
使用Spring Cache框架实现二级缓存,思路是使用redis通知各订阅的服务清除各自的内存缓存
引入依赖
<dependency>
<groupId>com.jincou</groupId>
<artifactId>redis-caffeine-cache-starter</artifactId>
<version>1.0.0</version>
</dependency>
添加配置
# 二级缓存配置
l2cache:
config:
# 是否存储空值,默认true,防止缓存穿透
allowNullValues: true
# 组合缓存配置
composite:
# 是否全部启用一级缓存,默认false
l1AllOpen: false
# 是否手动启用一级缓存,默认false
l1Manual: true
# 手动配置走一级缓存的缓存key集合,针对单个key维度
l1ManualKeySet:
- userCache:user01
- userCache:user02
- userCache:user03
# 手动配置走一级缓存的缓存名字集合,针对cacheName维度
l1ManualCacheNameSet:
- userCache
- goodsCache
# 一级缓存
caffeine:
# 是否自动刷新过期缓存 true 是 false 否
autoRefreshExpireCache: false
# 缓存刷新调度线程池的大小
refreshPoolSize: 2
# 缓存刷新的频率(秒)
refreshPeriod: 10
# 写入后过期时间(秒)
expireAfterWrite: 180
# 访问后过期时间(秒)
expireAfterAccess: 180
# 初始化大小
initialCapacity: 1000
# 最大缓存对象个数,超过此数量时之前放入的缓存将失效
maximumSize: 3000
# 二级缓存
redis:
# 全局过期时间,单位毫秒,默认不过期
defaultExpiration: 300000
# 每个cacheName的过期时间,单位毫秒,优先级比defaultExpiration高
expires: {userCache: 300000,goodsCache: 50000}
# 缓存更新时通知其他节点的topic名称 默认 cache:redis:caffeine:topic
topic: cache:redis:caffeine:topic
项目启动上加启用注解
/**
* 启动类
*/
@EnableCaching
@SpringBootApplication
public class CacheApplication {
}
缓存使用
@Cacheable(key = "'cache_user_id_' + #userId", value = "userCache")
缓存的清理和更新不做说明了,这里是阿里的二级缓存使用案例
jetcache: github.com/alibaba/jet…
在我的项目中,我并没有使用cache框架去实现多级缓存,我是使用caffeine的自动加载去获取redis的对应数据的方式对应单独的业务实现对应的多级缓存功能
package org.springblade.desk.utils;
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springblade.desk.entity.Notice;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
public class NoticeCache {
private RedisTemplate redisTemplate;
private static NoticeCache instance;
private static final String NOTICE_CACHE_ID = "notice:id:";
private final LoadingCache<String, Notice> caffeine = Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, Notice>() {
@Override
public @Nullable Notice load(@NonNull String key) throws Exception {
log.error("加载缓存中, key: {}", key);
return (Notice) redisTemplate.opsForValue().get(key);
}
});
private NoticeCache() {
}
public static synchronized NoticeCache getInstance() {
if (instance == null) {
instance = new NoticeCache();
}
return instance;
}
public void setRedisTemplate(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 获取缓存
*
* @param key 缓存键
* @return 缓存值
*/
public Notice get(String key) {
try {
return caffeine.get(NOTICE_CACHE_ID + key);
} catch (Exception e) {
log.error("获取缓存失效, key: {}, 错误信息: {}", NOTICE_CACHE_ID + key, e.getMessage(), e);
return null;
}
}
/**
* 设置缓存
*
* @param key 缓存键
* @param value 缓存值
*/
public void set(String key, Notice value) {
caffeine.put(NOTICE_CACHE_ID + key, value);
redisTemplate.opsForValue().set(NOTICE_CACHE_ID + key, value);
}
/**
* 清除缓存
*
* @param key 缓存键
*/
public void clear(String key) {
redisTemplate.delete(NOTICE_CACHE_ID + key);
caffeine.invalidate(NOTICE_CACHE_ID + key);
}
}
这里存在一个问题,就是由于是内存缓存和分布式缓存的结合,caffeine我希望是只存在一个实例,不被创建,redis的连接却是可以新建的,所以我采用了set方法注入。
问题解决
缓存穿透
什么是缓存穿透呢?业务系统查询缓存为空,查询数据库也为空,大批量的这种请求会造成数据库的压力过大,造成的原因可能是恶意的访问或者业务逻辑有问题。
解决方案:1.增加过滤器业务库中不存在的key就直接返回null;2.查库返回为null之后直接缓存进行保存,下次请求缓存直接返回null,不访问数据库,为了避免占用缓存空间可以设置过期时间。
//防止缓存穿透
if (noticeCache.get(String.valueOf(notice.getId())+ "-null") == null) {
return R.data(null);
}
//创建缓存实例
Notice detail = noticeCache.get(String.valueOf(notice.getId()));
if (detail == null) {
detail = noticeService.getOne(Condition.getQueryWrapper(notice));
if (detail != null) {
noticeCache.set(String.valueOf(detail.getId()), detail);
}else {
noticeCache.set(String.valueOf(detail.getId() + "-null"), null);
}
}
缓存雪崩
什么是缓存雪崩呢?缓存雪崩就是大批的缓存失效,所有的请求全部都打到了数据库上。造成的原因可能是:1.热门业务的缓存key设置的过期时间是统一的,导致缓存同一时间全部失效,下一刻的请求就全部打到了数据库上。2.redis宕机或者是服务新部署,这时候所有的缓存都不存在,所有的请求都会访问到数据库上,会造成数据库的压力过大
解决方案: 1.防止缓存大批量同时失效,设置过期时间时采用随机时间 2.缓存预热,将一定会使用到的缓存设置到服务启动时存入缓存,避免使用时再存储 3.上文提到的多级缓存,仅仅是redis缓存失效了,并不会影响使用,可以正常走一级缓存,起到了一个安全网的作用 4.redis集群防止宕机,一个redis宕机会造成所有的缓存失效,建立redis集群可以增加缓存的可靠性
缓存击穿
什么是缓存击穿呢?缓存击穿就是某一个key失效了,这时候恰好有大批量的请求并发地访问这个key,导致访问全部打到了数据库上。这种情况一般就是高并发场景下发生,一般来说这么热的缓存,很少遇见。
解决方案: 1.缓存真的很热,就直接设置为不过期,避免出现一损失key就有大批量请求来的情况,可能通用的字典或者用户组织数据这样的缓存会出现这种情况 2.设置互斥锁,缓存失效时,并发的请求去获取互斥锁,未拿到锁的阻塞,拿到锁的去访问数据库,降低数据库的访问压力
序列化问题
序列化问题需要注意,使用不同的序列化方式存入缓存的数据,获取后反序列化经常会出现序列化失败的情况。咱们存入缓存的数据,和取出来的缓存数据一定要统一序列化方式。