如何「优雅」地在项目中使用缓存

456 阅读20分钟

一、前言

《间谍过家家》不知道最近大家看了没有,是 B 站新上映的一部动漫,目前拥有非常高的人气。在最新一集里,我们可爱的阿尼亚要入学一所贵族学校了,为了帮助小阿尼亚顺利入学,她的“爸爸”和“妈妈”要在入学考试中表现地非常「优雅」(elegant),才能顺利帮助阿尼亚在残酷的入学考试中胜出,并最终入学这所学校。

这不禁引发了我的思考,作为一名后端开发人员,我们在平时的开发中如何才能写出优雅的代码呢 :)。

我首先想到了缓存。我们在平时的工作中多多少少都会和缓存打交道,Redis 是我们经常使用的缓存组件之一。

不过,直接使用 Redis 不是很方便,因此,我们可能会在项目中集成 Spring Cache 的能力,通过注解的方式使用缓存,免去了直接操作缓存的麻烦。

然而,随着我们系统的进一步发展,单纯使用 Redis 可能并不能满足我们对接口实时性的要求。因此,我们可能会引入本地缓存,如 Caffeine、Guava 等。

根据距离用户的远近,我们可以把离用户较近的 Caffeine、Guava 叫做一级缓存,把离用户较远的 Redis 叫做二级缓存。引入一级缓存后,我们查询数据的流程大概是这样的:

  1. 先查询一级缓存,如果一级缓存中能查询到数据,从一级缓存中查询数据;
  2. 如果一级缓存中查询不到数据,从二级缓存中查询数据,并把数据写入到一级缓存中;
  3. 如果二级缓存中也查询不到数据,从 DB 中查询数据,并把数据写入到二级缓存和一级缓存中。

引入一级缓存后,我们依然期望通过 Spring Cache 的方式使用缓存。但是我们会发现,Spring Cache 默认只能集成一种缓存,不能同时集成两种以上的缓存,并且实现我们上面说的这种查询数据的流程。

此外,由于我们现在的应用基本都是在分布式环境中部署的,当引入一级缓存后,如何保证一级缓存和二级缓存之间的数据一致性,以及如何保证各个应用服务机器之间的一级缓存的数据一致性,也是一个需要思考的问题。

针对上述问题,笔者实现了一个多级缓存管理器,它通过扩展 Spring Cache 的能力,实现对 Caffeine 和 Redis 的同时支持。此外,借助 Redis 的发布/订阅(pub/sub)功能,可以保证各个应用服务机器本地的 Caffeine 和 Redis 的数据一致性。

为了循序渐进地讲述知识,本文会先对 Spring Cache 做一个简单的介绍,然后介绍多级缓存管理器的设计思路、实现原理和使用方式,最后对文章内容做一个总结,并给出代码地址以供参考。

二、一个栗子

在介绍 Spring Cache 之前,我们先来思考一下我们平时开发中是怎么使用缓存的。

假设我们正在开发一个用户服务 UserService,为了提高查询效率,我们使用 Redis 来缓存用户。UserService 代码如下:

@Service
public class UserServiceImpl implements UserService {
  @Autowired
  private UserMapper userMapper;
 
  @Autowired
  private RedisService redisService;
​
  /**
  * 获取用户
  * 
  * @param id 用户 ID
  * @return 用户
  */
  @Override
  public User getUserById(Long id) {
    String cacheKey = "user_" + id;
    User user = redisService.get(cacheKey);
    if (user != null) {
      return user;
   }
    user = userMapper.getUserById(id);
    redisService.set(cacheKey, user);
    return user;
 }
 
  /**
  * 更新用户
  * 
  * @param user 更新后的用户
  */@Override
  public void updateUser(User user) {
    userMapper.updateUser(user);
    String cacheKey = "user_" + user.getId();
    redisService.set(cacheKey , user);
 }
 
  /**
  * 删除用户
  * 
  * @param id 用户 ID
  */
  @Override
  public void deleteUserById(Long id) {
    userMapper.deleteUserById(id);
    String cacheKey = "user_" + id;
    redisService.del(cacheKey);
 }
}

上面的代码虽然简单,但是会存在一些问题:

  • 缓存操作和业务逻辑耦合严重:在我们的用户服务中,对用户的操作是核心逻辑,对缓存的操作是非核心逻辑。但是,在上面的代码实现中,对用户的操作和对缓存的操作交织在一起,导致核心逻辑被埋没在一堆非核心逻辑之中,导致代码的可读性降低;
  • 代码重复:用户服务中对用户的操作有三种:查询、更新、删除。每次操作都需要拼接缓存 key,调用缓存服务的相关 API,进而产生了很多重复代码。此外,如果我们未来新增了一个服务也需要用到缓存,那么同样的缓存操作也会在另一个服务中出现,代码重复的情况会更加严重;
  • 不易扩展:当前用户服务使用了 Redis 作为缓存组件,如果未来我们需要替换缓存组件为 Memcached,那么除了要更改注入的 RedisService 为 MemcachedService,相应的缓存操作的代码都需要修改。

为了解决上述问题,我们可以引入 Spring Cache 来更优雅地使用缓存。

三、Spring Cache 简介

Spring Cache 并不是像 Redis、Caffeine 一样是一个具体的缓存组件,Spring Cache 是一个缓存抽象(Cache Abstraction) ,也就是说,Spring Cache 定义了我们使用缓存的方式,通过 Spring Cache 我们可以更方便地使用缓存。

3.1 Spring Cache 原理

Spring Cache 包括缓存声明和缓存配置两部分:

  • 缓存声明:主要是一些注解的定义,如 @Cacheable、@CachePut、@CacheEvict。其中注解支持 SpEL 表达式和 KeyGenerator,帮助我们实现灵活的缓存定义;
  • 缓存配置:顾名思义,即缓存的配置,如缓存的最大容量、过期时间、淘汰策略等。此外,Spring Cache 内置了一些常用的缓存配置,如 Redis、Caffeine 等,可以做到开箱即用。

有了缓存声明和缓存配置,那么 Spring Cache 是如何对缓存进行操作的呢?没错,就是 AOP。

Spring Cache 会为被注解标注的类或方法创建一个切面,然后在切面中完成对缓存的操作。如此一来,用户就不需要在业务逻辑中关心对缓存的操作了,缓存相关的操作交给 Spring Cache 去做就行。

3.2 Spring Cache 使用

了解了 Spring Cache 是什么,下面我们简单介绍下 Spring Cache 的使用。

虽然也可以通过 API 的方式使用 Spring Cache,但是在平时开发中,我们更多地还是通过注解使用 Spring Cache。Spring Cache 常用的注解有以下三个:

  • @Cacheable:标注在方法上,根据键(通常是方法的某个入参)从缓存中查询数据,如果从缓存中能够查询到数据,返回数据;如果从缓存中查询不到数据,执行方法,并将方法的返回结果放入缓存中;
  • @CachePut:标注在方法上,执行方法后会将方法的返回结果放入缓存中;
  • @CacheEvict:标注在方法上,执行方法后会将缓存中的数据删除。

我们以上面的用户服务为例简单介绍下 Spring Cache 的使用方式。首先引入相关的依赖:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

在 application.yml 中简单配置一下 Redis 连接的相关参数:

redis:
 host: localhost     # Redis 服务器地址
 database: 0         # Redis 数据库索引(默认为0)
 port: 6379          # Redis 服务器连接端口
 password:           # Redis 服务器连接密码(默认为空)
 jedis:
   pool:
     max-active: 8   # 连接池最大连接数(使用负值表示没有限制)
     max-wait: -1ms  # 连接池最大阻塞等待时间(使用负值表示没有限制)
     max-idle: 8     # 连接池中的最大空闲连接
     min-idle: 0     # 连接池中的最小空闲连接
 timeout: 3000ms     # 连接超时时间(毫秒)

然后对缓存做一下配置。这里主要是在配置类中创建并返回一个 CacheManager 的 Bean。Spring Cache 默认集成了一个适用于 Redis 的 CacheManager,即 org.springframework.data.redis.cache.RedisCacheManager,里面做了一些默认的配置,我们这里只需要简单地配置一下过期时间和 key、value 的序列化方式即可:

@EnableCaching
@Configuration
public class CacheConfig {
  @Bean
  public CacheManager cacheManager(RedisConnectionFactory factory) {
      RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
          // 设置缓存的默认过期时间
         .entryTtl(Duration.ofSeconds(60L))
          // 设置 key 序列化方式
         .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
          // 设置 value 的序列化方式
         .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
      return RedisCacheManager.builder(factory)
         .cacheDefaults(configuration)
         .build();
   }
}

注意,这里要在配置类上添加注解 @EnableCaching 以开启缓存功能。

完成对缓存的配置工作后,接下来就是声明对缓存的使用了。这里介绍三种比较常用的注解,它们分别是 @Cacheable、@CachePut、@CacheEvict。

3.2.1 @Cacheable

@Cacheable 一般用于查询数据的场景。我们可以将 @Cacheable 标注在 UserService 类的 getUserById 方法上,就可以使用缓存了。代码如下:

@Cacheable(value = "user", key = "#id")
public User getUserById(Long id) {
   return userMapper.getUserById(userId);
}

可以看到,改造后的代码相比之前简洁了很多,我们只需要在方法中关心获取用户的逻辑即可,读写 Redis 的操作全部由 Spring Cache 帮我们完成了。

这里简单介绍下注解中几个属性的含义:

  • value:缓存名称,和属性 cacheNames 互为别名关系,表示当前方法的结果会被缓存在哪个 Cache 上。Spring Cache 是通过 cacheName 对 Cache 进行隔离的,每个 cacheName 对应一个 Cache。value 可以是数组,绑定多个 Cache。简单来说,你可以把 value 理解为缓存 key 的前缀;
  • key:缓存 key,表示当前方法的返回结果会被缓存在哪个 key 上。该属性支持书写 SpEL 表达式。上例中,#id 表示我们取 id 这个方法入参作为 key;

由于 RedisCacheManager 默认会使用 :: 作为 value 和 key 之间的连接符,因此如果我们查询 id 为 1 的用户,执行完上面的方法后,Redis 中实际会存入一个 key 为 user::1,value 为 User 经过序列化后的 JSON 字符串的记录。

3.2.2 @CachePut

@CachePut 一般用于更新数据的场景。我们可以将 @CachePut 标注在 UserService 类的 updateUser 方法上:

@CachePut(value = "user", key = "#user.id")
public User updateUser(User user) {
    userMapper.updateUser(user);
    return user;
}

和 @Cacheable 不同,被 @CachePut 标注的方法每次都会执行。

有一点需要注意,之前 updateUser 方法的返回值为 void,但是这里需要修改方法的返回值为 User,否则执行完 updateUser 方法后,会缓存一个空对象到当前的 key 上,影响后续的查询。

3.2.3 @CacheEvict

@CacheEvict 一般用于删除数据的场景。我们可以将 @CacheEvict 标注在 UserService 类的 deleteUserById 方法上:

@CacheEvict(value = "user", key = "#id")
public void deleteUserById(Long id) {
    userMapper.deleteUserById(id);
}

当调用 deleteUserById 方法成功后,会删除对应的缓存。这里方法的返回值类型可以是 void。

四、多级缓存管理器

上面我们介绍了 Spring Cache 的相关概念和基本使用,从中我们可以发现,通过 Spring Cache 的方式使用缓存是非常方便的,我们只需要创建并配置一个 CacheManager,然后对需要使用缓存的方法添加相应的注解,就可以使用缓存了。

但是,使用 Spring Cache 有一个限制,那就是我们一次只能配置一个 CacheManager

假如我们现在需要在项目中引入本地缓存,如 Caffeine,并且完成下面这样的查询流程:查询数据时先查询 Caffeine,如果 Caffeine 中能够查询到数据,返回数据;如果 Caffeine 中查询不到数据,查询 Redis,并将数据写入到 Caffeine;如果 Redis 中也查询不到数据,从 DB 中查询数据,并将数据写入到 Caffeine。

为了使用 Spring Cache 完成上面的查询流程,首先,我们需要先引入 Caffeine 的依赖:

<dependency>
 <groupId>com.github.ben-manes.caffeine</groupId>
 <artifactId>caffeine</artifactId>
</dependency>

接着,在配置类中替换之前的 RedisCacheManager 为 CaffeineCacheManager:

@EnableCaching
@Configuration
public class CacheConfig {
    @Bean
    public CacheManager cacheManager(Caffeine caffeine) {
        CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
        caffeineCacheManager.setCaffeine(caffeine);
        return caffeineCacheManager;
    }
}

然后,修改用户服务的代码,在方法中手动地操作 Redis:

@Cacheable(value = "user", key = "#id")
public User getUserById(Long id) {
    String cacheKey = "user_" + id;
    User user = redisService.get(cacheKey);
    if (user != null) {
      return user;
    }
    user = userMapper.getUserById(id);
    redisService.set(cacheKey, user);
    return user;
}

当然,你也可以不替换 RedisCacheManager 为 CaffeineCacheManager,那样就需要在方法中手动地操作 Caffeine。

总之,不管使用哪种方式,我们都需要在业务代码中操作缓存,造成业务逻辑和缓存操作的耦合,重新引入开头那个例子中存在的一系列的问题。

那么,有没有方法可以进一步消除业务代码中的缓存操作,不管是操作 Caffeine 还是操作 Redis 都一并交给 Spring Cache 去管理呢?

有的!我们只需要简单扩展一下 Spring Cache 的能力,实现一个多级缓存管理器即可。

4.1 设计思路

在进行具体的设计之前,我们可以先简单梳理一下这个多级缓存管理器的需求点都有哪些,或者说我们期望它实现的功能都有哪些,帮助我们打开设计思路。

假设我们使用 Caffeine 作为一级缓存,使用 Redis 作为二级缓存。

首先,我们希望这个多级缓存器可以帮助我们实现如下的查询流程:

同时,我们希望可以通过注解的方式使用缓存,这样就不用在业务代码中操作缓存了。

此外,我们希望这个多级缓存管理器可以支持用户做一些配置,比如缓存过期时间、是否开启 Caffeine、是否开启 Redis 等。

另一方面,引入 Caffeine 后,可能会造成 Caffeine 和 Redis 数据不一致的问题,包括应用服务机器本地的 Caffeine 和 Redis 的数据不一致,以及应用服务机器之间的 Caffeine 的数据不一致,我们希望多级缓存管理器可以同时帮我们解决这个问题。

4.2 实现原理

实现上述这么一个多级缓存管理器其实很简单,我们只需要简单地扩展一下 Spring Cache 的能力即可。

具体来说,我们需要实现一个自定义的 CacheManager。Spring Cache 的 CacheManager 是一个接口,定义如下:

public interface CacheManager {

	@Nullable
	Cache getCache(String name);

	Collection<String> getCacheNames();

}

CacheManager 接口中包含两个方法,getCache 和 getCacheNames:

  • getCache:根据 name 获取一个 Cache;
  • getCacheNames:获取当前 CacheManager 管理的所有 Cache 的 name。

其中,name 就是我们前面说的缓存名称,而 Cache 是 Spring Cache 的一个接口,封装了对缓存的一些操作。Cache 接口的定义如下:

public interface Cache {

	@Nullable
	ValueWrapper get(Object key);

	void put(Object key, @Nullable Object value);

	void evict(Object key);

	...
  
}

可以看到,Cache 中封装了一些操作缓存的方法,如 get、put、evict 等。

至此,我们的多级缓存管理器的设计思路就比较清晰了:

  1. 首先,我们需要实现一个自定义的 CacheManager,这个自定义的 CacheManager 需要实现 CacheManager 接口及其相应的方法;
  2. 接着,我们还需要实现 Cache 接口及其相应的方法,封装我们对 Caffeine 和 Redis 的操作;
  3. 最后,我们将 CacheManager 注册到 Spring 容器中,做一些配置,然后对需要使用缓存的方法添加相应的注解就可以使用缓存了。

4.2.1 核心类和方法

我们的多级缓存管理器大概有以下几个类:

  • MultiLevelCacheManager:多级缓存管理器,CacheManager 接口的实现类
  • MultiLevelCache:多级缓存,Cache 接口的实现类
  • MultiLevelCacheConfig:封装一些配置

这里我们重点看下 MultiLevelCacheManager 和 MultiLevelCache 这两个类。

MultiLevelCacheManager

MultiLevelCacheManager 的核心逻辑如下:

public class MultiLevelCacheManager implements CacheManager {
    private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);

    @Overridepublic Cache getCache(String name) {
        Cache cache = cacheMap.get(name);
        if (cache != null) {
            return cache;
        }
        cache = createMultiLevelCache(name);
        cacheMap.put(name, cache);
        return cache;
    }

    @Override
    public Collection<String> getCacheNames() {
        return Collections.unmodifiableSet(cacheMap.keySet());
    }

    private Cache createMultiLevelCache(String name) {
        return new MultiLevelCache(name, caffeineCache, stringKeyRedisTemplate, multiLevelCacheConfig);
    }
}

这里我们主要看下 getCache 方法。getCache 方法的逻辑其实很简单,首先判断 cacheMap 中是否有 key 为 name 的 Cache,如果有就返回,没有的话就创建一个 Cache,放入 cacheMap 中,然后返回这个 Cache。

MultiLevelCache

MultiLevelCache 是 Cache 接口的实现类。不过这里我们并没有选择直接实现 Cache 接口,而是继承抽象类 org.springframework.cache.support.AbstractValueAdaptingCache,AbstractValueAdaptingCache 提供了 Cache 接口中一些方法的默认实现。

MultiLevelCache 的核心逻辑如下:

public class MultiLevelCache extends AbstractValueAdaptingCache {
    private static final String JOINER = "::";

    private final String name;
    private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;
    private final RedisTemplate<Object, Object> stringKeyRedisTemplate;

    @Override
    protected Object lookup(Object key) {
        Object value = caffeineCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
      	Object redisKey = getRedisKey(key);
        value = stringKeyRedisTemplate.opsForValue().get(redisKey);
        if (value != null) {
            caffeineCache.put(key, value);
        }
        return value;
    }

    @Override
    public void put(Object key, Object value) {
        if (!super.isAllowNullValues() && value == null) {
            this.evict(key);
            return;
        }
        value = toStoreValue(value);
        Duration expire = getExpire();
        stringKeyRedisTemplate.opsForValue().set(getRedisKey(key), value, expire);
        caffeineCache.put(key, value);
    }

    @Override
    public void evict(Object key) {
        stringKeyRedisTemplate.delete(getRedisKey(key));
        caffeineCache.invalidate(key);
    }

    private Object getRedisKey(Object key) {
        return name.concat(JOINER).concat(String.valueOf(key));
    }

    private Duration getExpire() {
        ...
    }
}

这里我们主要看下 lookup 方法、put 方法和 evict 方法。

lookup 方法顾名思义,就是根据某一个 key 查找对应的 value。它是 AbstractValueAdaptingCache 类中的方法,在执行 get 方法时会调用 lookup 方法:

@Override
@Nullable
public ValueWrapper get(Object key) {
  	return toValueWrapper(lookup(key));
}

在 lookup 方法中,我们实现了查询 Caffeine 和 Redis 的逻辑,即先查询 Caffeine,如果 Caffeine 中能够查询到数据,返回数据;如果 Caffeine 中查询不到数据,查询 Redis,并把数据写入到 Caffeine 中,最后返回数据。

put 方法实现了将数据放入缓存的操作。首先判断 value 是否为空,如果 value 为空并且配置中不允许缓存空值,我们将对应的缓存清除,接着分别往 Redis 和 Caffeine 中放入缓存。在往 Redis 中放入缓存时可以设置一个过期时间,过期时间可以在 MultiLevelCacheConfig 中进行配置。

evict 方法即清除缓存。这里我们选择先清除 Redis 中的缓存,再清除 Caffeine 中的缓存,这是因为,如果先清除 Caffeine 中的缓存再清除 Redis 中的缓存,在一些并发场景下,可能会造成在清除 Caffeine 缓存之后并且在清除 Redis 缓存之前,有的请求查询 Caffeine 中的数据查询不到,去查询 Redis,导致把 Redis 中即将要被清除的脏数据写入到 Caffeine 中了。

4.2.2 数据一致性保证

通过上面的代码,我们已经实现了多级缓存管理器的基本功能,但是还有一个重要的问题没有解决,即如何保证数据一致性。这里我们通过 Redis 的发布/订阅功能实现对数据一致性的保证。

下面我们先对 Redis 的发布/订阅功能做一个简单的介绍,然后介绍下如何在我们的多级缓存管理器中集成这个功能。

Redis 发布/订阅简介

Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。

Redis 的 SUBSCRIBE 命令可以让客户端订阅任意数量的频道(channel), 每当有新的消息发送到被订阅的频道时,消息就会被发送给所有订阅该频道的客户端。

Redis 有两种发布/订阅模式,它们分别是基于频道的发布/订阅模式和基于模式(pattern)的发布/订阅模式。由于我们项目中使用的是基于频道的发布/订阅模式,因此下面我们主要介绍基于频道的发布/订阅模式。

下图展示了基于频道的发布/订阅模式客户端和服务器的关系:

上图中,我们在 Redis 中开启了一个频道 channel1,有三个客户端订阅了 channel1,分别是 client1、client2、client3。

当有新消息通过 PUBLISH 命令发送到 channel1 时, 这个消息就会被发送给订阅它的三个客户端:

如果我们的应用是基于 Spring Boot 的,那么使用该功能也非常简单。我们只需要配置一个 org.springframework.data.redis.listener.RedisMessageListenerContainer,在其中注册监听消息并执行的 org.springframework.data.redis.connection.MessageListener,然后在需要发送消息的地方调用 RedisTemplate#convertAndSend 方法即可。示例代码如下:

// 配置
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(xxxMessageListenerAdapter, "my_topic_name");

// 发送消息
redisTemplate.convertAndSend("my_topic_name", "message_content");
集成方式

在我们的多级缓存管理器中,我们期望每次更新 Redis 时,删除所有应用服务机器本地的 Caffeine 中对应的数据,避免 Redis 和 Caffeine 之间出现数据不一致的情况。

首先,我们在配置类中配置一个类型为 RedisMessageListenerContainer 的 Bean:

@Bean
public RedisMessageListenerContainer cacheMessageListenerContainer(RedisTemplate<Object, Object> stringKeyRedisTemplate, MultiLevelCacheManager cacheManager, MultiLevelCacheConfig config) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(Objects.requireNonNull(stringKeyRedisTemplate.getConnectionFactory()));
    MultiLevelCacheMessageListener listener = new MultiLevelCacheMessageListener(stringKeyRedisTemplate, cacheManager);
    container.addMessageListener(listener, new ChannelTopic(config.getRedisTopic()));
    return container;
}

接着,我们实现 MultiLevelCacheMessageListener 这个类。这个类需要实现 MessageListener 这个接口,接口中只包含一个方法 onMessage,我们在 MultiLevelCacheMessageListener 中实现这个方法即可:

@Override
public void onMessage(Message message, byte[] pattern) {
    MultiLevelCacheMessage cacheMessage = (MultiLevelCacheMessage) redisTemplate.getValueSerializer().deserialize(message.getBody());
    if (cacheMessage == null) {
      return;
    }
    cacheManager.clearLocal(cacheMessage.getCacheName(), cacheMessage.getKey());
}

在 onMessage 方法中,我们首先拿到 Redis 发过来的消息并包装成 MultiLevelCacheMessage,MultiLevelCacheMessage 的定义很简单,仅仅包括一个缓存名称 cacheName 和一个 key:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MultiLevelCacheMessage {
    private String cacheName;
    private Object key;
}

然后,我们调用 CacheManager#clearLocal 方法,首先根据 cacheName 找到对应的 Cache,然后根据 key 清除 Caffeine 中的缓存。代码也比较简单,这里就不贴了,有兴趣的朋友可以看从文末下载代码查看。

最后说下发送消息这块,当更新 Redis 时,我们需要发送一条消息到 Redis。具体到 MultiLevelCache 中就是 put、evict、clear 这三个方法。

我们以 put 方法为例,添加发送消息的逻辑:

@Override
public void put(Object key, Object value) {
    ...
    stringKeyRedisTemplate.opsForValue().set(getRedisKey(key), value, expire);
  	stringKeyRedisTemplate.convertAndSend(topic, new MultiLevelCacheMessage(name, key));
    ...
}

这样,当 Redis 中的缓存更新时,所有客户端都会收到消息,并清除本地 Caffeine 中对应的缓存。

4.3 如何使用

上面我们对多级缓存管理器的实现原理做了一些简单的介绍,其实更多地是在向大家讲述设计这样一个多级缓存管理器的思想,大家完全可以基于上面的思路,并结合自己项目的特点,实现自己的一个多级缓存管理器。

不过基于上面的实现,我们这里也可以简单地把这个多级缓存管理器用起来。

使用这个多级缓存管理器的方式非常简单,我们只需要在配置类中配置下 Caffeine、Redis、MultiLevelCacheConfig、RedisMessageListenerContainer、MultiLevelCacheManager 即可。一个简单的配置示例如下:

@EnableCaching
@Configuration
public class CacheConfig {
    @Bean
    public Cache<Object, Object> caffeineCache() {
        return Caffeine.newBuilder()
                .initialCapacity(128)
                .maximumSize(1024)
                .expireAfterWrite(30, TimeUnit.SECONDS)
                .build();
    }

    @Beanpublic RedisTemplate<Object, Object> stringKeyRedisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }

    @Bean
    public MultiLevelCacheConfig cacheConfig() {
        return new MultiLevelCacheConfig();
    }

    @Beanpublic MultiLevelCacheManager cacheManager(Cache<Object, Object> caffeineCache,
            RedisTemplate<Object, Object> stringKeyRedisTemplate, MultiLevelCacheConfig config) {
        return new MultiLevelCacheManager(caffeineCache, stringKeyRedisTemplate, config);
    }

    @Beanpublic RedisMessageListenerContainer cacheMessageListenerContainer(RedisTemplate<Object, Object> stringKeyRedisTemplate, MultiLevelCacheManager cacheManager, MultiLevelCacheConfig config) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(stringKeyRedisTemplate.getConnectionFactory());
        MultiLevelCacheMessageListener listener = new MultiLevelCacheMessageListener(stringKeyRedisTemplate, cacheManager);
        container.addMessageListener(listener, new ChannelTopic(config.getRedisTopic()));
        return container;
    }
}

然后在我们的业务代码中就可以使用 @Cacheable 等注解使用缓存了:

@Cacheable(value = "user", key = "#id")
public User getUserById(Long id) {
    return userMapper.getUserById(userId);
}

这样一来,业务代码中和缓存相关的操作就通通被去掉了,代码是不是变得更「优雅」了?

五、总结

本文首先介绍了 Spring Cache 的相关概念,通过 Spring Cache,我们可以方便地使用缓存,解除了业务代码和缓存操作代码的耦合。不过,随着我们系统的发展,我们除了会在项目中使用像 Redis 这样的一级缓存,也会使用像 Caffeine 这样的二级缓存,以进一步降低接口响应的时间。然而,Spring Cache 默认只支持集成一种缓存组件,不能同时支持 Caffeine 和 Redis。为了解决这个问题,我们通过扩展 Spring Cache 的能力,开发了一个多级缓存管理器,它可以帮助我们完成查询 Caffeine 和 Redis 的逻辑,同时提供 Caffeine 和 Redis 之间数据一致性的保证。

这是样例代码:github.com/sixunguidia…,有兴趣的朋友可以参考一下。

六、参考