一、前言
《间谍过家家》不知道最近大家看了没有,是 B 站新上映的一部动漫,目前拥有非常高的人气。在最新一集里,我们可爱的阿尼亚要入学一所贵族学校了,为了帮助小阿尼亚顺利入学,她的“爸爸”和“妈妈”要在入学考试中表现地非常「优雅」(elegant),才能顺利帮助阿尼亚在残酷的入学考试中胜出,并最终入学这所学校。
这不禁引发了我的思考,作为一名后端开发人员,我们在平时的开发中如何才能写出优雅的代码呢 :)。
我首先想到了缓存。我们在平时的工作中多多少少都会和缓存打交道,Redis 是我们经常使用的缓存组件之一。
不过,直接使用 Redis 不是很方便,因此,我们可能会在项目中集成 Spring Cache 的能力,通过注解的方式使用缓存,免去了直接操作缓存的麻烦。
然而,随着我们系统的进一步发展,单纯使用 Redis 可能并不能满足我们对接口实时性的要求。因此,我们可能会引入本地缓存,如 Caffeine、Guava 等。
根据距离用户的远近,我们可以把离用户较近的 Caffeine、Guava 叫做一级缓存,把离用户较远的 Redis 叫做二级缓存。引入一级缓存后,我们查询数据的流程大概是这样的:
- 先查询一级缓存,如果一级缓存中能查询到数据,从一级缓存中查询数据;
- 如果一级缓存中查询不到数据,从二级缓存中查询数据,并把数据写入到一级缓存中;
- 如果二级缓存中也查询不到数据,从 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 等。
至此,我们的多级缓存管理器的设计思路就比较清晰了:
- 首先,我们需要实现一个自定义的 CacheManager,这个自定义的 CacheManager 需要实现 CacheManager 接口及其相应的方法;
- 接着,我们还需要实现 Cache 接口及其相应的方法,封装我们对 Caffeine 和 Redis 的操作;
- 最后,我们将 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…,有兴趣的朋友可以参考一下。