Redis断连从框架层面该如何抢救?

前言

上周发生了一件鸡飞狗跳的线上事故,六节点的Redis-Cluster集群所在的大部分机器因为网络带宽问题断连了,排查之后发现是那几台物理机带宽被占满了,导致整个集群因为槽位不满16384而请求失败。并且因为没有考虑缓存失效问题,而让有使用缓存的接口全部报错,影响了用户体验。在本地测试时,还发现因为框架中的Redisson组件初始化时会强制连接和校验槽位,而导致整个服务因Redis-Cluster断连而无法启动。

造成了一段时间的中断,当时是别的同事在负责,因为涉及到我的六脉神剑,所以我决定还是我来收个尾。经过上周五和本周一的方案思考和设计,琢磨出了一套不成熟的方案,暂行,期待读者有更好的想法能分享一下。同时我也会逐步分析我的思考过程,和最终方案设计时的考量。

现状导向问题

一开始上手比较匆忙,毕竟这个问题我一开始是抱着做甩手掌柜的心态来做旁观者。(⊙﹏⊙),有个三四天吧,可能最后还是没有个好的解法,波及到我负责的项目了,想了想,还是从框架层控制一下,做一下整体规划。

简单描述下现状,一是Redis集群不可用了,用到Redis的接口就挂了,只有少部分接口做了手动降级处理,即捕获异常走数据库。二是本地想连这个集群,发现服务根本无法启动。根据现状整理问题,得出需要解决的是集群宕机后,如何保证服务正常运行以及正常启动。

思维风暴

代码规范

框架中作为Redis的入口和客户端,提供了三种方式,分别是SpringCache、SpringDataRedis和Redisson。同事在使用的时候也是随便用,因此我针对这三种都进行了一定的配置,详情见六脉神剑-我在公司造了六个轮子(21745阅读245赞536收藏)。使用情况比较复杂的时候,就要考虑周全,避免牵一发而动全身。

从问题一来看就是一个很明显的缓存失效场景,最直接的解决方案就是捕获异常后转数据库连接,但这是开发注意事项,是最直白的规范,详见三个月前我的一篇文章,同事血压操作集锦第一弹,截图如下。

但是今天要讨论的是要从框架层面解决这个问题,而且是要尽可能小的改动。因为老项目很多不遵守开发规范的代码,所以如果要统一按照上面的案例进行修改,那将是一个非常耗时的工程。同时还要解决第二个问题,Redis集群宕机后项目无法启动,因为框架强依赖于Redis,Spring会因为无法正常加载Redis相关的Bean而导致启动失败。

统一入口,减少变量

在进行了小半天的头脑风暴和资料查询后,发现大家没有这样的困扰,并且所有的方案都指向了Redis的高可用和代码规范。诚然,这是最优解,但是基于团队目前的现状,我也想做出我的思考,给出一套应急的方案。

说干就干,我的第一想法是统一入口,因为公司框架的SpringBoot版本是2.1.X过低,所以我个人极力不推荐使用SpringDataRedis,也就是redisTemplate。因此我干掉了这个依赖,只留下了原生的redisson作为唯一的Redis客户端。因为对SpringCache做了Redis的扩展,所以也更换成了Redisson提供支持(最开始用的lettuce,因为有问题就换成了Jedis,更换的原因见六脉神剑那篇文章)。

scss 复制代码
/**
 * 定义Jedis客户端,集群和单点同时存在时优先集群配置
 */
@Bean
public JedisConnectionFactory redisConnectionFactory() {
    String redisHost = config.getProperty("spring.redis.host");
    String redisPort = config.getProperty("spring.redis.port");
    String cluster = config.getProperty("spring.redis.cluster.nodes");
    String redisPassword = config.getProperty("spring.redis.password");
    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
    // 默认阻塞等待时间为无限长,源码DEFAULT_MAX_WAIT_MILLIS = -1L
    // 最大连接数, 根据业务需要设置,不能超过实例规格规定的最大连接数。
    jedisPoolConfig.setMaxTotal(100);
    // 最大空闲连接数, 根据业务需要设置,不能超过实例规格规定的最大连接数。
    jedisPoolConfig.setMaxIdle(60);
    // 关闭 testOn[Borrow|Return],防止产生额外的PING。
    jedisPoolConfig.setTestOnBorrow(false);
    jedisPoolConfig.setTestOnReturn(false);
    JedisClientConfiguration jedisClientConfiguration = JedisClientConfiguration.builder().usePooling()
            .poolConfig(jedisPoolConfig).build();
    if (StringUtils.hasText(cluster)) {
        // 集群模式
        String[] split = cluster.split(",");
        RedisClusterConfiguration clusterServers = new RedisClusterConfiguration(Arrays.asList(split));
        if (StringUtils.hasText(redisPassword)) {
            clusterServers.setPassword(redisPassword);
        }
        return new JedisConnectionFactory(clusterServers, jedisClientConfiguration);
    } else if (StringUtils.hasText(redisHost) && StringUtils.hasText(redisPort)) {
        // 单机模式
        RedisStandaloneConfiguration singleServer = new RedisStandaloneConfiguration(redisHost, Integer.parseInt(redisPort));
        if (StringUtils.hasText(redisPassword)) {
            singleServer.setPassword(redisPassword);
        }
        return new JedisConnectionFactory(singleServer, jedisClientConfiguration);
    } else {
        throw new ToolException("spring.redis.host及port或spring.redis.cluster" +
                ".nodes必填,否则不可使用RedisTool以及Redisson");
    }
}

/**
 * 配置Spring-Cache内部使用Redis,配置序列化和过期时间
 */
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    RedisSerializer<String> redisSerializer = new StringRedisSerializer();
    Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer
            = new Jackson2JsonRedisSerializer<>(Object.class);
    ObjectMapper om = new ObjectMapper();
    // 防止在序列化的过程中丢失对象的属性
    om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
    // 开启实体类和json的类型转换,该处兼容老版本依赖,不得修改
    om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
    jackson2JsonRedisSerializer.setObjectMapper(om);
    // 配置序列化(解决乱码的问题)
    RedisCacheConfiguration config = RedisCacheConfiguration.
            defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
            .disableCachingNullValues()// 不缓存空值
            .entryTtl(Duration.ofMinutes(30));//30分钟不过期
    return RedisCacheManager
            .builder(connectionFactory)
            .cacheDefaults(config)
            .build();
}

调整后代码,就是按照官网改了改,感兴趣可以看看Redisson-WIKI

ini 复制代码
/**
 * 对 Redisson 的使用都是通过 RedissonClient 对象
 */
@ConditionalOnProperty(name = "ruijie.tool.redis-enable", havingValue = "true")
@Bean(destroyMethod = "shutdown") // 服务停止后调用 shutdown 方法。
public RedissonClient redisson() {
    String redisHost = config.getProperty("spring.redis.host");
    String redisPort = config.getProperty("spring.redis.port");
    String cluster = config.getProperty("spring.redis.cluster.nodes");
    String redisPassword = config.getProperty("spring.redis.password");
    Config config = new Config();
    //使用String序列化时会出现RBucket<Integer>转换异常
    //config.setCodec(new StringCodec());
    if (ObjectUtils.isEmpty(redisHost) && ObjectUtils.isEmpty(cluster)) {
        throw new ToolException("spring.redis.host及port或spring.redis.cluster" +
                ".nodes必填,否则不可使用Redis");
    } else {
        if (StringUtils.hasText(cluster)) {
            // 集群模式
            String[] split = cluster.split(",");
            List<String> servers = new ArrayList<>();
            for (String s : split) {
                servers.add("redis://" + s);
            }
            ClusterServersConfig clusterServers = config.useClusterServers();
            clusterServers.addNodeAddress(servers.toArray(new String[split.length]));
            if (StringUtils.hasText(redisPassword)) {
                clusterServers.setPassword(redisPassword);
            }
            //修改命令超时时间为40s,默认3s
            clusterServers.setTimeout(40000);
            //修改连接超时时间为50s,默认10s
            clusterServers.setConnectTimeout(50000);
            clusterServers.setCheckSlotsCoverage(false);
        } else {
            // 单机模式
            SingleServerConfig singleServer = config.useSingleServer();
            singleServer.setAddress("redis://" + redisHost + ":" + redisPort);
            if (StringUtils.hasText(redisPassword)) {
                singleServer.setPassword(redisPassword);
            }
            singleServer.setTimeout(40000);
            singleServer.setConnectTimeout(50000);
        }
    }
    RedissonClient redissonClient = null;
    try {
        redissonClient = Redisson.create(config);
    } catch (Exception e) {
        Log.error("初始化Redis失败", e);
    }
    return redissonClient;
}

@ConditionalOnProperty(name = "ruijie.tool.redis-enable", havingValue = "true")
@Bean
public CacheManager cacheManager(RedissonClient redissonClient) {
    Map<String, CacheConfig> config = new HashMap<>();
    //开辟命名空间,过期设置为1小时,连接最大存活时间为30分钟
    config.put("springCache", new CacheConfig(60 * 60 * 1000, 30 * 60 * 1000));
    return new RedissonSpringCacheManager(redissonClient, config);
}

框架层改造完了之后,现在只有一个入口即Redisson,只要Redisson稳住,就不会出现问题。那么该如何稳住呢,为了解决第二个问题,也就是Redis集群挂了还能正常启动,我的第一个想法是通过Spring的Bean生命周期入手,尝试开始。按照Redisson官方推荐的加载方式使用Redisson.create(config)创建一个RedissonClient接口的实现类,那么我们在代码中引入这个接口的话,Spring就会自动帮我们填充这个自定义的实现类。

组件可插拔

问题来了,如果这个Redis挂了的话,能不能启动呢?当然是肯定不能启动的,因为我现在就遇到这个问题了,但是为了模拟这个情况,我随便输入几个地址。

这里很明显的提示了如果要创建cacheManager(SpringCache核心类),必须要提供一个RedissonClient的实现类。顺带一提,Redisson开启懒加载在这是无意义的,因为CacheManager cacheManager(RedissonClient redissonClient)这里必须要注入一个才能初始化,并不是想象中项目启动,业务代码中使用缓存调接口才初始化。

最开始我在做组件的时候,如果我想排掉三方包中的依赖,我的首选是实现BeanDefinitionRegistryPostProcessor接口,在Spring的Bean生命周期初始化后根据名字主动移除该Bean。这个不能解决我的问题,因为我这是在Bean初始化的时候就失败了。

常见的框架排Bean的方法,还有一招暴力的,直接在依赖配置文件中干掉这个框架依赖或者在SpringBoot启动类上排掉这个依赖的自动配置类。我可以选择将Redis整个依赖单独拆分出来成为一个新的组件,这样也能算是一个可插拔的设计,并且能让臃肿的框架包轻量一些。但是我不喜欢,所以我还是采用了配置类的方式,刚好Spring有足够的定制化配置,我选择了@ConditionalOnProperty(name = "ruijie.tool.redis-enable", havingValue = "true")这个注解。意思是配置文件中,只有配置ruijie.tool.redis-enable=true时才初始化被注解的Bean。

用法很简单,在框架中所有用到Redis的Bean上统统加上这个注解即可。但是同样存在一个限制,那就是项目代码中不能允许引入Redis相关类,引入的话同样会导致启动失败,因为框架中所有Redis相关的Bean都没有初始化。

尝试

因为还是没能从根源上解决无法启动的问题,于是我有了极端的想法,能不能注册一个假的Redis?首先我尝试在@Bean注解的Redisson注册方法中,提供一个null,妄图通过一个空的Bean来保证至少启动时不报错。经测试无效,当传入null时,Spring根本不加载该Bean,和上面初始化失败时报一样的错误,即找不到一个实现类。

因为是用Redisson初始化,所以我想着能不能通过构造方法做一个不进行初始化连接的Bean注入到Spring,跟一下Redisson的初始化源码

发现走的还是构造方法,点到构造方法后发现是protected修饰,还没有开放的重载

org.redisson.config.ConfigSupport#createConnectionManager这个方法就是创建连接的方法,连不上就报错......这条路算是堵死了

在多次尝试后,放弃了,毕竟从本质上来讲,Redis是个NoSql,就是个数据库。极端一点,类比MySQL,我本来就拿你当数据库使用,你项目数据库都挂了,那启动起来还有什么业务意义呢?与其思考这个,不如保证高可用,当然本期讨论的不是这个。

另一个问题

接下来要解决另一个问题,如何保证项目运行中Redis断连还能正常使用,同时保证老代码的改动最小。

之前也说过,解决缓存挂掉的最好方法就是捕获异常后转数据库连接,但是从框架层来讲,我哪里知道你连的是什么数据库,更不知道你要的是什么数据了。因此得转换思路,记得之前看过一个技术理论,如果遇到不好解决的问题,那么最便捷的方法就是加一个中间层。所以我选择了二级缓存,第二级缓存是本地缓存,用的caffeine,本质上是个ConcurrentHashMap,这肯定挂不了了。新建一个统一的缓存类,以后项目中引入该缓存类即可。

typescript 复制代码
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.time.Duration;

/**
 * spring redis 工具类
 *
 * @author ruoyi
 **/
@Component
@Slf4j
@ConditionalOnProperty(name = "ruijie.tool.redis-enable",havingValue = "true")
public class CachePlusTool {
    @Autowired
    public RedissonClient redissonClient;
    /**
     * 本地缓存设置时不需要太长TTL以及太大容量,会占用过多内存造成OOM,得不偿失
     * 设置初始容量为1000,最大容量为10000,普通业务足够,对于过多元素的可采用json压缩为一个元素
     * 设置默认超时时间为4小时的考虑是经验值,过长的缓存没有意义,用户几乎在白天操作,且夜晚很多项目需要跑定时,会占用大量内存
     */
    private final Cache<String, Object> caffeine = Caffeine.newBuilder()
            .initialCapacity(1000).expireAfterAccess(Duration.ofHours(4))
            .maximumSize(10000).build();

    /**
     * 缓存基本的对象,Integer、String、实体类等,默认超时时间4小时
     *
     * @param key   缓存的键值
     * @param value 缓存的值
     */
    public <T> void put(String key, T value) {
        try {
            redissonClient.getBucket(key).set(value, Duration.ofHours(4));
        } catch (Exception e) {
            log.error("Redis的Put连接失败,降级处理使用本地缓存Caffeine", e);
            caffeine.put(key, value);
        }
    }

    /**
     * 重载版本,仅支持对redis的ttl设置,对caffeine无效
     *
     * @param key     缓存的键值
     * @param value   缓存的值
     * @param timeout 过期时间
     */
    public <T> void put(String key, T value, Duration timeout) {
        try {
            redissonClient.getBucket(key).set(value, timeout);
        } catch (Exception e) {
            log.error("Redis的Put连接失败,降级处理使用本地缓存Caffeine", e);
            caffeine.put(key, value);
        }
    }

    /**
     * 获得缓存的基本对象。
     * 可能返回null值--因为对于缓存来说类似于数据库,分不清是程序错误导致的null还是数据为空的null,所以需要开发人员自行判断
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T get(String key) {
        Object value = null;
        try {
            value = redissonClient.getBucket(key).get();
        } catch (Exception e) {
            log.error("Redis的Get连接失败,降级处理使用本地缓存Caffeine", e);
            //没有返回null
            value = caffeine.getIfPresent(key);
        }
        return (T) value;
    }
}

上图为测试结果,在Redis尝试无望后转而使用本地缓存,并会打印日志进行提示。并且该方法对于老项目比较友好,基本上只需替换引入的Redis类为新的CachePlusTool即可,使用方式如下。

后续可以继续扩容该工具了,如果有特殊的需求,那么说明强依赖于Redis,那就得承担强依赖带来的问题,不在本方案考虑范围内。

Redis单机快速部署

测试主要是测了一个Redis在项目运行中,突然断掉的情况,这里按照官方流程快速安装一个单机Redis,命令不用改

官网下载安装包

wget download.redis.io/redis-stabl...

解压缩

tar -xzvf redis-stable.tar.gz

到解压后根目录

cd redis-stable

编译

make

安装

make install

修改redis.conf

bind XXXX(IP地址)

protected-mode no(关掉保护模式)

requirepass 123(编一个123的简单密码)

这个部署倒是出人意料的简单,照着官网我就随便弄弄,没想到就好了。

方案总结

代码规范

解决的问题是项目运行中Redis挂了,项目依旧能提供本地缓存或者转数据库连接的服务,接口不会挂掉

对于常见的缓存存取,使用框架提供的CachePlusTool即可,代码会发到DevOps上,有补充的自行填充

scss 复制代码
@Autowired
public CachePlusTool cachePlusTool;

cachePlusTool.put(key, value);
cachePlusTool.put(key, value, Duration.ofHours(4));
cachePlusTool.get(key);

如果有特殊需求,比如分布式锁,或者项目是多节点部署且对数据正确性要求高的接口,一定要捕获异常,有必要的转数据库连接,代码片如下

ini 复制代码
@Autowired
private RedissonClient redissonClient;

public String getProductLine(String itemNo) {
    String cacheKey = "order:getProductLine:" + itemNo;
    String cacheValue = null;
    RBucket<String> bucket = redissonClient.getBucket(cacheKey);
    try {
        cacheValue = bucket.get();
    } catch (Exception e) {
        //捕获异常记得打印日志
        log.error("redis连接异常", e);
    }
    if (cacheValue != null) {
        return cacheValue;
    } else {
        //有必要的话转数据库查询
        String res = ptmErpMapper.getProductLine(itemNo);
        bucket.set(res, 16, TimeUnit.HOURS);
        return res;
    }
 }

解耦Redis

解决的问题是完全不依赖Redis,但是依赖了框架,想要解耦Redis,避免因为Redis挂了而导致服务无法启动。

导入依赖

com.ruijie

tool-spring-boot-starter

2.3.3

在配置文件中加上开关配置

ruijie.tool.redis-enable=false

如上配置就让框架中所有Redis相关的Bean不会初始化,也就是彻底解耦,不会受到Redis集群状态的影响。

微醺码头

本期微醺码头主要是针对以上方案的一个回顾与发散,讨论下方案本身的问题以及我的一些构想。

首先我想说的是二级缓存方案中的本地缓存,对于多节点部署的项目来说是存在问题的,打个比方,如果是A节点使用了本地缓存获得了数据a,但是数据库此时如果变成了b,那么B节点通过本地缓存就得到了b,这样多节点给前端的数据就会变成一会儿是a,一会儿是b。当然真实场景,为了保证缓存一致性,可以选择旁路缓存模式,及时更新,甚至使用延时双删策略保证较强的一致性。

关于Redis这层解耦,其实我还有个想法,既然都加了一个中间层,为啥不把这个中间层扩大呢?构建一个Redis独立微服务,统一管理所有的Redis,提供通用的熔断和降级处理。当然,这光是想想就觉得问题很多,更期待读者能给我带来更优质的想法和建议。

写在最后

本来没想写的,花了上周五和本周一的时间来解决这个问题,找了下网上没啥好的想法,索性就我来写个。因为要给团队内写个使用文档,考虑到部分同事希望我多讲讲原理,喜欢听,那我就多写写,反正也不碍事。有个同事还要让我搞技术分享会,那算了,给我整活,之前搞过几次,效果不好,还老是下班搞,这不行。写写博客吧,想学的人自然会学,不想学的按着头也不会学,顺其自然。我最近也是受到了我目标的激励,重新唤起了动力,加油,Fighting!!!未来模模糊糊,总是有点犹豫不决,那就先往前走,行动起来总没错,诸君共勉!

相关推荐
安的列斯凯奇6 小时前
SpringBoot篇 单元测试 理论篇
spring boot·后端·单元测试
AI航海家(Ethan)6 小时前
PostgreSQL数据库的运行机制和架构体系
数据库·postgresql·架构
Bunny02126 小时前
SpringMVC笔记
java·redis·笔记
架构文摘JGWZ6 小时前
FastJson很快,有什么用?
后端·学习
BinaryBardC6 小时前
Swift语言的网络编程
开发语言·后端·golang
邓熙榆6 小时前
Haskell语言的正则表达式
开发语言·后端·golang
贾贾20237 小时前
配电自动化系统“三区四层”数字化架构
运维·科技·架构·自动化·能源·制造·智能硬件
希忘auto9 小时前
详解Redis的Zset类型及相关命令
redis
专职9 小时前
spring boot中实现手动分页
java·spring boot·后端
Ciderw9 小时前
Go中的三种锁
开发语言·c++·后端·golang·互斥锁·