使用Redis作为Mybatis二级缓存

本文主要记录redis作为mybatis二级缓存时所遇到的问题,因为mybatis以及缓存基于sqlsession,会话完成后,缓存数据就会被清空,二级缓存基于mapper,虽然解决了sqlsession的问题,但是还是基于本地内存,应用被杀掉后,缓存还是被清空,并且分布式环境下会出现缓存一致性问题。所以现在大部分二级缓存都是使用redis实现,有效解决分布式情况下缓存问题。当然,redis分布式缓存也会存在一定的问题。后面有时间再记录。

添加redis依赖及配置

springboot下直接添加如下依赖

1
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

这里搭建的是redis哨兵模式,在application.yaml中配置

1
2
3
4
5
6
7
8
9
10
11
spring:
redis: #redis 缓存设置
password: 123456
sentinel:
master: mymaster
nodes: 192.168.0.10:26379,192.168.0.10:26380,192.168.0.10:26381
database: 0
timeout: 60s
lettuce:
pool:
enabled: true

配置RedisTemplate

RedisTemplate主要需要配置key的序列化和value的序列化,各类json的框架都需要实现RedisSerializer,我们这里使用Spring配置的Jackson2JsonRedisSerializer如下所示

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
@Bean
public Jackson2JsonRedisSerializer<Object> redisJsonSerializer() {
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
/*通过该方法对mapper对象进行设置,所有序列化的对象都将按以下规则进行系列化
Include.Include.ALWAYS 默认
Include.NON_DEFAULT 属性为默认值不序列化
Include.NON_EMPTY 属性为 空("") 或者为 NULL 都不序列化,则返回的json是没有这个字段的。这样对移动端会更省流量
Include.NON_NULL 属性为NULL 不序列化,就是为null的字段不参加序列化
objectMapper.setSerializationInclusion(Include.NON_EMPTY);*/
// 添加自定义的序列化与反序列化操作,这里主要添加系统枚举抽象
SimpleModule sm = new SimpleModule();
sm.addSerializer(Long.class, ToStringSerializer.instance);
sm.addSerializer(Long.TYPE, ToStringSerializer.instance);
sm.addSerializer(BaseEnum.class, BaseEnumSerializer.instance);
sm.addSerializer(BaseConstEnum.class, ConstEnumSerializer.instance);
sm.addDeserializer(BaseEnum.class, BaseEnumDeserializer.instance);
sm.addDeserializer(BaseConstEnum.class, ConstEnumDeserializer.instance);
// 设置所有字段都参与序列化
objectMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
// 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 序列化时将对象全类名一起保存下来,并且final类不参与序列化
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
objectMapper.registerModule(sm);
serializer.setObjectMapper(objectMapper);
return serializer;
}
```+
这个配置很重要,如果不明白会造成很多问题,上面的配置中,特别注意activateDefaultTyping,不然redis反序列化时并不会转为实际的对象,而是LinkedHashMap。如下
```json
[
"com.buguagaoshu.redis.model.User",
{
"name": "1",
"age": "11",
"message": "牛逼"
}
]

如果没设置,json串将是这样

1
2
3
4
5
{
"name": "1",
"age": "11",
"message": "牛逼"
}

配置完Jackson2JsonRedisSerializer,就需要配置RedisTemplate了,将刚才配置的Jackson2JsonRedisSerializer注入到RedisTemplate中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Primary
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory factory, Jackson2JsonRedisSerializer<Object> serializer) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
// redis连接工厂
template.setConnectionFactory(factory);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// redis.key序列化器
template.setKeySerializer(stringRedisSerializer);
// redis.value序列化器
template.setValueSerializer(serializer);
// redis.hash.key序列化器
template.setHashKeySerializer(stringRedisSerializer);
// redis.hash.value序列化器
template.setHashValueSerializer(serializer);
// 调用其他初始化逻辑
template.afterPropertiesSet();
// 这里设置redis事务一致
template.setEnableTransactionSupport(true);
return template;
}

Jackson2JsonRedisSerializer作为redis value的序列化与反序列工具。

RedisCache实现

mybatis二级缓存需要实现Cache接口,可以参考mybatis PerpetualCache和LruCache缓存实现,如下所示

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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
package com.mayahx.stcm.common.config;

import com.mayahx.stcm.common.util.ApplicationContextUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomUtils;
import org.apache.ibatis.cache.Cache;

import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
* @author 姜陶
* @date 2021/12/23 14:42
* @describe mybatis redis二级缓存配置
* MyBatis二级缓存Redis实现
* 重点处理以下几个问题
* 1、缓存穿透:存储空值解决,MyBatis框架实现
* 2、缓存击穿:使用互斥锁,我们自己实现
* 3、缓存雪崩:缓存有效期设置为一个随机范围,我们自己实现
* 4、读写性能:redis key不能过长,会影响性能,这里使用SHA-256计算摘要当成key
**/
@Slf4j
public class RedisCache implements Cache {

/**
* 统一字符集
*/
private static final String CHARSET = "utf-8";
/**
* key摘要算法
*/
private static final String ALGORITHM = "SHA-256";
/**
* 统一缓存头
*/
private static final String CACHE_NAME = "MyBatis:";
/**
* 读写锁:解决缓存击穿
*/
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
/**
* 表空间ID:方便后面的缓存清理
*/
private final String id;
/**
* redis服务接口:提供基本的读写和清理
*/
private static volatile RedisServiceImpl redisService;
/**
* 信息摘要
*/
private volatile MessageDigest messageDigest;

/////////////////////// 解决缓存雪崩,具体范围根据业务需要设置合理值 //////////////////////////
/**
* 缓存最小有效期
*/
private static final int MIN_EXPIRE_MINUTES = 60;
/**
* 缓存最大有效期
*/
private static final int MAX_EXPIRE_MINUTES = 120;

/**
* MyBatis给每个表空间初始化的时候要用到
* @param id 其实就是namespace的值
*/
public RedisCache(String id) {
if (id == null) {
throw new IllegalArgumentException("Cache instances require an ID");
}
this.id = id;
}

/**
* 获取ID
* @return 真实值
*/
@Override
public String getId() {
return id;
}

/**
* 创建缓存
* @param key 其实就是sql语句
* @param value sql语句查询结果
*/
@Override
public void putObject(Object key, Object value) {
try {
String strKey = getKey(key);
// 有效期为1~2小时之间随机,防止雪崩
int expireMinutes = RandomUtils.nextInt(MIN_EXPIRE_MINUTES, MAX_EXPIRE_MINUTES);
getRedisService().set(strKey, value, expireMinutes, TimeUnit.MINUTES);
log.info("Put cache to redis, id={}", id);
} catch (Exception e) {
log.error("Redis put failed, id=" + id, e);
}
}

/**
* 读取缓存
* @param key 其实就是sql语句
* @return 缓存结果
*/
@Override
public Object getObject(Object key) {
try {
String strKey = getKey(key);
log.info("Get cache from redis, id={}", id);
return getRedisService().get(strKey);
} catch (Exception e) {
log.error("Redis get failed, fail over to db", e);
return null;
}
}

/**
* 删除缓存
* @param key 其实就是sql语句
* @return 结果
*/
@Override
public Object removeObject(Object key) {
try {
String strKey = getKey(key);
getRedisService().delete(strKey);
log.info("Remove cache from redis, id={}", id);
} catch (Exception e) {
log.error("Redis remove failed", e);
}
return null;
}

/**
* 缓存清理
* 网上好多博客这里用了flushDb甚至是flushAll,感觉好坑鸭!
* 应该是根据表空间进行清理
*/
@Override
public void clear() {
try {
log.info("clear cache, id={}", id);
String hsKey = CACHE_NAME + id;
// 获取CacheNamespace所有缓存key
Map<Object, Object> idMap = getRedisService().hashEntries(hsKey);
if (!idMap.isEmpty()) {
Set<Object> keySet = idMap.keySet();
Set<String> keys = new HashSet<>(keySet.size());
keySet.forEach(item -> keys.add(item.toString()));
// 清空CacheNamespace所有缓存
getRedisService().delete(keys);
// 清空CacheNamespace
getRedisService().delete(hsKey);
}
} catch (Exception e) {
log.error("clear cache failed", e);
}
}

/**
* 获取缓存大小,暂时没用上
* @return 长度
*/
@Override
public int getSize() {
return 0;
}

/**
* 获取读写锁:为了解决缓存击穿
* @return
*/
@Override
public ReadWriteLock getReadWriteLock() {
return readWriteLock;
}

/**
* 计算出key的摘要
* @param cacheKey CacheKey
* @return 字符串key
*/
private String getKey(Object cacheKey) {
String cacheKeyStr = cacheKey.toString();
log.info("count hash key, cache key origin string:{}", cacheKeyStr);
String strKey = byte2hex(getSHADigest(cacheKeyStr));
log.info("hash key:{}", strKey);
String key = CACHE_NAME + strKey;
// 在redis额外维护CacheNamespace创建的key,clear的时候只清理当前CacheNamespace的数据
getRedisService().hashSet(CACHE_NAME + id, key, "1");
return key;
}

/**
* 获取信息摘要
* @param data 待计算字符串
* @return 字节数组
*/
private byte[] getSHADigest(String data) {
try {
if (messageDigest == null) {
synchronized (MessageDigest.class) {
if (messageDigest == null) {
messageDigest = MessageDigest.getInstance(ALGORITHM);
}
}
}
return messageDigest.digest(data.getBytes(CHARSET));
} catch (Exception e) {
log.error("SHA-256 digest error: ", e);
throw new SecurityException("SHA-256 digest error"+"id=" + id + ".");
}
}

/**
* 字节数组转16进制字符串
* @param bytes 待转换数组
* @return 16进制字符串
*/
private String byte2hex(byte[] bytes) {
StringBuilder sign = new StringBuilder();
for (byte aByte : bytes) {
String hex = Integer.toHexString(aByte & 0xFF);
if (hex.length() == 1) {
sign.append("0");
}
sign.append(hex.toUpperCase());
}
return sign.toString();
}

/**
* 获取Redis服务接口
* 使用双重检查保证线程安全
* @return 服务实例
*/
private RedisService getRedisService() {
if (redisService == null) {
synchronized (RedisService.class) {
if (redisService == null) {
redisService = ApplicationContextUtil.getBean(RedisServiceImpl.class);
}
}
}
return redisService;
}
}

cache接口实现后,我们需要告诉mybatis,我们使用redis二级缓存。
首先有一个全局缓存开关配置

1
2
3
4
5
6
7
8
9
10
11
mybatis-plus:
type-enums-package: com.mayahx.stcm.api.entity.diagnose.enums
global-config:
db-config:
logic-delete-value: 'NULL'
logic-not-delete-value: 1
mapper-locations: classpath*:mapper/*Mapper.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
default-enum-type-handler: org.apache.ibatis.type.EnumOrdinalTypeHandler
cache-enabled: true

第二步需要在mapper xml中声明缓存引用

1
<cache-ref namespace="com.mayahx.stcm.diagnose.mapper.CsMapper"/>

最后一步,再mapper接口上添加@CacheNamespace注解

1
2
3
@Mapper
@CacheNamespace(implementation = RedisCache.class,eviction = RedisCache.class)
public interface CsMapper extends BaseMapper<Cs> ...

测试缓存生效

请求一个GET接口,看看是否是第一次走DB,第二次走redis缓存

看看运行日志

我们再次请求接口

作者

Labradors

发布于

2022-03-25

更新于

2022-03-29

许可协议

评论