這里提到的緩存是通用的緩存,如表格獲取時優先查詢redis,如果有,則讀取 redis 中的數據,如果沒有,讀取數據庫,并將返回結果存入 redis 中,然后新增/修改/刪除等操作時,需要將對應的緩存數據清空,以保證每次獲取都是最新的數據。當然,有其他需求的時候,也可以直接通過 redisTemplate來對 redis 進行增刪改查,這個做法跟 MySQL 等關系型數據庫是差不多的,關于 redis 操作的部分因為不是這篇文章的重點,所以這里就不贅述了。
這里的實現思路有兩種,第一種是完全通過 spring AOP面向切面編程,給 select 做一層切面,給 save/update/delete 做一層切面,來完成上面說的功能。第二種是Spring的 cache 庫(其實還是面向切面的技術),輔以 spring AOP,可以大大簡化切面編程部分代碼。
首先是要導入相關 jar 包,我現在項目都是 spring-boot 的,所以jar 包帶有 spring-boot 前綴的,如果不是 spring-boot 的,直接找對應 spring 版本就可以了,都是比較通用的 jar 包,也不難找。
<!-- redis —>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- cache 緩存 —>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
首先是cache 和 redis 的配置,我這里將兩個配置寫在了一個類中
@Configuration
@EnableCaching
@PropertySource(value = "classpath:/application.properties")
public class CacheConfig extends CachingConfigurerSupport {
/**
*
* @return
*/
@Bean
public CacheManager cacheManager(RedisTemplate redisTemplate) {
RedisCacheManager manager = new RedisCacheManager(redisTemplate);
//設置統一的過期時間
manager.setDefaultExpiration(120l);
//可在 map 中給不同的 key 設置對應的過期時間
// manager.setExpires(new HashMap<String, Long>());
return manager;
}
@Value("${spring.redis.host}")
String hostName;
@Value("${spring.redis.password}")
String password;
@Value("${spring.redis.port}")
Integer port;
@Value("${spring.redis.timeout}")
Integer timeout;
@Bean
JedisConnectionFactory jedisConnectionFactory() {
JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
jedisConnectionFactory.setHostName(hostName);
jedisConnectionFactory.setPort(port);
jedisConnectionFactory.setPassword(password);
jedisConnectionFactory.setDatabase(0);
jedisConnectionFactory.setTimeout(timeout);
return jedisConnectionFactory;
}
}
這個類有幾個地方要注意的,首先是類的注解,@configuration 是 spring-boot 將類裝配成 bean 的注解,@EnableCaching 是聲明開啟緩存功能的注解,@PropertySource 是引用配置文件的注解
配置類里面有兩個 bean,cacheManager是給 cache 庫配置緩存的實際位置,因為 spring-cache 可以搭配的緩存很多,也就有很多cacheManager 可以使用,我們這里用的是 redis,所以用的是 RedisCacheManager。
這里可以給緩存上過期時間,但是這里要注意一下這個過期時間指的是通過 cache類庫添加進 Redis 緩存的過期時間,也就是@Cacheable 和@CachePut 這兩個注解。如果單獨使用 RedisTemplate 添加緩存,是要另外設置過期時間的。另外一個bean是 Redis 的配置方法。
然后簡單介紹一下 cache 庫,這個庫是 Spring的緩存庫,使用起來也是非常簡單,通過幾個使用在類或方法上面的注解即可達成目的
@Cacheable //通過方法的查找是優先查找緩存,如果找到,方法不會執行,當找不到時會去找數據庫,并將返回結果放入緩存
@CachePut //通過方法的返回值會放入緩存中,無論緩存中是否有值,方法一定會被執行
@CacheEvict //清除緩存中的一個或多個條目
@Cacheing //這是一個分組的注解,能夠同時應用多個其他的緩存注解
這里只是簡單介紹一下這幾個注解的含義,這些內容在其他文章中也都有比較詳實的說明,這里也不過多的介紹了。
著重要說的是@Cacheable 注解,這個注解是可以達到查找時優先查找緩存,當緩存中沒有才去訪問數據庫功能的,這個功能實際是通過 AOP 來完成的,所以我們也可以自己寫相關代碼,不過既然有現成的,那直接調用是極好的。
@Cacheable 注解有幾個屬性,對緩存有著較大的影響(這些屬性其實這幾個注解都有,但是上面幾個注解有缺陷,下面會講)。
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Cacheable {
String key() default "";
String keyGenerator() default "";
String condition() default "";
String unless() default "";
}
這里只截取了 Cacheable 注解中部分屬性,其他屬性具體功效可以看源碼,介紹的也都比較詳實。因為 redis 是鍵值對數據庫,存儲模型都是 key-value 格式的,所以要存入緩存,得要有對應的 key。key 有兩種生成方式,第一種是直接指定,這種方式一般用于某些定制化的緩存當中,通用返回接口的緩存得要用第二種方式,也就是通過鍵生成器來生成緩存的 key。condition 和 unless 的區別是,condition 是條件成立時,存入緩存;unless 是排除值不成立的,存入緩存。這兩者看起來是一個意思,但是在邏輯上來說還是有細微差別的,一個是阻止存入緩存(unless),但是還是會在緩存中查找;另外一個是先判斷 condition 的條件(condition),如果不成立,則不會查找緩存,也不會存入緩存。以上的 key,condition,unless 都是使用的 SpEL 表達式,后面會在實例中有一些簡單的介紹,詳細的說明也可通過其他文章學習。
在這些屬性中,key 的生成格外重要,要能夠通用,不必為每個需要用緩存的方法都手動生成 key,又要保證唯一性,因為不止是方法名會影響 key 值,每次調用時參數的不一致 都應該是不同的 key。這里keyGenerator很好的幫我們完成了這樣的工作。
可以使用Spring默認的SimpleKeyGenerator來生成 key,但是這個 key 是不會將函數名組合在 key 中,也是有缺陷,所以我們需要自定義一個 keyGenerator。
@Override
public Object generate(Object target, Method method, Object... params) {
Logger log = Logger.getLogger("Keygenerate");
StringBuilder key = new StringBuilder();
key.append(method.getDeclaringClass().getName()).append(".").append(method.getName()).append(":"); //先將類的全限定名和方法名拼裝在 key 中
if (params.length == 0) {
return key.append(NO_PARAM_KEY).toString();
}
for (Object param : params) { //通過遍歷參數,將參數也拼裝在 key 中,保證每次獲取key 的唯一性
if (param == null) {
key.append(NULL_PARAM_KEY);
} else if (ClassUtils.isPrimitiveArray(param.getClass())) {
int length = Array.getLength(param);
for (int i = 0; i < length; i++) {
key.append(Array.get(param, i));
key.append(',');
}
} else if (ClassUtils.isPrimitiveOrWrapper(param.getClass()) || param instanceof String) {
key.append(param);
} else {
key.append(param.hashCode()); //如果是map 或 model 類型
}
key.append('-');
}
return key.toString();
}
我是將實現了 KeyGenerator 的類放在了 Spring 的上下文中,這樣子只要是使用了@Cacheable 注解的方法都可以根據規則來生成唯一的 key 了。
當然實際項目中還是有可能是會需要針對特定的方法來生成 key 的,下面給一個實例來說明:
@Cacheable(unless = "#result == '{\"data\":[],\"total_count\":0}'",key = "'keyGenerator' + '.' + #methodName + ':' + #id")
這是一個帶有 unless 和 key 的注解,實現的功能是查詢,當沒有值時不存入緩存。
到此我們已經可以通過注解將獲取到的值存入緩存,并直接通過緩存將值獲取出來,不用額外的調用數據庫了,接下來的工作是當 save/update/delete 等操作時,將相關數據從緩存中清除,以保證獲取數據的準確性。
cache 庫的注解中是有可以實現這個功能的,@CacheEvict。但是當我們通過 KeyGenerator 來自定義生成 key 時,@CacheEvict 便無法獲取對應的 Key,則無法正確清除相關緩存。舉個栗子,有用戶表和訂單表,用戶表有一次查詢,在緩存中生成這次查詢的緩存,訂單表也有一次查詢,在緩存中生成這次查詢的緩存。這時新增一條訂單,需要清空訂單相關的全部緩存,否則會出現獲取數據不一致的情況。但是如果通過@CacheEvict 來做,是無法知道目前 Redis 中關于訂單緩存的 Key 的,當然可以全部清空,這樣會把用戶表的緩存也清空掉,這必然是不好的。
那為了實現相關操作只清空相關的所有緩存,這里采取 AOP 的方式,對 save/update/delete 切面,當有這些操作的時候,就清空對應的緩存。
這里有兩種做法,第一種直接將切點設置成save/update/delete,在每次進入方法前刪除緩存,但是這種做法一個是得嚴格限制方法名,另外一個是如果有一些其他方法想要刪除緩存,就得要增加切點。所以這里用第二種方法,自定義注解,然后通過檢查方法是否帶有該注解,來判斷是不是需要清空緩存。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ClearRedis {
}
首先是自定義的緩存,然后在需要用到刪除的方法上面使用該緩存即可
@ClearRedis
public String save(User user){...}
這樣準備工作就算完成了,接下來是AOP 相關的代碼
@Component
@Aspect
public class AspectJConfig {
@Pointcut("execution(* com.Demo.*.*(..))")
private void clearCache(){}
//save 方法前,清空相關 key的緩存
@Before("clearCache()")
public void beforeSave(JoinPoint joinPoint) throws Throwable {
String targetName = joinPoint.getTarget().getClass().getName();
Class targetClass = Class.forName(targetName);
Method[] methods = targetClass.getMethods();
String methodName = joinPoint.getSignature().getName();
boolean isClearRedisPresent = false;
for (Method method : methods)
{
if (method.getName().equals(methodName))
{
if (method.isAnnotationPresent(ClearRedis.class))
{
isClearRedisPresent = true;
break;
}
}
}
if (isClearRedisPresent)
{
//獲取切點作用的方法
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
//獲取有作用方法前綴的 key,遍歷以刪除該切點作用方法關聯的緩存,因為 save 有刷新數據庫
Set<String> keys = stringRedisTemplate.keys(methodSignature.getDeclaringTypeName() + "*");
stringRedisTemplate.delete(keys);
keys = stringRedisTemplate.keys(methodSignature.getDeclaringTypeName() + "*");
}
}
}
由于是通過增加注解的方式,所以切點的范圍可以定位到 service 層的所有方法上。邏輯上來說在save/delete/update 前,后刪除緩存都是可以的,所以是用@Before 還是@After 或者@Around 都是可以的。
方法體內分為兩個部分,第一個部分是判斷該切點方法有沒有使用@ClearRedis 注解,第二部分是當使用了注解,則清空對應的緩存。這兩步都是利用了 JAVA 的反射機制,通過 joinPoint 來找到對應的調用類和調用方法以及調用方法的參數,這樣我們就可以根據類+方法名+參數列表來組裝對應的 key,這個跟上面在 KeyGenerator 中生成 key 的邏輯是一一對應的。最后是利用的 Redis 的模糊查詢的邏輯可刪除對應前綴的 Key,也就是我們可以不管參數是多少,是用哪個方法調用的,直接刪除指定類獲取到的所有緩存。
到此為止所有的部分就介紹完畢了,通過這樣的配置,可以簡單的在獲取方法上增加@Cacheable 注解生成緩存,同時在方法上增加@ClearRedis 來清空類對應的緩存,保證了讀的效率,也同時保證的讀的準確性。
最后有兩點存疑
1.我之前試過用@CacheEvict,想來生成指定的 Key,并通過前綴刪除,但是好像做不到,因為前綴刪除這個功能是 Redis 的,不是 cache 的。不過我覺得既然 cache 提供這樣一個注解,應該不至于做不到這個需求,所以還得再繼續看一下。
2.第二個就是如果將切點設置成 service 層所有的方法,會不會對效率有所影響,這個也要在實際的運行環境中檢測才行。