redis + caffeine 缓存应用

636 阅读12分钟

最近使用缓存很多,所以想要对缓存做一次总结

缓存

使用缓存前,首先要考虑是否真的需要缓存

最近我们项目的一个接口被发现超时,排查发现接口的入参常常为空全查数据,接口里除了全查数据库还有递归等数据处理。这时候其实大部分请求都是需要同样的全量的数据,所以可以引入缓存。

缓存可以分为进程内缓存和分布式缓存两种

进程内缓存: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学习

大概的实现如图所示:

image.png

缓存的实现内部包含一个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 实现多级缓存 1721282963188.png 先说使用场景,尽管内存缓存在微服务场景中需要考虑多实例的缓存一致性问题,但是超热点数据、外部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方法注入。

问题解决

缓存穿透

什么是缓存穿透呢?业务系统查询缓存为空,查询数据库也为空,大批量的这种请求会造成数据库的压力过大,造成的原因可能是恶意的访问或者业务逻辑有问题。

1724056745961.png

解决方案: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.设置互斥锁,缓存失效时,并发的请求去获取互斥锁,未拿到锁的阻塞,拿到锁的去访问数据库,降低数据库的访问压力

序列化问题

序列化问题需要注意,使用不同的序列化方式存入缓存的数据,获取后反序列化经常会出现序列化失败的情况。咱们存入缓存的数据,和取出来的缓存数据一定要统一序列化方式。