文章目录

  • 背景
  • Caffeine 简介
  • 相对于Guava Cache优化点
  • 整合
  • 缓存配置
  • 缓存使用
  • 自定义缓存删除注解
  • reids 事件监听删除缓存
  • 测试
  • 测试类
  • 测试结果
  • 总结
  • 源码下载


背景

为什么我们明明有了分布式缓存redis,还要将本地缓存多此一举整合为分布式缓存呢。原因很简单,性能。不管redis多块,都需要网络请求,io耗时,如果使用本地缓存基本没有耗时。

Caffeine 简介

官方文档 Caffeine 是基于 JAVA 8 的高性能缓存库。并且在 spring5 (springboo 2.x) 后,spring 官方放弃了 Guava,而使用了性能更优秀的 Caffeine 作为默认缓存组件

关于性能测试,Caffine 官方也给了一份图片

lettuce与redisson冲突 redis和caffeine_lettuce与redisson冲突


这里只给出了一个性能测试的图片,可以看到Caffeine的性能是最好的,更多详细数据可以参考官网

lettuce与redisson冲突 redis和caffeine_memcached_02

相对于Guava Cache优化点

Guava Cache 本质上还是使用了LRU淘汰算法
在了解LRU算法前。我们先了解几个常用的缓存淘汰算法

  1. FIFO(First-In, First-Out):先进先出,在这种淘汰算法中,先进入缓存的会先被淘汰,会导致命中率很低。
  2. LRU(Least Recently Used):最近最少使用算法,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。仍然有个问题,如果有个数据在 1 分钟访问了 1000次,再后 1 分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。

像Mysql就使用了LRU算法,不同的是MySql对LRU算法做了一定的优化,对数据进行冷热分离,将 LRU 链表分成两部分,一部分用来存放冷数据,也就是刚从磁盘读进来的数据,另一部分用来存放热点数据。当从磁盘读取数据页后,会先将数据页存放到 LRU 链表冷数据区的头部,如果这些缓存页在 1 秒之后被访问,那么就将缓存页移动到热数据区的头部;如果是 1 秒之内被访问,则不会移动,缓存页仍然处于冷数据区中。因为Mysql读书数据是按数据页读取,还有预读操作,所以作了如下优化,感兴趣可以自己去详细了解

  1. LFU(Least Frequently Used):最近最少频率使用,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了 LRU 不能处理时间段的问题。但是可能存在缓存前期缓存次数过多,后期没人访问了,因为前期访问次数太多,导致一直不被淘汰。

总之以上三种淘汰算法有利有弊。但是后续由前Google工程师发明的W-TinyLFU淘汰算法,提供了一个近乎最佳的命中率。Caffine Cache就是基于此算法而研发。
W-TinyLFU是如何解决LFU算法的缺点的呢?感兴趣可以看这里
https://jishuin.proginn.com/p/763bfbd358a0

这里限于篇幅就不作过多使用

整合

这里我们首先加入需要用到的依赖

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>
        <dependency>
   			<groupId>org.redisson</groupId>
   			<artifactId>redisson</artifactId>
   			<version>3.16.2</version>
		</dependency>

版本自行选择

缓存配置

  • CacheConfig
@EnableCaching
@Configuration
public class CacheConfig {
    /**
     * 节点同步缓存的name和key的分隔符
     */
    public static final String DOUBLE_COLON = "::";
    /**
     * 默认缓存时间
     */
    private static final long DEFAULT_TTL = 30;
    /**
     * 默认最大条数
     */
    private static final long MAXIMUM_SIZE = 10000;
    /**
     * 默认分钟
     */
    private static final TimeUnit DEFAULT_TIME_UNIT = TimeUnit.MINUTES;

    @Bean
    @Primary
    public CacheManager caffeineCacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        List<CaffeineCache> cacheBeans = getCacheBeans();
        if (DataUtils.isNotEmpty(cacheBeans)) {
            cacheManager.setCaches(cacheBeans);
        }
        return cacheManager;
    }

    /**
     * 把 缓存 CaffeineCache 配置到添加到这个list中
     * @return
     */
    private List<CaffeineCache> getCacheBeans() {
        List<CaffeineCache> list = new ArrayList<>();
        list.add(CacheConfig.builderCaffeineCache(CacheConstants.TEST_CACHE));
        return list;
    }



    // 构造缓存
    public static CaffeineCache builderCaffeineCache(String name, long ttl, TimeUnit unit, long maxSize) {
        return new CaffeineCache(name, Caffeine.newBuilder().expireAfterWrite(ttl, unit).maximumSize(maxSize).build());
    }

    public static CaffeineCache builderCaffeineCache(String name, long ttl) {
        return new CaffeineCache(name, Caffeine.newBuilder().expireAfterWrite(ttl, DEFAULT_TIME_UNIT).maximumSize(MAXIMUM_SIZE).build());
    }

    public static CaffeineCache builderCaffeineCache(String name) {
        return new CaffeineCache(name, Caffeine.newBuilder().expireAfterWrite(DEFAULT_TTL, DEFAULT_TIME_UNIT).maximumSize(MAXIMUM_SIZE).build());
    }



}

@EnableCaching代表开启Spring Cache,这里缓存管理器我们使用SimpleCacheManager而不是CaffeineCacheManager,因为SimpleCacheManager配置更为灵活,可以为每个缓存配置相应的失效时间、策略等

spring cache对缓存的管理定义了一个接口CacheManager 总共有如下实现类

lettuce与redisson冲突 redis和caffeine_redis_03


其中AbstractCacheManager又是一个抽象类,继承了AbstractCacheManager有如下类

lettuce与redisson冲突 redis和caffeine_redis_04


由于CacheManager众多,我就不一一说明了,感兴趣可以去官网自己了解。我这里偷一张网图说明下

lettuce与redisson冲突 redis和caffeine_lettuce与redisson冲突_05

图片来源:

对于缓存的配置,后续就直接加到我们设置的一个list中

List<CaffeineCache> list = new ArrayList<>();

缓存使用

对于缓存的使用,spring提供了如下注解

  • @Cacheable : 根据方法对其返回结果进行缓存,下次请求时,如果缓存存在,则直接读取缓存数据返回;如果缓存不存在,则执行方法,并把返回的结果存入缓存中。一般用在查询方法上
  • @CachePut:使用该注解标志的方法,每次都会执行,并将结果存入指定的缓存中。其他方法可以直接从响应的缓存中读取缓存数据,而不需要再去查询数据库。一般用在新增方法上
  • @CacheEvict: 使用该注解标志的方法,会清空指定的缓存。一般用在更新或者删除方法上
  • @Caching:组合注解,可以组合上面的多个注解
    其源码:
public @interface Caching {
	Cacheable[] cacheable() default {};
	CachePut[] put() default {};
	CacheEvict[] evict() default {};
}

这里我们主要使用@Cacheable注解,由于需要改造成分布式缓存。我们这里自定义一个注解

自定义缓存删除注解

  • DeleteCache
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DeleteCache {

    String value();
    String key() default "";
}
  • CacheAspect
    然后定义一个切面去删除缓存
@Aspect
@Component
@Slf4j
public class CacheAspect {

    @Autowired
    private RedissonClient redissonClient;

    @Autowired
    FeishuRobot feishuRobot;

    @Pointcut("@annotation(com.zou.component.annotation.DeleteCache)")
    public void fun() {}

    /**
     * 主要是做节点同步。若没有cacheKey将会把整个缓存Map给清除掉
     */
    @AfterReturning(value = "fun()", returning = "object")
    public void doAfter(JoinPoint joinPoint, Object object) {
        RTopic topic = redissonClient.getTopic(RedisCache.CACHE_TOPIC);
        CacheManager cacheManager = SpringUtils.getBean("caffeineCacheManager", CacheManager.class);
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        DeleteCache deleteCache = method.getAnnotation(DeleteCache.class);
        String value = deleteCache.value();
        String key = deleteCache.key();
        Object cacheKey = ExpressionUtil.isEl(key) ? ExpressionUtil.parse(deleteCache.key(), method, joinPoint.getArgs()) : key;
        String pushMsg;

        Cache cache = cacheManager.getCache(value);
        if (DataUtils.isEmpty(cache)) return;

        if (DataUtils.isEmpty(cacheKey)) {
            cache.clear();
            pushMsg = value + CacheConfig.DOUBLE_COLON;
        } else {
            cache.evict(cacheKey);
            pushMsg = value + CacheConfig.DOUBLE_COLON + cacheKey;
        }
        try {
            // todo 后续是否对多节点返回数据监控
            topic.publish(pushMsg);
        } catch (Exception e) {
            log.info("分布式缓存刷新通知异常,缓存 {}", value, e);
            // 缓存删除失败监控
        }
    }

}

这里首先aop自已删除一次缓存,然后基于redis发布订阅发布消息多节点删除缓存。

  • ExpressionUtil
    ExpressionUtil主要是对el表达式作解析使用,不是本文的重点
public class ExpressionUtil {

    /**
     * el表达式解析
     *
     * @param expressionString 解析值
     * @param method           方法
     * @param args             参数
     * @return
     */
    public static Object parse(String expressionString, Method method, Object[] args) {
        if (DataUtils.isEmpty(expressionString)) {
            return null;
        }
        //获取被拦截方法参数名列表
        LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
        String[] paramNameArr = discoverer.getParameterNames(method);
        //SPEL解析
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i < Objects.requireNonNull(paramNameArr).length; i++) {
            context.setVariable(paramNameArr[i], args[i]);
        }
        return parser.parseExpression(expressionString).getValue(context);
    }

    public static boolean isEl(String param) {
        return Objects.equals(param.substring(0, 1), "#");
    }
}

reids 事件监听删除缓存

  • RedisCacheListener
@Component
@Slf4j
public class RedisCacheListener implements ApplicationRunner, Ordered {

    @Autowired
    private RedissonClient redisson;

    @Override
    public void run(ApplicationArguments applicationArguments){
        RTopic topic = redisson.getTopic(RedisCache.CACHE_TOPIC);
        topic.addListener(String.class, (channel, msg) -> {
            CacheManager cacheManager = SpringUtils.getBean("caffeineCacheManager", CacheManager.class);
            String[] split = msg.split(CacheConfig.DOUBLE_COLON);
            Cache cache = cacheManager.getCache(split[0]);
            evictOrClear(cache, split);
            log.info("{} 缓存清除完成", msg);
        });
    }

    private void evictOrClear(Cache cache, String[] split) {
        Objects.requireNonNull(cache);
        if (split.length > 1) {
            cache.evict(split[1]);
        } else {
            cache.clear();
        }
    }

    @Override
    public int getOrder() {
        return 1;
    }
}

至此就整合完成

测试

@Service
public class UserCacheService {


    /**
     * 查找
     * 先查缓存,如果查不到,会查数据库并存入缓存
     */
    @Cacheable(value = CacheConstants.TEST_CACHE, key = "#id", sync = true)
    public User getUser(Long id){
        System.out.println("查询数据库:" + id);
        User user = new User();
        user.setId(id);
        user.setName("test");
        return user;
    }

    /**
     * 更新/保存
     */
    @DeleteCache(value = CacheConstants.TEST_CACHE, key = "#user.id")
    public void saveOrUpdateUser(User user){
        System.out.println("保存或更新数据库" + user.getId());
    }

    /**
     * 删除
     */
    @DeleteCache(value = CacheConstants.TEST_CACHE, key = "#user.id")
    public void delUser(User user){
        System.out.println("删除数据库");
    }

    /**
     * 删除
     */
    @DeleteCache(value = CacheConstants.TEST_CACHE, key = "#id")
    public void delUser(Long id){
        System.out.println("删除数据库");
    }

}

测试类

@Test
    public void test() throws Exception{
        Long id = 1L;
        User user = userCacheService.getUser(id);
        User user2 = userCacheService.getUser(id);
        System.out.println("查询其他数据");
        User user3 = userCacheService.getUser(2l);
        User user4 = userCacheService.getUser(2l);
        System.out.println("进行删除");
        // 删除 id为1的数据
        userCacheService.delUser(user);
        User user7 = userCacheService.getUser(1l);
        User user5 = userCacheService.getUser(2l);
        User user6 = userCacheService.getUser(2l);



    }

测试结果

lettuce与redisson冲突 redis和caffeine_缓存_06

总结

这种分布式缓存各个节点之间缓存同步没有作强一致性,所以如果有强一致性的场景还是推荐使用redis。本地缓存唯一优点就是比redis快,其次自定义注解改为了分布式删除,分布式通知采用redis发布订阅。可能存在redis异常导致缓存不正确,这种情况暂时不处理,只是加了简单的监控。

源码下载

后续有需要代码会上传到github