低版本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集群不能提供服务时,它将进行如下操作:

  1. 连接Redis进行get操作,进行读取缓存;
  2. 连接Redis失败,执行方法体,获得执行结果;
  3. 连接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

  1. 排除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>
  1. 引入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"