1. J2EE 缓存规范:JSR-107的核心概念(仅了解)
JSR-107定义了5个核心接口(javax.cache.cache-api):CachingProvider、CacheManager、Cache、Entry、Expiry。他们的从属调用关系如图:
(1)CachingProvider定义了创建、配置、获取、管理和控制多个CacheManager。一个应用可以在运行期访问多个CachingProvider。
(2)CacheManager定义了创建、配置、获取、管理和控制多个唯一命名的Cache,这些Cache存在于CacheManager的上下文中。一个CacheManager仅被一个CachingProvider所拥有。
(3)Cache是一个类似Map的数据结构并临时存储以Key为索引的值。一个Cache仅被一个CacheManager所拥有。
(4)Entry是一个存储在Cache中的key-value对。
(5)Expiry每一个存储在Cache中的条目有一个定义的有效期。一旦超过这个时间,条目为过期的状态。一旦过期,条目将不可访问、更新和删除。缓存有效期可以通过ExpiryPolicy设置。
2. Spring的缓存抽象(从3.1开始,也支持了JSR-107的原生注解的使用)
- 定义了org.springframework.cache.CacheManager和org.springframework.cache.Cache两个接口来规范不同的缓存产品与Spring交互的实现xxxCache(如RedisCache、EhCacheCache、ConcurrentMapCache等)。
- 强调CacheManager和Cache两个接口概念:
(1)CacheManager,管理各种各样的Cache。
(2)Cache才是真正完成缓存操作的对象。 - Sring缓存时使用到的注解
注解 作用 @EnableCaching 开启基于注解的缓存 @Cacheable 代表一个方法可以被缓存。每次调用需要缓存功能@Cacheable的方法时,Spring会检查用“当前指定参数”调用该目标方法是否已经被调用且返回过。如果有,就直接从缓存中获取上次该方法调用后的返回结果用来返回,否则就调用该方法,然后把本次结果保存到缓存中,再返回给调用方,这样一来,下次用相同参数对该方法的调用就可以直接从缓存中获取并返回给调用方。 @CacheEvict 清空缓存,标注在删除性质的方法上。在持久层将记录删除时,从缓存中也应该删除。 @CachePut 更新缓存,标注在更新性质的方法上。标注了该注解的方法总会被调用(不是像@Cacheable从缓存中拿出该方法之前缓存的结果),并且将返回结果更新到缓存 - 缓存的本质时kv键值对,在Spring中由KeyGenerator定义缓存保存时key的生成策略,Serializer定义缓存保存时value的序列化策略。
3. Spring Cache Hello World
-
基本环境搭建
(1) 使用Spring Initializer创建一个包含Spring’s Cache abstraction、Web、MySqlDriver、MyBatis Framework的Spring Boot项目。
(2)新建数据库,导入sql文件,创建Department和Employee表。
(3)创建与表结构对应的JavaBean:Department和Employee。
(4)整合MyBatis操作数据库:- 主配置文件中配置数据源spring.datasource.xxx。
- 主配置文件中开启MyBatis的驼峰命名配置。mybatis.configuration.map-underscore-to-camel-case=true
- 使用注解版的MyBatis:
(1)在主配置类上标注@MapperScan,指定要扫描的mapper接口所在的包。
(2)在mapper包下创建EmployeeMapper和DepartmentMapper,使用@Insert、@Update、@Delete、@Select注解完成CRUD方法。
(3)在Test路径下的测试类中注入Mapper检查测试。
(5)补全Service和Controller,启动主配置类的main测试。
-
使用缓存,有两个核心步骤:
(1)在主配置类上标注@EnableCaching,开启基于注解的缓存。
(2)给需要缓存的方法标注相关注解@CacheEnable、@CacheEvict、@CachePut。/**将方法的返回结果缓存 * 之后再使用相同的参数调用该方法时,会直接从缓存中获取,不会再调用一次改方法(也就是不会再到数据库进行SQL查询) * 几个属性: * 1. value/cacheNames:指定“缓存到...”的Cache的名字标识。为了从CacheManger所管理的多个Cache找到,每一个Cache有自己唯一的名字。 * 这个属性可以指定一个多值数组,将返回值同时缓存到多个Cache中。 * 2. key:指定缓存“方法返回值”时所使用的key值,默认是使用本次方法调用中的参数值。比如getEmp(1)的键值对就是<1,getEmp返回的1号Employee对象>。 * 支持SpEL表达式,如#id表示取出形参id的值,等价的写法还有#root.args[0]、#a0、#p0(取出第一个形参值)。 * 3. keyGenerator:key生成器。可以自定义生成器(实现KeyGenerator接口),在配置类中@Bean注入到IoC容器中,给这个属性指定它在IoC容器内的id。 * key/keyGenerator只能二选一使用。 * 4. cacheManager/cacheResolver:指定“缓存到...”的Cache来自哪个CacheManger(在使用多个缓存产品的项目中可以用到)。(CacheManagerA中的empCache和CacheMangerB中的empCache完全是两个独立的Cache) * cacheManger与cacheResolver只能二选一使用。 * 5. condition:指定条件,只有在符合条件的情况下才会缓存。 * 支持SpEL表达式,如#id>0 and #root.methodName eq 'aaa'表示只有在传入的参数id>0且目标方法名为aaa时,才会把返回值加入缓存。 * 6. unless:否定缓存,当unless指定的条件为true时,方法的返回值就不会被缓存。 * 可以获取到返回值进行判断,如unless="#result==null"表示当返回值为null时不缓存。 * 当condition和unless同时满足的时候,以unless为准,返回值不会被缓存。 * 7. sync:是否使用异步模式。默认为false,表示目标方法执行完后,以同步的方式将返回值置入缓存中。 * 但sync设为true时,unless属性不支持。 */ @Cacheable(cacheNames = {"emp"}) public Employee getEmp(Integer id){ System.out.println("查询"+id+"号员工"); return employeeMapper.getEmpById(id); }
-
这些注解属性所支持的SpEL表达式。
4. 缓存的工作原理
4.1 缓存的配置原理
- 缓存模块的自动配置类的关键在于@Import(CacheConfigurationImportSelector.class),又是之前的核心调用selectImports()。
返回的imports是所有的缓存配置类,它们是有顺序的。public String[] selectImports(AnnotationMetadata importingClassMetadata) { CacheType[] types = CacheType.values(); String[] imports = new String[types.length]; for(int i = 0; i < types.length; ++i) { imports[i] = CacheConfigurations.getConfigurationClass(types[i]); } return imports; } }
而默认生效的缓存配置类是SimpleCacheConfiguration,作用是给容器中注入 了一个ConcurrentMapCacheManager。@Configuration( proxyBeanMethods = false ) @ConditionalOnMissingBean({CacheManager.class}) @Conditional({CacheCondition.class}) class SimpleCacheConfiguration { SimpleCacheConfiguration() { } @Bean ConcurrentMapCacheManager cacheManager(CacheProperties cacheProperties, CacheManagerCustomizers cacheManagerCustomizers) { ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager(); List<String> cacheNames = cacheProperties.getCacheNames(); if (!cacheNames.isEmpty()) { cacheManager.setCacheNames(cacheNames); } return (ConcurrentMapCacheManager)cacheManagerCustomizers.customize(cacheManager); } }
- ConcurrentMapCacheManager实现了CacheManager的接口,那它的核心方法就是通过一个名字获取一个Cache的getCahce(),而在这个方法中会创建和获取ConcurrentMapCache类型的缓存。
public Cache getCache(String name) { //名字与Cache的关系保存在cacheMap中,它是一个ConcurrenHashtMap<String,Cache> Cache cache = (Cache)this.cacheMap.get(name); //如果获取不到,就对这个cacheMap加锁并再获取一次,还是获取不到就创建一个ConcurrentMapCache,然后放到cacheMap中。 if (cache == null && this.dynamic) { ConcurrentMap var3 = this.cacheMap; synchronized(this.cacheMap) { cache = (Cache)this.cacheMap.get(name); if (cache == null) { cache = this.createConcurrentMapCache(name); this.cacheMap.put(name, cache); } } } return cache; }
- ConcurrentMapCache底层用来存储数据的是一个ConcurrentHashMap<Object,Object>。
4.2 @Cacheable运行流程
- 目标方法(标注了@Cacheable)被调用前,CacheManager(默认是ConcurrentMapCacheManager)会先按照@Cacheable的属性cacheNames获取对应的缓存Cache(默认是ConcurrentMapCache),如果是第一次调用目标方法Cache获取不到,便会用这个名字去自动创建一个Cache。
- 从这个Cache中查找缓存的内容,而使用的key是按照某种策略生成的,默认由SimpleKeyGenerator生成,它的生成key的策略:
(1)如果目标方法没有参数,key=new SimpleKey()。
(2)如果目标方法有一个参数,key=参数的值。
(3)如果目标方法有多个参数,key=new SimpleKey(参数列表)。 - 先从缓存Cache中获取,没有拿到就调用目标方法。
- 将目标方法的返回值放到缓存Cache中。
【总结】@Cacheable标注的方法执行之前,先来找到声明的缓存,检查其中是否已存在需要的返回值,而查询所使用的key在默认情况下就是参数值。如果没有,就执行目标方法,以<参数值,返回值>的形式保存到缓存中,之后再对该方法的调用就可以直接使用缓存中的已存的数据。
5. 自定义Key
- 默认的SimpleKeyGenerator生成key的策略:
(1)如果目标方法没有参数,key=new SimpleKey()。
(2)如果目标方法有一个参数,key=参数的值。
(3)如果目标方法有多个参数,key=new SimpleKey(参数列表)。 - 在@Cacheable的key属性上动手:
使用到SpEL表达式,可以在注解的属性上拼接,如@Cacheable(key="#root.methodName+’[’+#id+’]’")会得到“getEmpById[1]”这样的key。 - 在@Cacheable的keyGenerator属性上动手:
(1)先在配置类中给容器注入一个KeyGenerator(引入包名要选对,org.springframework.cache.interceptor.KeyGenerator)。
(2)把自定义KeyGenerator的id(默认是方法名,可以通过@Bean指定)设置到@Cacheable的keyGenerator属性。@Configuration public class CacheConfig { @Bean public KeyGenerator myKeyGenerator(){ return new KeyGenerator() { @Override public Object generate(Object o, Method method, Object... objects) { return method.getName()+"["+ Arrays.asList(objects).toString()+"]"; } }; } }
@Cacheable(cacheNames = {"emp"},keyGenerator = "myKeyGenerator") public Employee getEmp(Integer id){...}
6. @CachePut(可指定属性与@Cacheable是一样的)
-
应用场景:修改了数据库中某条记录后,同时更新到缓存中。
-
@CachePut与@Cacheable的运行时机不同:先调用目标方法,然后将方法的返回值保存到缓存中。被@CachePut标注的目标方法一定总会被调用。
-
正因为@CachePut在目标方法执行后更新缓存,因此在属性key的设定上,SpEL可以使用#result来取得目标方法的返回值。而@Cacheable在目标方法执行前(还没有返回值)就需要使用key先检查缓存,因此它也就不支持#result。
-
应该特别注意的问题
(1)举例说明问题现象://EmployeeService.java @Cacheable(cacheNames="emp") public Employee getEmpById(Integer id){...} @CachePut(cacheNames="emp") public Employee updateEmp(Employee employee){...}
<1> 第一次调用getEmpById(1)查询1号员工,返回值(这条记录)被置入到缓存emp中。之后再调用getEmpById(1)都直接从缓存emp中取出,而不再从数据库中查询。
<2> 将1号员工更新过的信息封装为employee,调用updateEmp(employee),数据库中1号员工记录被更新,返回值(更新后的employee)。
<3> 此时,再次调用getEmp(1),返回的是更新前的旧值。(2)问题的核心:key不一致
默认(不指定)的情况下,@Cacheable标注 的getEmp,@CachePut标注的updateEmp的key都是第一个参数值,而getEmp(1)的key是1,updateEmp(employee)的key是employee对象。因此,因为key的不同,@CachePut向缓存emp中存放的是一条key=employee新记录,而不是覆盖了key=1的那条旧值。(3)问题的解决
给@CachePut指定属性key,key="#employee.id"(使用传入参数中的id)或key="#result.id"(使用返回值的id)都能解决这个问题。
7. @CacheEvict
- 应用场景:删除数据库中的某条数据后,同时在缓存中也清除掉。
- @CacheEvict同样按照指定key从缓存中删除。此外,它有一个可配置属性allEntries,默认为false。当allEntries=true时,表示目标方法被调用后,指定缓存中所有的键值对都被删除。
- @CacheEvict还多了一个配置属性beforeInvocation,默认为false,表示“从指定缓存中删除key对应的键值对”的行为在目标方法被调用之后完成的。如果在目标方法中出现了异常,缓存中的键值对不会被删除;但如果beforeInvocation=true,那么无论目标方法是否出现异常,缓存中的对应键值对都会被删除。
8. @Caching
- 它是@Cacheable、@CachePut、@CacheEvict的复合注解,主要适用于缓存规则复杂的目标方法。
//Cacheable/CachePut/CacheEvict都是数组类型的属性,可以指定多个这些注解。 public @interface Caching { Cacheable[] cacheable() default {}; CachePut[] put() default {}; CacheEvict[] evict() default {}; }
- 实例
这个实例有一个现象:当第二次调用getEmpByLastName(“aaa”)的时候,仍然会向数据库查询aaa。这是因为,标注的@Caching中除了对@Cacheable的声明外,还声明了@CachePut,而@CachePut总是会调用目标方法,在目标方法返回后将<key,返回值>置入缓存emp中。//这个实例的效果就是,当该方法被调用后,向emp缓存中置入三个键值对 //三个key分别是:参数lastName,返回employee中的id属性,返回employee中的email属性;而value都是这个返回的employee。 //之后再使用id或email查询这条记录时,就能从emp缓存中直接取出而不再向数据库查询。 @Caching( cacheable = { @Cacheable(value = "emp",key="#lastName") },put = { @CachePut(value = "emp",key = "#result.id"), @CachePut(value = "emp", key = "#result.email") } ) public Employee getEmpByLastName(String lastName){ return employeeMapper.getEmpByLastName(lastName); }
9. @CacheConfig:标注在整个类上,抽取出Cache的公共设置
这个注解的作用就是将该类下所有标注在方法上的@Cacheable、@CachePut、@CacheEvict的公共属性设置(cacheNames,keyGenerator,cacheManager)抽取出来,进行一次指定。
10. 以Redis为例,在项目中操作缓存中间件
- 不管是什么样的缓存中间件,只要导入了其相应的类依赖,它对应的xxxCacheConfiguration类才会生效,配置它需要的CacheManager(因为@ConditionalOnClass被标注在这些类上)。默认使用的是SimpleCacheConfiguration(ConcurrentMapCacheManager+ConcurrentMapCache,底层由ConcurrentHashMap<Object,Object>实际存储)。
10.1 搭建Redis环境(使用Docker)
- 下载Redis镜像(如果docker hub外网仓库下载慢且容易失败,可使用来自 docker中国镜像加速:给下载的xxx镜像名前面加上镜像地址 registry.docker-cn.com/library/xxx)
#下载redis镜像 docker pull registry.docker-cn.com/library/redis #查看全部镜像 docker images
- 启动redis镜像
#启动一个redis镜像(-d 后台启动 | -p 将虚拟机端口映射到容器端口) docker run -d -p 6379:6379 --name myredis registry.docker-cn.com/library/redis #查看镜像运行情况 docker ps
- 使用RedisDesktopManager连接测试命令(Redis中文网是个好东西)。
10.2 整合Redis到项目中
- 引入spring-boot-starter-data-redis依赖(它引入了jedis客户端),RedisAutoConfiguration生效,它最主要的作用是给容器中注入了RedisTemplate<Object,Object>和StringRedisTemplate来简化操作。
//RedisAutoConfiguration.java //操作k-v都是对象的template @Bean @ConditionalOnMissingBean( name = {"redisTemplate"} ) public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException { RedisTemplate<Object, Object> template = new RedisTemplate(); template.setConnectionFactory(redisConnectionFactory); return template; } //专门操作k-v都是字符串的template @Bean @ConditionalOnMissingBean public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException { StringRedisTemplate template = new StringRedisTemplate(); template.setConnectionFactory(redisConnectionFactory); return template; }
- 在主配置文件中对redis相关属性进行设置。只需要指定spring.redis.host即可。
- 在Test路径下的测试类中@Autowired注入RedisTemplate和StringRedisTemplate测试。
//以@Autowired的StringRedisTemplate为例,它们的操作都有一个模式: //XXX就是五大数据类型String(在StringRedisTemplate中叫Value)、Set、List、Hash、Zset //someCommd对应redis命令行的操作命令 stringRedisTemplate.opsForXXX().someCommand();
10.3 使用RedisTemplate保存对象
- 被保存的对象的类必须实现Serializable接口。
- 由RedisAutoConfiguration自动注入的RedisTemplate<Object,Object>在保存对象时,默认使用的是Jdk提供的对象序列化器(在RedisTemplate底层核心调用afterProperties方法中defaultSerializer=new JdkSerializerRedisSerializer(…))对键和值进行序列化后保存。
- 在配置类@Configuration中添加一个自定义RedisTemplate<Object,Employee>,使用Jackson2JsonRedisSerializer(RedisSerializer接口的一个实现类),使用它就可以在保存Employee对象时将键值都序列化为JSON(只要设置defaultSerializer,就可以同时设置keySerializer和valueSerializer)。
【注意】若想要替换RedisAutoConfiguration默认注入的RedisTemplate(在默认注入的RedisCacheManager中被使用),只需要在配置类@Configuration中注入名字为redisTemplate的自定义RedisTemplate即可。因为@ConditionalOnMissingBean(name=“redisTemplate”)。@Configuration public class RedisConfig { //需要将哪个JavaBean保存到Redis中,就注入这个类型的RedisTemplate<Object,JavaBean类型> @Bean public RedisTemplate<Object,Employee> employeeRedisTemplate( RedisConnectionFactory redisConnectionFactory) throws UnknownHostException{ RedisTemplate<Object, Employee> template = new RedisTemplate<Object, Employee>(); template.setConnectionFactory(redisConnectionFactory); //使用GenericJackson2JsonRedisSerializer更好,因为可以不用为每个JavaBean类型都注入一个RedisTemplate<Object,JavaBean类型>,类型信息同样会被序列化后保存到redis中,之后获取时反序列化也不会出错 template.setDefaultSerializer(new Jackson2JsonRedisSerializer<Employee>(Employee.class)); return template; } }
//RedisAutoConfiguration.java @Bean @ConditionalOnMissingBean( name = {"redisTemplate"} ) public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException { RedisTemplate<Object, Object> template = new RedisTemplate(); template.setConnectionFactory(redisConnectionFactory); return template; }
- 序列化踩过的坑请参照 [2018/11][实践总结][Redis][Shiro][FreeMarker][AOP-Log4j][404Request] 项目总结 中关于RedisTemplate的部分。
11. 自定义CacheManager
- 整合Redis后的一个现象
当引入spring-boot-starter-data-redis之后,会给容器中注入RedisCacheManager(CacheManager的一个实现,作用是创建RedisCache作为缓存组件)。而这些缓存配置类上都标注了@ConditionalOnMissingBean(CacheManager.class)表示容器中没有CacheManager时才生效。因此其他缓存配置都不会生效,这就使默认使用的SimpleCacheConfiguration(ConcurrentMapCacheManager)不生效。
//RedisCacheConfiguration.java /* * Spring Boot 1.5.9 * 引入spring-boot-starter-data-redis后, * RedisCacheConfiguration给容器中添加的是RedisCacheManager, * 而它所使用的与Redis实际交互的是默认的RedisTemplate<Object,Object>(由RedisAutoConfiguration注入,默认使用JdkSerializerRedisSerializer进行序列化)。 */ //Spring Boot 2.0源码与之前不同,使用了建造者模式 @Bean RedisCacheManager cacheManager(CacheProperties cacheProperties, CacheManagerCustomizers cacheManagerCustomizers, ObjectProvider<org.springframework.data.redis.cache.RedisCacheConfiguration> redisCacheConfiguration, ObjectProvider<RedisCacheManagerBuilderCustomizer> redisCacheManagerBuilderCustomizers, RedisConnectionFactory redisConnectionFactory, ResourceLoader resourceLoader) { RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(this.determineConfiguration(cacheProperties, redisCacheConfiguration, resourceLoader.getClassLoader())); List<String> cacheNames = cacheProperties.getCacheNames(); if (!cacheNames.isEmpty()) { builder.initialCacheNames(new LinkedHashSet(cacheNames)); } redisCacheManagerBuilderCustomizers.orderedStream().forEach((customizer) -> { customizer.customize(builder); }); return (RedisCacheManager)cacheManagerCustomizers.customize(builder.build()); }
- 自定义RedisCacheManager
,在配置类@Configuration中注入。同样由于@ConditionalOnMissingBean({CacheManager.class}),自定义RedisCacheManager被注入到容器后,默认注入的RedisCacheManager将不会被创建。//(与Spring Boot 1.5不同,2.x使用了建造者模式创建RedisCacheManager,具体实例待补充) //1.5.9中自定义RedisCacheManager的实例,这个employeeCacheManager就是之前自定义的RedisTemplate,会被自动注入 @Bean public RedisCacheManager employeeCacheManager(RedisTemplate<Object,Employee> employeeRedisTemplate){ RedisCacheManager cacheManager = new RedisCacheManager(employeeRedisTemplate); cacheManager.setUsePrefix(true); return cacheManager; }
- RedisCacheManager同样存在CacheManagerCustomizers的模式,可以定制缓存的一些规则。
- 这个案例可能出现的问题和解决方法
【问题】因为使用的自定义RedisTemplate<Object,Employee>具体在Employee类型上,所以RedisCacheManager也被具体在这个类型上,导致@Cacheable从Redis中取出“非Employee”数据时反序列化错误(因为类型信息定死了是Employee,反序列化器在映射字段时绑定不进去)。
【解决】
(1)在主配置类@Configuration中使用将GenericJackson2JsonRedisSerializer作为序列化器的RedisTemplate<Object,Object> redisTemplate。
(2)给每一个需要缓存的JavaBean创建一个具体的RedisTemplate<Object,JavaBean类型>,用它来自定义一个具体的RedisCacheManger并注入容器。然后在@Cacheable中指定cacheManager在容器中的id。简言之,创建多个CacheManager来处理不同的JavaBean(但在注入多个CacheManager的情况下,一定要在某个CacheManger的@Bean之上标注@Primary,作为不指定cacheManager时默认使用的“保底”。在实际的开发中,应该把RedisAutoConfiguration中原先默认注入的RedisCacheManager拿到自己的配置类@Configuration中注入并标注@Primary)。
12. 通过编写代码的方式手动使用缓存,而不是注解方式自动存取
三步走:获取CacheManager - 获取Cache - 调用Cache的方法操作:
使用@Autowired注入CacheManger(当容器中存在多个CacheManager时,需要标注@Qualifier指定注入哪一个CacheManager),然后调用getCache(cacheName)拿到caceName对应的Cache,用Cache来实际存取。
@Service
public class EmployeeService {
@Qualifier("empCacheManager")
@Autowired
private RedisCacheManager empCacheManager;
@Autowired
private EmployeeMapper employeeMapper;
public Employee getEmpById(Integer id){
Employee employee = employeeMapper.getEmpById(id);
//获取指定的缓存
Cache empCache = empCacheManager.getCache("emp");
empCache.put("emp:1",employee);
return employee;
}
}