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"