低版本SpringBoot Redis缓存旁路设计改造方案实践
目录
文章目录
- 低版本SpringBoot Redis缓存旁路设计改造方案实践
- 1. 引言
- 1.1 编写目的
- 1.2 读者对象
- 2. 业务背景
- 3. 适用场景
- 4. 解决方案
- 4.1 引入Redisson替换Lettuce
- 4.2 修改配置文件
- 4.3 更优雅的配置方式
1. 引言
1.1 编写目的
在越来越多的系统建设中,旁路设计受到重视,但是在低版本SpringBoot,以及其默认引入的Lettuce Redis客户端中,并没有很好的处理旁路问题。本文则讲述通过引入Redisson Redis客户端替换Lettuce的方式,进行更好的旁路解决。
1.2 读者对象
本文档适合以下人员阅读:
项目组设计与开发人员;
2. 业务背景
SpringBoot 2.0.x-2.2.x版本中,默认引入的配置,不支持配置连接Redis 客户端Socket超时时间。以SpringBoot 2.0.8.RELEASE为例,其引入的letture-core 5.0.5.RELEASE中,默认的连接超时为10秒,且无开放修改接口,部分代码如下:
// io.lettuce.core.SocketOptions
public class SocketOptions {
public static final long DEFAULT_CONNECT_TIMEOUT = 10;
public static final TimeUnit DEFAULT_CONNECT_TIMEOUT_UNIT = TimeUnit.SECONDS;
public static final Duration DEFAULT_CONNECT_TIMEOUT_DURATION = Duration.ofSeconds(DEFAULT_CONNECT_TIMEOUT);
}
// io.lettuce.core.ClientOptions
public class ClientOptions implements Serializable {
public static final SocketOptions DEFAULT_SOCKET_OPTIONS = SocketOptions.create();
public static final SslOptions DEFAULT_SSL_OPTIONS = SslOptions.create();
private final SocketOptions socketOptions;
private final SslOptions sslOptions;
}
对于一个添加@Cacheable
注解意图进行实现Redis旁路的方法来说,当Redis集群不能提供服务时,它将进行如下操作:
- 连接Redis进行get操作,进行读取缓存;
- 连接Redis失败,执行方法体,获得执行结果;
- 连接Redis,将执行结果写入缓存;
那么在步骤1、3中,由于连接Redis会失败,但是由于无法配置的连接超时为10秒,这个为了使用缓存进行加速访问的接口,在不能正常加速的情况下,还会有进行连接Socket至少额外20秒的开销。那么在一个大接口调用两个需要缓存加速的小接口时,额外的耗时时间就提升至40秒,直接超过了网关的超时时间30秒,接口将被熔断,旁路设计实际上并不生效。
所以项目中需要有一个对Redis客户端连接Socket超时时间可配置的选型。
3. 适用场景
当Redis缓存作用为加速查询耗时,减少接口耗时的场景,则适用本方案进行实践。若Redis缓存作为内存数据库进行持久化场景使用时,则不适用此方案,例如Session会话管理。
4. 解决方案
在整体脚手架基于SpringBoot 2.0.8.RELEASE版本,且无法升级版本的大前提下,通过更改Redis客户端进行解决成了可靠的途径。
4.1 引入Redisson替换Lettuce
- 排除Lettuce
在spring-boot-starter-data-redis中,将其默认依赖的Redis客户端Lettuce进行排除。
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
- 引入Redisson
在引入Redisson时,需要注意版本问题,参考官方redisson-spring-data、以及redisson-spring-boot-starter工程中的介绍,对于SpringBoot 2.0.x版本,引入的Redisson版本须为3.9.1。
<!-- pom.xml -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.9.1</version>
</dependency>
4.2 修改配置文件
Redisson配置文件既读取SpringBoot默认的RedisProperties
,也读取其自定义的RedissonProperties
。其配置如下:
- SpringBoot 公共配置
spring:
redis:
database:
host:
port:
password:
ssl:
timeout:
cluster:
nodes:
sentinel:
master:
nodes:
- Redisson 配置
spring:
redis:
redisson:
file: classpath:redisson.yaml
config: |
clusterServersConfig:
idleConnectionTimeout: 10000
# 连接超时时间配置
connectTimeout: 10000
timeout: 3000
retryAttempts: 3
retryInterval: 1500
failedSlaveReconnectionInterval: 3000
failedSlaveCheckInterval: 60000
password: null
subscriptionsPerConnection: 5
clientName: null
loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
subscriptionConnectionMinimumIdleSize: 1
subscriptionConnectionPoolSize: 50
slaveConnectionMinimumIdleSize: 24
slaveConnectionPoolSize: 64
masterConnectionMinimumIdleSize: 24
masterConnectionPoolSize: 64
readMode: "SLAVE"
subscriptionMode: "SLAVE"
nodeAddresses:
- "redis://127.0.0.1:7004"
- "redis://127.0.0.1:7001"
- "redis://127.0.0.1:7000"
scanInterval: 1000
pingConnectionInterval: 0
keepAlive: false
tcpNoDelay: false
threads: 16
nettyThreads: 32
codec: !<org.redisson.codec.MarshallingCodec> {}
transportMode: "NIO"
通过将Redisson配置中connectTimeout
配置项改小(例如2秒),同时将retryAttempts
配置的次数改为0,则可以将Redis Socket 连接的问题做到快速失败,在Redis集群宕机时,单个@Cacheable
注解的接口增加的耗时为4秒,并不会导致超过网关的超时时间,最终导致接口被熔断。
4.3 更优雅的配置方式
当前Redisson的配置中,spring.redis.redisson.config
是作为String类型变量引入的,其后通过尝试JSON转型、YAML转型成配置类的方式,代码如下:
// org.redisson.spring.starter.RedissonAutoConfiguration#redisson
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(RedissonClient.class)
public RedissonClient redisson() throws IOException {
Config config = null;
if (redissonProperties.getConfig() != null) {
try {
InputStream is = getConfigStream();
config = Config.fromJSON(is);
} catch (IOException e) {
try {
InputStream is = getConfigStream();
config = Config.fromYAML(is);
} catch (IOException e1) {
throw new IllegalArgumentException("Can't parse config", e1);
}
}
}
return Redisson.create(config);
}
这样进行引入配置的方式存在一个十分不优雅的方式,Redis集群信息,密码等单个配置项无法使用${xxx}方式在SpEL中解析,则无法放入配置中心,当然将spring.redis.redisson.config
整个key放入配置中心可以解决问题,但是也同样很不优雅。
为了能够将spring.redis.redisson.config
配置项中的单个配置项放入配置中心,可以通过增加工具类解析该配置项,并通过Spring提供的Environment进行参数的获取。
- 解析配置工具类
@Component
public class SpringExpressionUtil {
@Resource
private Environment environment;
/**
* 匹配${xxx}表达式
*/
private static final Pattern SPRING_ELEMENT_LANGUAGE_PATTERN = Pattern.compile("\\$\\{(.*?)}");
public String replaceSpELPatternValue(final String config) {
String[] result = new String[1];
result[0] = config;
Map<String, String> kv = new HashMap<>();
Matcher matcher = SPRING_ELEMENT_LANGUAGE_PATTERN.matcher(config);
String key;
String realKey;
while (matcher.find()) {
key = matcher.group();
// 去除${}, 提取key
realKey = key.substring(2, key.length() - 1);
kv.put(key, environment.getProperty(realKey, ""));
}
kv.forEach((pattern, value) -> {
result[0] = result[0].replace(pattern, value);
});
return result[0];
}
}
- 注入
RedissonClient.class
Bean
// 在@Configuration配置类中进行注入
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson(@Value("classpath:/redisson.yaml") Resource configFile) throws IOException {
return createRedissonClient(configFile);
}
private RedissonClient createRedissonClient(Resource configFile) throws IOException {
String config = null;
InputStream in = null;
try {
in = configFile.getInputStream();
config = IoUtil.read(in, Charset.defaultCharset());
} finally {
IoUtil.close(in);
}
return Redisson.create(Config.fromYAML(config));
}
- 配置文件redisson.yaml
在配置文件中,根据需要,将需要参数化的配置以${xxx}形式配置,并在spring配置文件、配置中心中配置对应key的属性即可。其具体可配置参数在类org.redisson.config.Config
中可以进行查看。
singleServerConfig: idleConnectionTimeout: ${spring.redis.redisson.idleConnectionTimeout} pingTimeout: ${spring.redis.redisson.pingTimeout} connectTimeout: ${spring.redis.redisson.connectTimeout} timeout: ${spring.redis.redisson.timeout} retryAttempts: ${spring.redis.redisson.retryAttempts} retryInterval: ${spring.redis.redisson.retryInterval} reconnectionTimeout: ${spring.redis.redisson.reconnectionTimeout} failedAttempts: ${spring.redis.redisson.failedAttempts} password: ${spring.redis.redisson.password} subscriptionsPerConnection: ${spring.redis.redisson.subscriptionsPerConnection} clientName: ${spring.redis.redisson.clientName} address: ${spring.redis.redisson.address} subscriptionConnectionMinimumIdleSize: ${spring.redis.redisson.subscriptionConnectionMinimumIdleSize} subscriptionConnectionPoolSize: ${spring.redis.redisson.subscriptionConnectionPoolSize} connectionMinimumIdleSize: ${spring.redis.redisson.connectionMinimumIdleSize} connectionPoolSize: ${spring.redis.redisson.connectionPoolSize} database: ${spring.redis.redisson.database} dnsMonitoringInterval: ${spring.redis.redisson.dnsMonitoringInterval}threads: ${spring.redis.redisson.threads}nettyThreads: ${spring.redis.redisson.nettyThreads}codec: !<org.redisson.codec.JsonJacksonCodec> {}transportMode: "NIO"