Content Table

Redis 集成

如果数据每次都从数据库查询,当并发大的时候,性能就会急剧的下降,常用、很少变化的数据可以采用 Redis 作为缓存,数据首先从 Redis 里查询,如果 Redis 里没有,然后才从数据库查询,并把查询到的结果放入 Redis。

Gradle 依赖

1
2
compile 'org.springframework.data:spring-data-redis:1.7.4.RELEASE'
compile 'org.springframework.session:spring-session-data-redis:1.2.2.RELEASE'

为了方便且以后可能需要在集群里使用 Redis 存放 Session,故引入了 spring-session-data-redis
同时也指定了使用更新的 spring-data-redis

redis.xml

存放在 resources/config/redis.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"/>
<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
<!--<property name="hostName" value="${redis.host}" />-->
<!--<property name="port" value="${redis.port}" />-->
<!--<property name="password" value="${redis.password}" />-->
<!--<property name="timeout" value="${redis.timeout}" />-->
<property name="database" value="0"/>
<property name="poolConfig" ref="jedisPoolConfig"/>
<property name="usePool" value="true"/>
</bean>

<bean id="redisTemplate" class="org.springframework.data.redis.core.StringRedisTemplate">
<property name="connectionFactory" ref="jedisConnectionFactory"/>
</bean>
</beans>

redisTemplate 用来访问 Redis

引入 redis.xml

在 web.xml 的 listener 中加载 redis.xml

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 加载 MyBatis, Spring Security, Redis 配置等 -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>
classpath:config/redis.xml
</param-value>
</context-param>

<!-- Listener -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

使用 RedisTemplate 访问 Redis

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
@Autowired
private DemoMapper demoMapper;

@Autowired
private StringRedisTemplate redisTemplate;

@GetMapping("/demos/{id}")
@ResponseBody
public Demo findDemoById(@PathVariable int id) {
// [1] 先从 Redis 查找
// [2] 找到则返回
// [3] 找不到则从数据库查询,查询结果放入 Redis
Demo d = null;
String redisKey = "demo_" + id;
String json = redisTemplate.opsForValue().get(redisKey);

if (json != null) {
// 如果解析发生异常,有可能是 Redis 里的数据无效,故把其从 Redis 删除
try {
d = JSON.parseObject(json, Demo.class);
} catch (Exception ex) {
logger.warn(ex.getMessage());
redisTemplate.delete(redisKey);
}
}

if (d == null) {
d = demoMapper.findDemoById(id);

if (d != null) { // 这里需要仔细考虑,null 对象如果不放缓存,如果这个对象被大量访问,会导致缓存穿透,增加数据库的压力
redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(d));
}
}

return d;
}

清空 Redis 里的数据

1
flushdb

查看 Redis 缓存是否生效

  • 打印日志
  • 查看数据库链接的非活跃时间,查询到了数据,但是非活跃时间越来越长,说明缓存生效了
    • MySQL 执行 show full processlist

重构

观察从 Redis 取数据,没有再从数据库取,然后放入 Redis 的代码,除了 d = JSON.parseObject(json, Demo.class) 和 d = demoMapper.findDemoById(id) 这二句会根据不同的业务逻辑不同外,其他的代码都是不变的,所以可以使用策略模式进行重构。

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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.stream.Collectors;

/**
* 提供访问 Redis 缓存的功能,默认缓存时间为 1 个小时,也可以自己设置缓存时间
*/
@Service
public class RedisDao {
@Autowired
private StringRedisTemplate redisTemplate;

private static final long DEFAULT_CACHE_DURATION_IN_SECONDS = 3600; // 默认缓存时间为 1 小时 (3600 秒)

/**
* 缓存优先读取 JavaBean,如果缓存中没有,则从数据库查询并保存到缓存
*
* @param redisKey Redis 中缓存的 key
* @param clazz 实体类型
* @param supplier 缓存失败时的数据提供器, supplier == null 时 return null
* @param <T> 类型约束
* @return 实体对象
*/
public <T> T get(String redisKey, Class<T> clazz, Supplier<T> supplier) {
return get(redisKey, clazz, null, supplier, DEFAULT_CACHE_DURATION_IN_SECONDS);
}

/**
* 缓存优先读取 JavaBean,如果缓存中没有,则从数据库查询并保存到缓存
*
* @param redisKey Redis 中缓存的 key
* @param clazz 实体类型
* @param supplier 缓存失败时的数据提供器, supplier == null 时 return null
* @param timeoutSeconds 缓存超时时间,单位为秒
* @param <T> 类型约束
* @return 实体对象
*/
public <T> T get(String redisKey, Class<T> clazz, Supplier<T> supplier, long timeoutSeconds) {
return get(redisKey, clazz, null, supplier, timeoutSeconds);
}

/**
* 缓存优先读取 Collections Or Map,如果缓存中没有,则从数据库查询并保存到缓存
*
* @param redisKey Redis 中缓存的 key
* @param typeReference 反序列化集合时 FastJson 需要用 TypeReference 来指定类型,例如类型为 List<Demo>
* @param supplier 缓存失败时的数据提供器,supplier == null 时 return null
* @param <T> 类型约束
* @return 实体对象
*/
public <T> T get(String redisKey, TypeReference<T> typeReference, Supplier<T> supplier) {
return get(redisKey, null, typeReference, supplier, DEFAULT_CACHE_DURATION_IN_SECONDS);
}

/**
* 缓存优先读取 Collections Or Map,如果缓存中没有,则从数据库查询并保存到缓存
*
* @param redisKey Redis 中缓存的 key
* @param typeReference 反序列化集合时 FastJson 需要用 TypeReference 来指定类型,例如类型为 List<Demo>
* @param supplier 缓存失败时的数据提供器,supplier == null 时 return null
* @param timeoutSeconds 缓存超时时间,单位为秒
* @param <T> 类型约束
* @return 实体对象
*/
public <T> T get(String redisKey, TypeReference<T> typeReference, Supplier<T> supplier, long timeoutSeconds) {
return get(redisKey, null, typeReference, supplier, timeoutSeconds);
}

/**
* 缓存优先读取缓存,如果缓存中没有,则从数据库查询并保存到缓存
*
* @param redisKey Redis 中缓存的 key
* @param clazz 对象的类型 (clazz 和 typeReference 互斥使用)
* @param typeReference 反序列化集合时 FastJson 需要用 TypeReference 来指定类型,例如类型为 List<Demo>
* @param supplier 缓存失败时的数据提供器,supplier == null 时 return null
* @param timeoutSeconds 缓存超时时间,单位为秒
* @param <T> 类型约束
* @return 实体对象
*/
private <T> T get(String redisKey, Class<T> clazz, TypeReference<T> typeReference, Supplier<T> supplier, long timeoutSeconds) {
T d = null;
String json = redisTemplate.opsForValue().get(redisKey);

if (json != null) {
// 如果解析发生异常,有可能是 Redis 里的数据无效,故把其从 Redis 删除
try {
if (clazz != null) {
d = JSON.parseObject(json, clazz);
} else {
d = JSON.parseObject(json, typeReference);
}
} catch (Exception ex) {
redisTemplate.delete(redisKey);
}
}

if (d == null && supplier != null) {
d = supplier.get();
// 这里需要考虑,null 对象如果不放缓存,如果这个对象被大量访问,会导致缓存穿透,增加数据库的压力
// 综合考虑, null 时可缓存 2s, 这样可以防止短时间内的大量访问穿透缓存, 又能不被 null 占据缓存
if (d != null) {
redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(d), timeoutSeconds, TimeUnit.SECONDS);
}
}

return d;
}

/**
* 使用 Redis Set 缓存数据,如果缓存没有命中,则使用提供器的数据并保存到缓存
*
* @param redisKey Redis 中缓存的 key
* @param supplier 缓存失败时的数据提供器
* @param timeoutSeconds 缓存超时时间,单位为秒
* @param <T> 类型约束
* @return 对象集合
*/
public <T> Set<T> getFromSet(String redisKey, Class<T> clazz, @Nullable Supplier<Set<T>> supplier, long timeoutSeconds) {
BoundSetOperations<String, String> operations = redisTemplate.boundSetOps(redisKey);
Set<String> members = operations.members(); // members 有可能返回 null

if (members != null && !members.isEmpty()) {
return members.stream().map(member -> JSON.parseObject(member, clazz)).collect(Collectors.toSet());
} else if (supplier != null) {
Set<T> newSet = supplier.get();

if (newSet != null && !newSet.isEmpty()) {
String[] array = newSet.stream().map(JSON::toJSONString).toArray(String[]::new);
operations.add(array);
operations.expire(timeoutSeconds, TimeUnit.SECONDS);

return newSet;
}
}

return Collections.emptySet();
}

/**
* 指定的 value 是否在 Set values 中。
* <p>
* 如果未命中缓存,可能key对应的values未放入缓存或数据过期,考虑把数据放入缓存后再检索
*
* @param redisKey key
* @param value value
* @return 是否命中缓存
*/
public boolean isMember(String redisKey, String value) {
Boolean is = redisTemplate.boundSetOps(redisKey).isMember(value); // isMember 有可能返回 null
return is == null ? false : is;
}

/**
* 删除 key 的缓存
*
* @param key 缓存的 key
*/
public void deleteCache(String key) {
redisTemplate.delete(key);
}
}
1
2
3
4
5
6
7
8
@GetMapping("/demos/{id}")
@ResponseBody
public Demo findDemoById(@PathVariable int id) {
String redisKey = "demo_" + id;
Demo d = redisDao.get(redisKey, Demo.class, () -> demoMapper.findDemoById(id));

return d;
}

不同业务场景可以使用 Lambda 表达式实现 RedisDao.get() 的最后一个参数 Supplier,不需要再关心从 Redis 取数据和存储数据到 Redis 的代码了。

Spring 注解使用 Redis

还可以使用 spring-data-redis 的注解 @Cacheable, @CacheEvict 实现缓存 (使用注解操作 Redis 中的缓存), 但也有缺点, 例如缓存时间只能统一配置, 不能为特定的 key 设置缓存时间, 是否缓存 null 值也不能自定义, 怎么解决缓存穿透是个问题.

Redis 配置

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
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:cache="http://www.springframework.org/schema/cache"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/cache
http://www.springframework.org/schema/cache/spring-cache.xsd">
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"/>
<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
<property name="poolConfig" ref="jedisPoolConfig"/>
<property name="usePool" value="true"/>
<property name="database" value="0"/>
</bean>

<bean id="redisTemplate" class="org.springframework.data.redis.core.StringRedisTemplate">
<property name="connectionFactory" ref="jedisConnectionFactory"/>
</bean>

<bean id="cacheManager" class="org.springframework.data.redis.cache.RedisCacheManager">
<constructor-arg ref="redisTemplate"/>
<property name="defaultExpiration" value="3000"/>
<property name="transactionAware" value="true"/>
<property name="usePrefix" value="true"/>
</bean>

<!-- 启用缓存注解功能,这个是必须的,否则注解不会生效: 使用 cacheManager -->
<cache:annotation-driven/>

<bean id="redisUserService" class="com.xtuer.RedisUserService"/>
</beans>

数据访问服务

使用缓存注解 @Cacheable@CacheEvict

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

import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class RedisUserService {
/**
* 第一次查询时从数据库读取, 并保存到 Redis.
*
* value 为 Redis 键的前缀, 不能为空
* key 为 Redis 键的动态部分, 由传入的参数确定, 使用
* Spring Expression Language (SpEL) expression for computing the key dynamically.
*
* 调用函数 findUserById(123) Redis 中生成缓存的 key 为: user:id_123
*/
@Cacheable(value="user", key="'id_' + #id")
public String findUserById(int id) {
System.out.println("Find user: " + id);
return "User_" + id;
}

/**
* 删除用户, 并删除 Redis 中的的缓存
*/
@CacheEvict(value="user", key="'id_' + #id")
public void deleteUser(int id) {
System.out.println("Delete user: " + id);
}
}

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import com.xtuer.RedisUserService;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

public class RedisTest {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("redis-cache.xml");
RedisUserService service = context.getBean("redisUserService", RedisUserService.class);

System.out.println(service.findUserById(123)); // 创建对象, 生成缓存
System.out.println(service.findUserById(123)); // 直接从缓存读取
service.deleteUser(123); // 删除缓存
}
}