本文主要介绍 JSR107 ,Spring缓存抽象、整合redis
1. JSR107
javaee发布了 JSR107缓存规范,其中定义了5个核心接口,分别是 CachingProvider ,CacheManager、Cache、Entry和Expiry
- CachingProvider定义了创建、配置、获取、管理和控制多个CacheManager。一个应用可以在运行期访问多个CachingProvider。
- CacheManager 定义了创建、配置、获取、管理和控制多个唯一命名的Cache,这些Cache存在于CacheManager的上下文中。一个CacheManager仅被一个CachingProvider所拥有。
- Cache是一个类似于Map的数据结构并临时存储以Key为索引的值、一个Cache仅被一个CacheManager所拥有。
- Entry 是一个存储在Cache中的key-value对
- Expiry每一个存储在Cache中的条目有一个定义的有效期,一旦超过这个时间,条目为过期的状态。一旦过期,条目将不可访问、更新和删除。缓存有效期可以通过ExpiryPolicy设置。
2. Spring缓存抽象
由于后来整合要使用jsr107,整个系统难度较大等一系列原因,导致用的比较少,我们更多使用的是spring缓存抽象,其底层却是和jsr107一样。
Spring从3.1开始定义了org.springframework.cache.Cache和org.springframework.cache.CacheManager接口来统一不同的缓存技术,并且支持使用JCache(JSR-107)注解简化我们开发。
Cache | 缓存接口,定义缓存操作。实现有:RedisCache、EnCacheCache、ConcurrentMapCache等 |
CacheManager | 缓存管理器,管理各种缓存(Cache)组件 |
@Cacheable | 主要针对方法配置,能够根据方法的请求参数对其结果进行缓存 |
@CacheEvict | 清空缓存 |
@CachePut | 缓存更新,保证方法被调用,又希望结果被缓存时使用该注解 |
@EnableCaching | 开启基于注解的缓存 |
keyGenerator | 缓存数据时key生成策略 |
serialize | 缓存数据时value序列化策略 |
- Cache接口为缓存的组件规范定义,包含缓存的各种操作集合。
- Cache接口下Spring提供了各种xxxCache的实现,如RedisCache、EhCacheCache、ConcurrentMapCache等。
- 每次调用需要缓存功能的方法时,Spring会检查指定参数的指定的目标方法是否已经被调用过,如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法并缓存结果后返回给用户。下次调用直接从缓存中获取。
- 使用spring缓存抽象时,需要关注以下两点
- 确定方法需要被缓存以及他们的缓存策略。
- 从缓存中读取之前缓存存储的数据。
搭建缓存测试基本环境:
数据库表创建
/*
Navicat MySQL Data Transfer
Source Server : 本地
Source Server Version : 50528
Source Host : 127.0.0.1:3306
Source Database : springboot_cache
Target Server Type : MYSQL
Target Server Version : 50528
File Encoding : 65001
Date: 2018-10-29 10:54:04
*/
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for department
-- ----------------------------
DROP TABLE IF EXISTS `department`;
CREATE TABLE `department` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`departmentName` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------
-- Table structure for employee
-- ----------------------------
DROP TABLE IF EXISTS `employee`;
CREATE TABLE `employee` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`lastName` varchar(255) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`gender` int(2) DEFAULT NULL,
`d_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
创建一个新工程,引入以下依赖(idea中):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
创建javabean对象
public class Department {
private Integer id;
private String departmentName;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getDepartmentName() {
return departmentName;
}
public void setDepartmentName(String departmentName) {
this.departmentName = departmentName;
}
public Department() {
super();
}
public Department(Integer id, String departmentName) {
this();
this.id = id;
this.departmentName = departmentName;
}
@Override
public String toString() {
return "Department[" +
"id=" + id +
", departmentName='" + departmentName + '\'' +
']';
}
}
public class Employee {
private Integer id;
private String lastName;
private String email;
private Integer gender; //性别 1男 0女
private Integer dId;
public Employee() {
}
public Employee(Integer id, String lastName, String email, Integer gender, Integer dId) {
this();
this.id = id;
this.lastName = lastName;
this.email = email;
this.gender = gender;
this.dId = dId;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Integer getGender() {
return gender;
}
public void setGender(Integer gender) {
this.gender = gender;
}
public Integer getdId() {
return dId;
}
public void setdId(Integer dId) {
this.dId = dId;
}
@Override
public String toString() {
return "Employee[" +
"id=" + id +
", lastName='" + lastName + '\'' +
", email='" + email + '\'' +
", gender=" + gender +
", dId=" + dId +
']';
}
}
配置数据源:
spring:
datasource:
url: jdbc:mysql://localhost:3306/spring_cache
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver # 可以省略不写,可根据连接自动判断
mybatis:
configuration: # 开启驼峰命名法
map-underscore-to-camel-case: true
使用注解版的mybatis
1) @MapperScan指定需要扫描的mapper接口所在的包
2)mapper文件编写
@Mapper
public interface EmployeeMapper {
@Select("SELECT * FROM employee WHERE id=#{id}")
public Employee getEmpById(Integer id);
@Update("UPDATE employee SET lastName=#{lastName},email=#{email},gender=#{gender},d_id=#{dId} WHERE id=#{id}")
public void update(Employee employee);
@Delete("DELETE FROM employee WHERE id=#{id}")
public void deleteEmpById(Integer id);
@Insert("INSERT INTO employee(lastName,email,gender,d_id) VALUES(#{lastName},#{email},#{gender},#{dId})")
public void insertEmp(Employee employee);
}
3)service层代码编写
@Service
public class EmployeeService {
@Autowired
EmployeeMapper employeeMapper;
public Employee getEmp(Integer id) {
System.out.println("查询 " + id + " 号员工");
Employee emp = employeeMapper.getEmpById(id);
return emp;
}
}
4)controller层代码编写
@RestController
@RequestMapping("emp")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
@GetMapping("/{id}")
public Employee getEmp(@PathVariable("id") Integer id) {
return employeeService.getEmp(id);
}
}
启动项目测试: localhost:8080/emp/1 ,如下所示
测试环境搭建完毕后,就可以体验缓存的使用了。 其步骤如下:
1. 开启基于注解的缓存 @EnableCaching
2. 标注缓存注解即可
@Cacheable @CacheEvict @CachePut
我们可以先查看没有加缓存的情况:
在application.yml中,打开日志输出
logging:
level:
com.zhao.springboot.mapper: debug
浏览器访问 http://localhost:8080/emp/1 ,每刷新一次页面,就会查询一次数据库,如下所示:
这就说明现在是没有缓存的。下面使用缓存:
在service层的该查询方法上个加入 @Cacheable ,将方法的运行结果进行缓存,以后再要相同的数据,直接从缓存中获取,不用调用方法。
@Cacheable(cacheNames={"emp"})
public Employee getEmp(Integer id) {
System.out.println("查询 " + id + " 号员工");
Employee emp = employeeMapper.getEmpById(id);
return emp;
}
重新启动项目,访问 http://localhost:8080/emp/1 ,经过几次刷新,发现控制台只有一次打印,访问http://localhost:8080/emp/2发现有新的内容输出。说明结果已被缓存。
那么,是什么样的一个运行流程呢?
@Cacheable
1. 方法运行前,先去查询Cache(缓存组件),按照cacheName指定的名字获取(CacheManager先获取相应的缓存。第一次获取缓存如果没有Cache组件会自动创建)
2. 去Cache中查找缓存的内容,使用一个key,默认就是方法的参数。
key是按照某种策略生成的, 默认是使用keyGenerator生成的,默认使用SimpleKeyGenerator生成key
SimpleKeyGenerator生成key的默认策略
如果没有参数:key=new SimpleKey()
如果有一个参数:key=参数的值
如果有多个参数:key=new SimpleKey(params)
3. 没有查到缓存就调用目标方法
4. 将目标方法返回的结果,放进缓存中
@Cacheable标注的方法执行前先来检查缓存中有没有该数据,默认按照参数的值作为key去查询缓存,如果没有就运行方法并将结果放入缓存,以后再来调用就可以直接使用缓存中的数据
核心:
1) 使用CacheManager【ConcurrentMapCacheManager】按照名字得到Cache【ConcurrentMapCache】组件
2) key使用keyGenerator生成的,默认是SimpleKeyGenerator,也可以自定义。
@CachePut 即调用方法,又更新缓存数据
运行时机:
1). 先调用目标方法
2). 将目标方法的结果缓存起来
编写service层的修改员工方法
@CachePut(value = "emp")
public Employee update(Employee employee) {
System.out.println("更新" + employee.getId() + "号员工");
employeeMapper.update(employee);
return employee;
}
如上代码所示,修改员工信息方法,上面加入注解 @CachePut ,为了方便起见,在controller层,使用了@PutMapping注解,进行测试如下:
1)查询1号员工:查询的结果会放入到缓存中。
2) 再次查询,查询结果还是之前的结果lastName=张三,gender=1
3) 更新1号员工 [lastName=zhangsan,gender=0],更新成功后返回的值如下:
4) 查询1号员工,查询结果如下:
查询结果却是没有更新前的,其原因如下:更新后,确实将返回的结果也放进缓存了,但是由于没有指定key的名称,所以默认key为传入的employee对象,值:返回的employee对象。于是再次查询,并没有去查数据库,造成数据不一致。
修改如下: 指定key的名称为 id
@CachePut(value = "emp", key = "#result.id")
// @CachePut(value = "emp",key = "#employee.id")// 两者都可以
public Employee update(Employee employee) {
System.out.println("更新" + employee.getId() + "号员工");
employeeMapper.update(employee);
return employee;
}
注意:使用result.id 或者employee.id都可以,但是result不能再@Cacheable下使用。执行时机不同。
此时重修更新员工信息后,再次查询数据就一致了,另外,注意,更新后再去查询,也并没有查询数据库,而是查询的缓存。
也就是说 @CachePut即更新了数据库,也更新了缓存。
@CacheEvict :缓存清除
编写service中的delete方法 ,并编写controller中的delete方法 ,如下:
@CacheEvict(value = "emp",key = "#id",allEntries = false,beforeInvocation = false)
public void deleteEmp(Integer id){
System.out.println("delete "+id+"号员工");
employeeMapper.deleteEmpById(id);
}
@GetMapping("delete")
public String delete(Integer id) {
employeeService.deleteEmp(id);
return "success";
}
key:指定要清除的数据
allEntries = false, allEntries:指定清除这个缓存(value指定的)中所有的数据,默认为false
当指定为true时,就意味着当删除了1号员工后,emp下的所有员工(1,2号)的缓存都会被清除。
beforeInvocation = false ,缓存的清除是否在方法执行前执行。
默认代表是缓存清除操作在方法执行后执行,如果出现异常缓存就不会被清除。 当指定为true时,意味着,清除缓存操作是在方法执行前执行,无论方法是否出现异常,缓存都清除。
@Caching :定义复制的缓存注解
在mapper中新增一个方法,根据lastName查询员工,在service新增方法 ,controller中同样新增,如下:
@Select("SELECT * FROM employee WHERE lastName=#{lastName}")
public Employee getEmpByLastName(String lastName);
@Caching(
cacheable = {
@Cacheable(value = "emp", key = "#name")
},
put = {
@CachePut(value = "emp", key = "#result.id"),
@CachePut(value = "emp", key = "#result.email")
}
)
public Employee getEmpByLastName(String name) {
return employeeMapper.getEmpByLastName(name);
}
@GetMapping("/lastName/{lastName}")
public Employee getEmpByLastName(@PathVariable("lastName") String lastName) {
return employeeService.getEmpByLastName(lastName);
}
此时注意: 当调用 该方法后,会分别以key为id,email,lastName将结果放入缓存中,此时如果在根据id查询, 就直接查询的数据库,而不是查数据库,但是当根据lastName查询时,发现并没有从缓存中,而是每次都查询了数据库,这是由于上面我们指定了 @CachePut ,该注解要求每次查询数据库,并更新缓存。
3. 整合redis
默认使用的是ConcurrentMapCacheManager==ConcurrentMapCache,将数据保存在ConcurrentMap<Object,Object>中,在开发中也可以使用缓存中间件:redis,memcached,ehcache
整合redis ,前提在linux上已经安装了redis ,关于安装redis的安装可以参考这篇文章:redis安装, 如果需要该工具,可以下载这个:
并通过工具进行连接 ,如下所示:
即连接成功。
引入redis的starter :在pom文件中添加以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置redis
application.yml配置文件中新增redis配置: spring.redis.host: 192.168.xxx.xxx
可在测试类中简单测试redis 。
@Autowired
StringRedisTemplate stringRedisTemplate; //操作字符串的
@Autowired
RedisTemplate redisTemplate; // k-v都是对象的
@Autowired
RedisTemplate<Object, Employee> empRedisTemplate;
/**
* Redis常见5大类型
* String(字符串),list(列表),Set(集合),Hash(散列),ZSet(有序)
* stringRedisTemplate.opsForValue();[String字符串]
* stringRedisTemplate.opsForList(); [list集合]
* stringRedisTemplate.opsForSet(); [Set集合]
* stringRedisTemplate.opsForHash(); [Hash散列]
* stringRedisTemplate.opsForZSet(); [ZSet(有序集合)]
*/
@Test
public void testRedis() {
//给redis 中保存数据
// stringRedisTemplate.opsForValue().append("msg","hello");
String val=(String )stringRedisTemplate.opsForValue().get("msg");
System.out.println(val);
// 测试 list
// stringRedisTemplate.opsForList().leftPush("myList","1");
// stringRedisTemplate.opsForList().leftPush("myList","2");
// stringRedisTemplate.opsForList().leftPush("myList","3");
}
//测试保存对象
@Test
public void testObj() {
Employee employee = employeeMapper.getEmpById(1);
// 默认如果保存对象,使用jdk序列化机智,序列化后的数据保存到redis中
// redisTemplate.opsForValue().set("emp-01",employee); //保存后,在查看,发现全部是 \xAc之类的,并不是json形式存入的
// 1. 将数据以json的方式保存
//2. redisTemplate默认的序列化规则:改变默认的序列化规则,在MyRedisConfig中配置的
empRedisTemplate.opsForValue().set("emp-01", employee);
}
注意:当在redis中放入对象时,该对象需要被序列化(实现Serializable接口 ),如果直接用restTempPlate放入(jdk的序列化器),那么会产生以下的情况 ,并不是以json形式传入的,
这是因为默认使用的是jdk的序列化机制,而我们在日常生产中,更多使用的是json形式的数据,为此,我们可以自定义一个配置,改变默认的序列化规则(使用json的序列化机制)。配置类如下:
@Configuration
public class MyRedisConfig {
// 同理,如果要转换别的对象,可以重新定义新的template。
@Bean
public RedisTemplate<Object, Employee> empRedisTemplate(RedisConnectionFactory redisConnectionFactory)
throws UnknownHostException {
RedisTemplate<Object, Employee> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
// 创建一个json的序列化器
Jackson2JsonRedisSerializer<Employee> jjrs = new Jackson2JsonRedisSerializer<Employee>(Employee.class);
template.setDefaultSerializer(jjrs);
return template;
}
}
这样,使用empRedistemplate 再重新存入对象,就变成如下的形式:
测试缓存:
原理:CacheManager===Cache 缓存组件来实际给缓存中存取数据
1) 引入redis的starter ,容器中保存的是RedisCacheManager
2) RedisCacheManager帮我们自动创建RedisCache来作为缓存组件,RedisCache通过操作redis来缓存数据。
3) 默认保存数据k-v都是object,利用序列化保存,如何保存为json?
1. 引入redis的starter ,cacheManager变为RedisCacheManager
2. 默认创建的RedisCacheManager操作redis的时候使用的是 jdk的序列化机制(RedisCacheConfiguration.class)
public static RedisCacheConfiguration defaultCacheConfig() {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
registerDefaultConverters(conversionService);
return new RedisCacheConfiguration(Duration.ZERO, true, true, CacheKeyPrefix.simple(), SerializationPair.fromSerializer(new StringRedisSerializer()), SerializationPair.fromSerializer(new JdkSerializationRedisSerializer()), conversionService);
}
3. 自定义CacheManager。
@Bean
public RedisCacheManager empCacheManager(RedisConnectionFactory factory) {
Jackson2JsonRedisSerializer<Employee> serializer = new Jackson2JsonRedisSerializer<Employee>(Employee.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));
RedisCacheManager redisCacheManager = RedisCacheManager.builder(factory).cacheDefaults(cacheConfiguration).build();
return redisCacheManager;
}
自定义CacheManager后,怎么使得我们自己的定义的cacheManager生效呢?在RedisCacheConfiguration的注解中可以看到,
@ConditionalOnMissingBean ,也就是说,如果容器中没有CacheManager,就使用该配置创建缓存管理器,但是容器中有,就会替换原有的,于是我们就可以直接启动项目,测试。 如下:
另外 ,以上全部是通过注解式的将数据放入缓存,也可以通过编码式的将数据放入缓存,可以在service中引入 自定义的manager,在需要操作缓存的地方,使用manager.getCache(),拿到缓存对象,然后操作数据。
@Service
@CacheConfig(cacheNames = "dep", cacheManager = "deptCacheManager") //因为存在多个cacheManager ,通过该注解,指明使用某个cacheManager
public class DepartmentService {
@Qualifier("deptCacheManager") //精确的根据id名为deptCacheManager拿到bean注入
@Autowired
RedisCacheManager deptCacheManager; //根据名称获取缓存
/**
* 编码式 存入缓存
* @param id
* @return
*/
public Department getDepById(Integer id){
System.out.println("查询部门 " + id);
Department dept= departmentMapper.getDeptById(id);
//获取缓存。
Cache deptCache= deptCacheManager.getCache("dep");
//放入缓存
deptCache.put("dept:"+dept.getId(),dept);
return dept ;
}
}
注意:当容器中存在多个cacheManager时, 要有一个主cacheManager,即用 @Primary 标识的manager,另外不同的service操作时,要指定 其使用的cacheManager。
补充: @Cachable() 标识的方法内部缓存失效问题解决。
如下所示:
@Transactional(readOnly = true)
public List<PersonInfo> AllPerson(String tid) {
List<TPerson> tPerson= mapper.getTPerson(tid);
if (tPerson== null) {
return null;
}
List<PersonInfo> personInfos= tPerson.stream().map(t-> this.getPersonDetail(t.getId()))
.collect(Collectors.toList());
return personInfos;
}
该方法使用了@Cachable注解(注解略.....)对返回结果进行缓存,同时方法中同时调用了本类的另一个方法,调用后,却发现getPersonDetail()方法的返回结果并未缓存,这是什么原因呢? 这是由于Spring Cache时基于动态生成的proxy代理来对方法进行切面,如果对象的方法是内部调用(即this引用),而不是外部引用,则会导致proxy失效。 即AllPerson方法又调用getPersonDetail()时并未走代理,Spring并不知道getPersonDetail()有注解。
这里提供一种解决办法:
定义一个Spring的上下文。
@Component
public class SpringContextUtil implements ApplicationContextAware {
// Spring应用上下文。
private static ApplicationContext applicationContext;
/**
* 实现 ApplicationContextAware接口的回调方法,设置上下文环境
*
* @param applicationContext
* @throws BeansException
*/
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringContextUtil.applicationContext = applicationContext;
}
/**
* 返回spring上下文。
*
* @return
*/
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
/**
* 从spring容器中获取对象。
*
* @param beanName
* @return
* @throws BeansException
*/
public static Object getBean(String beanName) throws BeansException {
return applicationContext.getBean(beanName);
}
/**
* 通用类型: 通过字节码获取对象。
*
* @param t
* @param <T>
* @return
* @throws BeansException
*/
public static <T> T getBean(Class<T> t) throws BeansException {
return applicationContext.getBean(t);
}
}
然后在上述方法中,通过spring 上下文重新拿到对象,调用方法,即方法将变为如下形式:
@Transactional(readOnly = true)
public List<PersonInfo> AllPerson(String tid) {
List<TPerson> tPerson= mapper.getTPerson(tid);
if (tPerson== null) {
return null;
}
List<PersonInfo> personInfos= tPerson.stream().map(t-> SpringContextUtil.getBean(PersonService.class) //本类
.getPersonDetail(t.getId()))
.collect(Collectors.toList());
return personInfos;
}
3. 使用redis在高并发下出现的缓存失效问题
1) 缓存穿透
缓存穿透指 查询一个一定不存在的数据,由于缓存中是不命中的,所以会去查询数据库,但数据库也无此记录,同时也没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
风险: 利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃
解决方案: 对null结果也进行缓存,并加入短暂的过期时间
2)缓存雪崩
缓存雪崩指的是我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB ,DB瞬时压力过重雪崩。
解决方案: 可以在原有的失效时间内,增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,从而减小集体失效的事件发生几率。
3) 缓存击穿
缓存击穿指 对于一些设置了过期时间的key ,如果这些key可能在某些时间点被超高并发的访问,是一种非常热点的数据, 如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询将会同时落到DB,称之为 缓存击穿。
解决方案: 加锁, 大量并发只让一个去查,其他人进行等待,查到以后释放锁,其他人获取锁,先查缓存,就会有数据,而不会再次进入DB查询。