SpringBoot2 | 第十六篇:SpringCache集成Redis

​ 上一篇简单的介绍了 Spring Cache 的使用方法,用的是默认缓存管理器 ConcurrentMapCacheManager, 本篇文章介绍 Spring Cache 如何集成 Redis

[TOC]

集成

​ 我们只需引入依赖 spring-boot-starter-data-redis,Spring Boot 扫描到后,RedisCacheConfiguration 自动配置生效,redis 缓存管理器 RedisCacheManager 就会 取代 默认缓存管理器ConcurrentMapCacheManager 成了 CacheManager 的实现

环境/版本一览:

  • 开发工具:Intellij IDEA 2018.2.2
  • springboot: 2.0.5.RELEASE
  • jdk:1.8.0_171
  • maven:3.3.9
  • spring-boot-starter-cache2.0.5.RELEASE
  • spring-boot-starter-data-redis:2.0.5.RELEASE

1、pom.xml

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
<dependencies>
<!-- 不需要引入 Cache 依赖了,下边 redis 已经依赖了 spring-boot-starter -->
<!--<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>-->
<!-- web starter关联依赖 spring-boot-starter-json -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 整合Lettuce Redis需要commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
</dependency>
</dependencies>

2、application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
spring:
redis:
host: localhost
password: 123456
# Redis默认情况下有16个分片,这里配置具体使用的分片,默认是0
database: 1
lettuce:
pool:
# 当池耗尽时,在引发异常之前连接分配可以阻塞的最长时间(使用负值表示没有限制) 默认 -1
max-wait: -1ms
# 连接池最大连接数(使用负值表示没有限制) 默认 8
max-active: 8
# 连接池中的最大空闲连接 默认 8
max-idle: 8
# 连接池中的最小空闲连接 默认 0
min-idle: 0
# 连接超时时间
timeout: 10000ms
# 一般来说是不用配置的,Spring Cache 会根据依赖的包自行装配
cache:
type: redis

3、config

自定义缓存方式的 keyvalue 的序列化方式

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
package com.fatal.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
* @author Fatal
* @date 2019/8/14 0014 20:12
*/
@Configuration
public class RedisConfig {

/**
* 自定义 RedisCacheConfiguration
* @return
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
// 自定义 key 的序列化器
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer()))
// 自定义 value 的序列化器
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer()))
// 禁止缓存 null 值
.disableCachingNullValues();
}

@Bean
public RedisSerializer<String> keySerializer() {
return new StringRedisSerializer();
}

@Bean
public GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}

}

4、entity

实体必须实现序列化

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
package com.fatal.entity;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import lombok.Data;
import lombok.experimental.Accessors;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
* User 实体
* @author: Fatal
* @date: 2018/10/15 0015 11:23
*/
@Data
@Accessors(chain = true)
public class User implements Serializable {

private Long id;
private String username;
private String password;

/**
* LocalDateTime 由于没有无参构造器,所以它需要使用特定的序列化器 `LocalDateTimeSerializer`
* 和 反序列化 `LocalDateTimeDeserializer`
*/
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime createTime;

@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime updateTime;

}
  • LocalDateTime 的序列化和反序列化

    LocalDateTime 比较特殊,由于没有无参构造器,所以它需要使用特定的序列化器 LocalDateTimeSerializer 和 反序列化 LocalDateTimeDeserializer

5、dto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.fatal.dto;

import lombok.Data;
import lombok.experimental.Accessors;

/**
* @author Fatal
* @date 2019/8/14 0014 10:06
*/
@Data
@Accessors(chain = true)
public class ParamDTO {

private Long id;

private String username;

}

6、dao

###IUserDao.java

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
package com.fatal.dao;

import com.fatal.dto.ParamDTO;
import com.fatal.entity.User;

import java.util.List;

/**
* User 数据库访问层
* @author: Fatal
* @date: 2018/10/14 0014 17:08
*/
public interface IUserDao {

/** 改 */
User update(User user);

/** 删 */
User remove(Long id);

/** 查 */
User selectById(Long id);

/** 查集合 */
List<User> listUser();

/** 根据DTO查询集合 */
List<User> listUser(ParamDTO paramDTO);

}

UserDaoImpl.java

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package com.fatal.dao.impl;

import com.fatal.dao.IUserDao;
import com.fatal.dto.ParamDTO;
import com.fatal.entity.User;
import org.springframework.stereotype.Repository;
import org.springframework.util.ObjectUtils;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* User 数据库访问层实现
* @author: Fatal
* @date: 2018/10/14 0014 17:11
*/
@Repository
public class UserDaoImpl implements IUserDao {

/** 模拟数据库 */
private static Map<Long, User> db = new HashMap<>();

static {
// 初始化数据库
db.put(1L,new User()
.setId(1L)
.setUsername("米彩")
.setPassword("18")
.setCreateTime(LocalDateTime.now())
.setUpdateTime(LocalDateTime.now()));
db.put(2L,new User()
.setId(2L)
.setUsername("米琪")
.setPassword("19")
.setCreateTime(LocalDateTime.now())
.setUpdateTime(LocalDateTime.now()));
db.put(3L,new User()
.setId(3L)
.setUsername("小米")
.setPassword("20")
.setCreateTime(LocalDateTime.now())
.setUpdateTime(LocalDateTime.now()));
}

@Override
public User update(User user) {
db.put(user.getId(), user);
return user;
}

@Override
public User remove(Long id) {
return db.remove(id);
}

@Override
public User selectById(Long id) {
return db.get(id);
}

@Override
public List<User> listUser() {
return new ArrayList<>(db.values());
}

@Override
public List<User> listUser(ParamDTO paramDTO) {
return db.values().stream()
.filter(user -> {
// 模拟多条件查询
Long id = paramDTO.getId();
String username = paramDTO.getUsername();
if (!ObjectUtils.isEmpty(id) && !ObjectUtils.isEmpty(username)) {
return id.equals(user.getId()) && username.equals(user.getUsername());
} else if (!ObjectUtils.isEmpty(id)) {
return id.equals(user.getId());
} else if (!ObjectUtils.isEmpty(username)){
return username.equals(user.getUsername());
} else {
return true;
}
})
.collect(Collectors.toList());
}

}

7、service

###IUserService.java

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
package com.fatal.service;

import com.fatal.dto.ParamDTO;
import com.fatal.entity.User;

import java.util.List;

/**
* User 服务
* @author: Fatal
* @date: 2018/10/14 0014 17:28
*/
public interface IUserService {

/** 修改 */
User update(User user);

/** 删 */
User remove(Long id);

/** 查 */
User selectById(Long id);

/** 查集合 */
List<User> listUser();

/** 根据DTO查询集合 */
List<User> listUser(ParamDTO paramDTO);

/** 修改(解决低流量下,缓存数据库双写一致性问题) */
User lowFlowRateWithUpdate(User user);

/** 修改(解决高并发下,缓存数据库双写一致性问题) */
User highConcurrencyWithUpdate(User user);

}

UserServiceImpl.java

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
package com.fatal.service.impl;

import com.fatal.dao.IUserDao;
import com.fatal.dto.ParamDTO;
import com.fatal.entity.User;
import com.fatal.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;

/**
* User 服务实现
* @author: Fatal
* @date: 2018/10/14 0014 17:28
*/
@Slf4j
@Service
public class UserServiceImpl implements IUserService {

@Autowired
private IUserDao dao;

@Autowired
private StringRedisTemplate template;

@Override
@CachePut(cacheNames = "user", key = "#user.id")
public User update(User user) {
// 健壮性判断...
log.info("进入【update】方法");
return dao.update(user);
}

@Override
@Caching(evict = {
@CacheEvict(cacheNames = "user", key = "#id", beforeInvocation = true)
})
public User remove(Long id) {
// 健壮性判断...
log.info("进入【remove】方法");
// 删除相关的数据,用户都不存在了,那相关的那些缓存留着干嘛
removeByPrefix(id);
return dao.remove(id);
}

@Override
@Cacheable(unless="#result == null", cacheNames = "user", key = "#id")
public User selectById(Long id) {
// 健壮性判断...
log.info("进入【selectById】方法");
return dao.selectById(id);
}


@Override
@Cacheable(unless="#result == null || #result.size == 0", cacheNames = "users", key = "'users'")
public List<User> listUser() {
// 健壮性判断...
log.info("进入【listUser()】方法");
return dao.listUser();
}

/**
* 查询集合、分页要不要做缓存,可以根据参数的复杂程度,如果参数是条件单一(比如只有id),则适合做缓存,
* 如果参数是一个DTO,里边的属性随着变化,出现了很多种情况(数学的`组合`问题),这中场景下要不要做缓存,
* 可以根据实际情况分析再做决定,对,一定要分析。下面以DTO作为参数进行讨论:
* 比如:DTO 有三个属性,分别为 1、2、3,那么出现的组合就有(因为位置是确定的,所以就变成`组合`问题)
* 1、、、
* 1、2、、
* 1、2、3、
* 1、、3、
* 、2、、
* 、2、3、
* 、、3、
* 三个属性就有7种情况,也就是一个实体对应7种,7 种只是根据位置是否有值
* 假设1有10个值,2有3个值,3有2个值
* 那么所有的可能为 7 * 10 * 3 * 2 = 420 种情况,即通过查询这个方法,累计会生成 420 条缓存(临界值)。
* 如果这些缓存使用频率高的话,那么一定要用。(减少数据库负担嘛)
* 如果频率偏低的话,那用不用根据实际情况吧。(缓存条数,查询结果的大小等等)
* 结论:
* 根据频率决定吧,如果查询条件相同的有一定的几率,建议使用。
* @return
*/
@Override
@Cacheable(unless="#result == null || #result.size == 0", cacheNames = "users", key = "#paramDTO.id + ':' + #paramDTO.username")
public List<User> listUser(ParamDTO paramDTO) {
// 健壮性判断...
log.info("进入【listUser(ParamDTO paramDTO)】方法");
return dao.listUser(paramDTO);
}

@Override
@CacheEvict(cacheNames = "user", key = "#user.id", beforeInvocation = true)
public User lowFlowRateWithUpdate(User user) {
// 健壮性判断...
log.info("进入【update】方法");
return dao.update(user);
}

@Override
@Caching(evict = {
@CacheEvict(cacheNames = "user", key = "#user.id", beforeInvocation = true),
@CacheEvict(cacheNames = "user", key = "#user.id")
})
public User highConcurrencyWithUpdate(User user) {
// 健壮性判断...
log.info("进入【update】方法");
return dao.update(user);
}

/**
* 模糊删除多条件生成的缓存
* @desc redis 命令没有模糊删除命令。所以使用了 keys * 模糊匹配出需要删除的Set<key>,然后全部删除
* @param id
*/
private void removeByPrefix(Long id) {
Set<String> keys = template.keys("users::" + id + ":*");
template.delete(keys);
}

/**
* @Cacheable 根据方法的请求参数对其结果进行缓存
* @CachePut 根据方法的请求参数对其结果进行缓存,和 @Cacheable 不同的是,它每次都会触发真实方法的调用
* @CachEvict 根据条件对缓存进行清空
*/

}

8、Application

开启缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.fatal;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication
@EnableCaching // 启用缓存
public class Chapter16Application {

public static void main(String[] args) {
SpringApplication.run(Chapter16Application.class, args);
}
}

9、Test

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
package com.fatal;

import com.fatal.dto.ParamDTO;
import com.fatal.entity.User;
import com.fatal.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.test.context.junit4.SpringRunner;

import java.time.LocalDateTime;
import java.util.List;

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class Chapter16ApplicationTests {

@Autowired
private IUserService userService;

@Autowired
private CacheManager cacheManager;

@Test
public void testCache() {
/**
* 测试查询(有缓存,没有显示进入【selectById】方法)
*/
User search = userService.selectById(1L);
log.info("【查询成功】 = [{}]", search);

/**
* 测试更新
*/
search.setUsername("fatal")
.setUpdateTime(LocalDateTime.now());
User update = userService.update(search);
log.info("【更新成功】 = [{}]", update);

/**
* 测试删除(缓存也一同被删除了)
*/
User remove = userService.remove(search.getId());
log.info("【删除成功】 = [{}]", remove);

/**
* 测试删除后查询(缓存没了,显示进入【selectById】方法)
*/
User selectAfterDelete = userService.selectById(search.getId());
log.info("【查询成功】 = [{}]", selectAfterDelete);
}

@Test
public void testListUser() {
List<User> users = userService.listUser();
log.info("【查询成功】 = [{}]", users);
}

@Test
public void testListUserWithParam() {
ParamDTO paramDTO = new ParamDTO()
.setId(1L)
.setUsername("米彩");
/*ParamDTO paramDTO = new ParamDTO()
.setId(2L)
.setUsername("米琪");*/
List<User> users = userService.listUser(paramDTO);
log.info("【查询成功】 = [{}]", users);
}

@Test
public void testSelectById() {
User search = userService.selectById(1L);
log.info("【查询成功】 = [{}]", search);
}

@Test
public void testRemove() {
User remove = userService.remove(1L);
log.info("【删除成功】 = [{}]", remove);
}


/**
* 测试低流量情况下更新数据缓存与数据库双写一致性问题
*/
@Test
public void testLowFlowRateWithUpdate() {
User search = userService.selectById(1L);
search.setPassword("123456");
userService.lowFlowRateWithUpdate(search);
log.info("【更新成功】 = [{}]", search);
}

/**
* 测试高并发情况下更新数据缓存与数据库双写一致性问题
*/
@Test
public void testHighConcurrencyWithUpdate() {
User search = userService.selectById(1L);
search.setPassword("123456");
userService.highConcurrencyWithUpdate(search);
log.info("【更新成功】 = [{}]", search);
}

/**
* 测试缓存管理器
*/
@Test
public void testCacheManager() {
System.out.println(cacheManager);
}

}

10、测试

测试一

先运行 Chapter16ApplicationTests.testSelectById()

然后运行 Chapter16ApplicationTests.testCache()

1539613483244

​ 结果和我们期望的一致,可以看到增删改查中,第一次查询是没有日志输出的,因为它直接从缓存中获取的数据,而添加、修改、删除都是会进入方法内执行具体的业务代码,然后通过切面去删除掉Redis中的缓存数据。其中 # 号代表这是一个 SpEL 表达式,此表达式可以遍历方法的参数对象,具体语法可以参考 Spring 的相关文档手册。

测试二

运行Chapter16ApplicationTests.testCacheManager()

1539613778577

测试三

运行Chapter16ApplicationTests.testSelectById()

查看 redis 图形化界面,LocalDateTime 序列化成功

1565791818902

测试四

运行两次 Chapter16ApplicationTests.testListUserWithParam()(换个参数)

再运行 Chapter16ApplicationTests.testSelectById()

初始化一下缓存数据

1565754420399

再运行 Chapter16ApplicationTests.testRemove(),测试删除

1565754630617

测试五

用缓存的时候,缓存与数据库双写一致性是比较重要的,读的话,先从数据库读,然后存到缓存中再响应。那么更新的时候,如何保证缓存与数据库双写一致性。在低流量的前提下,有一种解决方案,就是先删除缓存之后再更新数据库。为什么不是先更新之后再删除呢?因为如果更新的过程中出现问题,导致删除缓存的逻辑没有执行,就会出现缓存数据库双写不一致问题。

接下来,测试解决方案

这个测试方法会先将数据查出来,放到缓存中;在 lowFlowRateWithUpdate(..) 的方法执行前打个断点,方法体里边也打个断点

debug 运行 Chapter16ApplicationTests.testLowFlowRateWithUpdate(),如下:

执行到 lowFlowRateWithUpdate(..) 方法前,我们用 Redis 图形化界面 看看数据

1567928403686

1567928653660

数据查出来并且已经放缓存中了,接下来放行,来到 lowFlowRateWithUpdate(..) 方法体中,用 Redis 图形化界面 将数据库 reload 一下看看结果

1567928475090

1567928794938

缓存在执行更新数据库操作之前正常删除了。

测试六

上面那套方案只适用于低流量的情况下,高并发情况下,更新数据用上面那套方案有什么问题呢?

假设现在有一条请求是更新数据的,在删除缓存的时候,更新操作还没执行完,出现了另一个请求读取数据,从数据库读取未更新前的数据,并写到缓存中,那么这个时候就会出现 缓存与数据库双写不一致的问题了。怎么解决呢?

我自己的思路是:先删除缓存,然后更新数据库,更新完数据再删除缓存,以确保更新操作能将缓存正常删除。

具体实现上边写好了,接下来看看怎么测试,模拟出这种情况吧。

这个测试方法也会先将数据查出来,放到缓存中;和上边一样,在 highConcurrencyWithUpdate(..) 方法执行之前打个断点,方法里边也加个断点。

debug 运行 Chapter16ApplicationTests.testHighConcurrencyWithUpdate(..)

执行到 highConcurrencyWithUpdate(..) 方法前,我们用 Redis 图形化界面 看看数据

1567929785313

1567929800783

数据查出来并且已经放缓存中了,接下来放行,来到 highConcurrencyWithUpdate(..) 方法体中,用 Redis 图形化界面 将数据库 reload 一下看看结果,数据正常

1567929889747

1567929902398

这些和上边也一样,但是为了测试流程的完整性,还是贴出来吧,方便自己日后看。

来到这里之后,我们需要另起一个线程去查询数据,执行 Chapter16ApplicationTests.testSelectById()

Redis 图形化界面 将数据库 reload 一下

1567930098803

缓存又查出来了,数据正常,接下来,我们将 highConcurrencyWithUpdate(..) 的断点放行了,再用 Redis 图形化界面 将数据库 reload 一下看看结果

1567930208771

缓存数据正常删除了。这样在高并发的情况下更新数据也能保证 缓存与数据双写一致性 了。

参考资料

spring-cache文档

spring-data-redis文档

Redis 文档

Redis 中文文档

SpringBoot非官方教程 | 第十三篇:springboot集成spring cache

一起来学SpringBoot | 第十篇:使用Spring Cache集成Redis

Caching Data with Spring

总结

Cache 注解用法可参考上一篇

Redis 作为缓存实现,保证缓存与数据库双写一致性可以使用上边的方式,当然咯,不管是不是 Spring Cache 作为缓存门面,逻辑都一样:更新数据库前后都执行删除缓存操作即可。

源码地址: https://github.com/ynfatal/springboot2-learning/tree/master/chapter16

学习 唐亚峰方志朋 前辈的经验