Spring Cache 使用实践


@Deprecated 不建议阅读

一. Spring Cache的基本应用

Spring对cache有注解层的支持,我们可以非常方便的使用这些注解来实现业务中的缓存逻辑。建议使用Spring的同学手下一定有一个Spring源码的阅读环境。Spring主要的cache注解如下:

(源码位于spring-context -> cache -> annotation)

  • @interface Cacheable 这个注解应该是最常用的一个注解,这个注解可以标注一个方法,这个方法返回的数据会被缓存,下次调用将直接返回缓存中的数据。
  • @interface CachePut 这个注解标注的方法返回的数据也会被缓存,但是每次调用该方法都将执行该方法,并将最新数据放入相关联的缓存之中。所以即使缓存中已经有数据,还是会去执行这个方法。需要体会一下和Cacheable的区别。
  • @interface CacheEvict 这个注解标注的方法被调用时,CacheEvict指定的缓存数据将失效。

看一下Cacheable的源码,这里大致解释了一下主要字段的作用,其他的注解也类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Cacheable {

@AliasFor("cacheNames")
String[] value() default {}; // 关联的缓存名字,指定一个缓存

@AliasFor("value")
String[] cacheNames() default {};

String key() default ""; // 对应缓存中相关数据的key

String keyGenerator() default ""; // key自动生成策略

String cacheManager() default "";

String cacheResolver() default "";

String condition() default "";

String unless() default "";

boolean sync() default false; // 异步刷新数据,防止缓存穿透

}

当然只有注解是不行的,我们还需要具体的cache,这里我们使用的是GuavaCache。Spring 5 已经放弃了对GuavaCache的支持,据说是性能原因,取而代之的是CaffeineCacheEhCacheCache。之后的文章会介绍并对比GuavaCacheCaffeineCacheEhCacheCache的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@EnableCaching
@Configuration
public class CacheConfig extends CachingConfigurerSupport {

public final static String CACHE_FIVE_MINUTE = "CACHE_FIVE_MINUTE";
public final static String CACHE_ONE_HOUR = "CACHE_ONE_HOUR";

private static List<String> cacheList;

static{
cacheList = new ArrayList<>();
cacheList.add(CACHE_FIVE_MINUTE);
cacheList.add(CACHE_ONE_HOUR);
}

public static List<String> getCacheList() {
return cacheList;
}

@Override
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();

GuavaCache guava5mCache = new ImmutableGuavaCache(CACHE_FIVE_MINUTE,
CacheBuilder
.newBuilder()
.recordStats()
.maximumSize(5000)
.expireAfterWrite(5, TimeUnit.MINUTES).build());

GuavaCache guava1hourCache = new ImmutableGuavaCache(CACHE_ONE_HOUR,
CacheBuilder
.newBuilder()
.recordStats()
.maximumSize(5000)
.expireAfterWrite(1, TimeUnit.HOURS).build());

cacheManager.setCaches(Arrays.asList(guavaCache, guava30Cache, guava5mCache,
guava1hourCache));
return cacheManager;
}

@Override
@Bean(name = "simpleJsonKeyGenerator")
public KeyGenerator keyGenerator() {
return new SimpleJsonKeyGenerator();
}

public static class SimpleJsonKeyGenerator implements KeyGenerator{
private static Log logger = LogFactory.getLog(CacheConfig.class);

@Override
public Object generate(Object target, Method method, Object... params) {
String key = method + JSONObject.toJSONString(params);
return key;
}
}
}

这里注册了两个cache,名称分别是CACHE_FIVE_MINUTECACHE_ONE_HOUR。并注册了一个KeyGenerator,用于生产cache的key。这里的生成策略很简单,方法public Object generate(Object target, Method method, Object... params)的参数值得注意一下,target是调用被缓存方法的对象,method是被调用的方法,params是方法的参数签名,这种参数组合在Spring中非常常见,在Java中的InvocationHandler也使用了这种参数组合,public Object invoke(Object proxy, Method method, Object[] args),表示”某个对象调用了某个方法”。

KeyGenerator简单的返回了一个独一无二的key,这里会导致一个很麻烦的问题,下面来详细讨论。

二. Cache Key 引发的问题

上面描述了Spring Cache的一个简单实践,我们已经可以很方便的在业务逻辑代码中使用我们注册的Cache了:

1
2
3
4
5
6
7
/**
* 查找数据库中所有的书名,并缓存5分钟。
*/
@Cacheable(cacheNames = CacheConfig.CACHE_FIVE_MINUTE, sync = true)
public Map<Integer, Book> getAllBooks() {
return bookDAO.selectAllBooks();
}

我们设置了缓存5分钟自动失效,缓存失效后,再次调用getAllBooksName()会直接去数据库拿数据,然后再次缓存。但是我们面临一个严峻的问题。

假设有以下情景:有一个用户在数据库中新增了一本书,然后用户需要立即使用刚刚新增的这本书籍,这是就出现了问题,因为缓存中并没有这个数据,而只有当缓存到期自动失效后,再次从数据库中加载数据时,才会加载到刚刚新增的书籍到缓存中。这时,缓存和数据库中的数据出现了不一致。而且不一致的时间最长达到5分钟。

要解决这个问题就不得不提到”大名鼎鼎”的 缓存更新策略问题,这个问题是可以算作cache的一个经典问题。这里介绍一个常用的策略,称作:Cache Aside。简单描述一下这个更新策略:

  • 应用在查询数据的时候,先从缓存Cache中读取数据,如果缓存中没有,则再从数据库中读取数据,得到数据库的数据之后,将这个数据也放到缓存Cache中。
  • 如果应用要更新某个数据,也是先去更新数据库中的数据,更新完成之后,则通过指令让缓存Cache中的数据失效。

我们的问题有了解决办法:那就是当这个用户在数据库新增完书籍之后(注意这里一定是确保添加完毕之后),主动让缓存失效,当用户再次调用获取所有书籍的方法时,由于缓存已经失效,方法会从数据库中去获取最新的数据,这是缓存中的数据和数据库一致了。要注意的是,这里的一致并不是强一致,因为在并发环境下,用户添加完数据和调用主动缓存失效方法之间,可能会有其他的用户读取数据,这时,缓存仍然是旧缓存(因为发生在主动使缓存失效之前),但是数据库中已经有了最新的数据(发生在添加完数据之后),所以缓存的数据和数据库中的数据有一瞬间是不一致的。但是我们了解这一点就行,因为这种应用情景下,并不需要如此严格的数据一致性。

如果需要特定的缓存失效,我们只需要新增一个方法,然后标注上CacheEvict注解即可:

1
2
3
4
5
/**
* 使缓存失效
*/
@CacheEvict(cacheNames = CacheConfig.CACHE_FIVE_MINUTE, key = ???)
public void invalidateBooksCache() {}

这个方法不需要实现任何逻辑,当这个方法被调用时,缓存就失效了。

但是注意key这个参数,这里需要让缓存中一个特定的key对应的数据失效时,我们需要指定这个key,然而不幸的是,在上文代码中所见,使用了KeyGenerator去自动生成一个key,当再次需要这个key时,只能去按照规则去还原这个key(根据Object target, Method method, Object... params参数),这无疑是不能接受的。

三. Cache Key 如何管理?

这是本篇文章的重点,在缓存中,数据由CacheName和一个Key唯一决定,如果需要对特定的数据进行修改,则需要根据CacheName和Key去找到对应的数据。CacheName在本文实践中,只有两个:CACHE_FIVE_MINUTECACHE_ONE_HOUR,所以CacheName的获得并不是问题。真正的问题是Key,因为我们已经使用了一个KeyGenerator去自动生和被调用方法一一对应的Key,由于Key是和被调用方法一一对应的,在其他的方法中需要得到这个Key将变得困难。

1. 全局Enum

让我们忘记KeyGenerator这个东西。回到最初,我们最原始的管理方法是什么呢?我想最简单的方法就是定义一个全局的Enum类,里面去管理所有的CacheKey,大概像这样:

1
2
3
4
5
6
public enum CacheKeyEnum {
BOOKS,
USERS,
... // 等等其他的Key
;
}

然后上文中的代码,缓存注解就变成了这样:

1
2
3
4
5
6
7
8
9
10
@Cacheable(
cacheNames = CacheConfig.CACHE_FIVE_MINUTE,
key = CacheKeyEnum.BOOKS.toString(),
sync = true)
public Map<Integer, Book> getAllBooks() {
return bookDAO.selectAllBooks();
}

@CacheEvict(cacheNames = CacheConfig.CACHE_FIVE_MINUTE, key = CacheKeyEnum.BOOKS.toString())
public void invalidateBooksCache() {}

好了,这样通过一个全局Enum去管理所有的CacheKey的方式,比较好的解决掉了CacheKey的管理问题,目前看是这样。

缺点:如果你是一个经历过数十万行代码的人,相信你一定见过这种工程中的全局Enum,可能还会遇到全局的public static final String = "xxx";管理类,不得不说这是一种十分方便的管理常量的方式,但是随着工程的膨胀,这种类也将变得越来越大,你很难从一堆看着差不多的大写常量中找到自己想要的那个,这种情况在多人合作时将变得更加严重,因为你不确定别人是否已经定义了你想定义的常量,即使你在几百个常量中找到了你觉得可能是你需要的常量,你也不确定这就是你想定义的常量,你可能需要去找作者交流一下,即使你不管这些,定义一个属于你自己的常量,这可能导致同一个系统中存在多个意义一样但是命名不同的常量,极端情况下,大家可能自顾自的去添加各种常量,这部分系统将变得不可维护。

不难发现,一个系统中的CacheKey的数目绝对不是一个小数量,使用这种方式无异饮鸩止渴。

2. 抽象Cache的管理

另外一条思路就是,抽象一个AbstractCacheService,用来管理所有的Cache相关的工作。

缺点:很明显,以后我们所有需要Cache的Service,都会继承自AbstractCacheService,然而继承这种做法并不是Java工程师首先会考虑的解决问题的方式,因为继承太重了,这是由于Java中的单继承机制引起的。在决定去继承一个类之前,需要确定子类没有其他更重要的类需要继承,而且将来也不会有,因为要修改所有子类继承的父类类型是一个庞大无趣的机械工作。这使得我们在使用继承时慎之又慎,除非子类是对父类的直接扩展。一个很好的反面教材是Java标准库中的ObserverObservable,这是Java API对观察者模式的通用封装,而我们却鲜少见到有人用,原因和上述的原因是相同的,我们只能选择去继承Observable,而不是实现一个接口。

未完待续…