記一次 Redisson 線上問題 → ERR unknown command 'WAIT' 的排查與分析

2023-09-11 12:03:26

開心一刻

  昨晚和一個朋友聊天

  我:處物件嗎,咱倆試試?

  朋友:我有物件

  我:我不信,有物件不公開?

  朋友:不好公開,我當的小三

  專案很簡單,通過 redisson-spring-boot-starter 引入 redisson 

  扯點題外的東西,關於 redisson-spring-boot-starter 的設定方式

  設定方式有很多種,官網檔案做了說明,有 4 種設定方式:README.md

  方式 1:

  方式 2:

  方式 3:

  方式 4:

  如果 4 種方式都設定,最終生效的是哪一種?

  樓主我此刻只想給你個大嘴巴子,怎麼這麼多問題?

   RedissonAutoConfiguration 中有如下程式碼

@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(RedissonClient.class)
public RedissonClient redisson() throws IOException {
    Config config = null;
    Method clusterMethod = ReflectionUtils.findMethod(RedisProperties.class, "getCluster");
    Method timeoutMethod = ReflectionUtils.findMethod(RedisProperties.class, "getTimeout");
    Object timeoutValue = ReflectionUtils.invokeMethod(timeoutMethod, redisProperties);
    int timeout;
    if(null == timeoutValue){
        timeout = 10000;
    }else if (!(timeoutValue instanceof Integer)) {
        Method millisMethod = ReflectionUtils.findMethod(timeoutValue.getClass(), "toMillis");
        timeout = ((Long) ReflectionUtils.invokeMethod(millisMethod, timeoutValue)).intValue();
    } else {
        timeout = (Integer)timeoutValue;
    }

    if (redissonProperties.getConfig() != null) {
        try {
            config = Config.fromYAML(redissonProperties.getConfig());
        } catch (IOException e) {
            try {
                config = Config.fromJSON(redissonProperties.getConfig());
            } catch (IOException e1) {
                throw new IllegalArgumentException("Can't parse config", e1);
            }
        }
    } else if (redissonProperties.getFile() != null) {
        try {
            InputStream is = getConfigStream();
            config = Config.fromYAML(is);
        } catch (IOException e) {
            // trying next format
            try {
                InputStream is = getConfigStream();
                config = Config.fromJSON(is);
            } catch (IOException e1) {
                throw new IllegalArgumentException("Can't parse config", e1);
            }
        }
    } else if (redisProperties.getSentinel() != null) {
        Method nodesMethod = ReflectionUtils.findMethod(Sentinel.class, "getNodes");
        Object nodesValue = ReflectionUtils.invokeMethod(nodesMethod, redisProperties.getSentinel());

        String[] nodes;
        if (nodesValue instanceof String) {
            nodes = convert(Arrays.asList(((String)nodesValue).split(",")));
        } else {
            nodes = convert((List<String>)nodesValue);
        }

        config = new Config();
        config.useSentinelServers()
            .setMasterName(redisProperties.getSentinel().getMaster())
            .addSentinelAddress(nodes)
            .setDatabase(redisProperties.getDatabase())
            .setConnectTimeout(timeout)
            .setPassword(redisProperties.getPassword());
    } else if (clusterMethod != null && ReflectionUtils.invokeMethod(clusterMethod, redisProperties) != null) {
        Object clusterObject = ReflectionUtils.invokeMethod(clusterMethod, redisProperties);
        Method nodesMethod = ReflectionUtils.findMethod(clusterObject.getClass(), "getNodes");
        List<String> nodesObject = (List) ReflectionUtils.invokeMethod(nodesMethod, clusterObject);

        String[] nodes = convert(nodesObject);

        config = new Config();
        config.useClusterServers()
            .addNodeAddress(nodes)
            .setConnectTimeout(timeout)
            .setPassword(redisProperties.getPassword());
    } else {
        config = new Config();
        String prefix = REDIS_PROTOCOL_PREFIX;
        Method method = ReflectionUtils.findMethod(RedisProperties.class, "isSsl");
        if (method != null && (Boolean)ReflectionUtils.invokeMethod(method, redisProperties)) {
            prefix = REDISS_PROTOCOL_PREFIX;
        }

        config.useSingleServer()
            .setAddress(prefix + redisProperties.getHost() + ":" + redisProperties.getPort())
            .setConnectTimeout(timeout)
            .setDatabase(redisProperties.getDatabase())
            .setPassword(redisProperties.getPassword());
    }
    if (redissonAutoConfigurationCustomizers != null) {
        for (RedissonAutoConfigurationCustomizer customizer : redissonAutoConfigurationCustomizers) {
            customizer.customize(config);
        }
    }
    return Redisson.create(config);
}
View Code

  誰先生效,一目瞭然!

  問題分析

  有點扯遠了,我們再回到主題

   jar 未升級之前, redisson-spring-boot-starter 的版本是 3.13.6 ,此版本在開發、測試、生產環境都是能正常跑的

  把 redisson-spring-boot-starter 升級到 3.15.0 之後,在開發、測試環境執行正常,上生產後則報錯: org.redisson.client.RedisException: ERR unknown command 'WAIT' 

  因為沒做任何的業務程式碼修改,所以問題肯定出在升級後的 redisson-spring-boot-starter ,你說是不是?

  點進去你就會發現

  這不就是我們的生產異常?

  我立馬找運維確認,生產確實用的是阿里雲 redis ,並且是代理模式!

  出於嚴謹,我們還需要對: 3.14.0 是正常的, 3.14.1 有異常 這個結論進行驗證

  因為公司未提供測試環境的阿里雲 redis ,所以樓主只能自掏腰包購買一套最低配的阿里雲 redis 

  就衝樓主這認真負責的態度,你們不得一鍵三連?

  我們來看下驗證結果

  結論確實是對的

  樓主又去阿里雲翻了一下手冊

  我們是不是可以把問題範圍縮小了

   redisson  3.14.0 未引入 wait 命令,而 3.14.1 引入了,所以問題產生了!

  但這只是我們的猜想,我們需要強有力的支撐,找誰了?肯定還得是原始碼!

  WAIT 原始碼分析

  我們先跟 3.14.0 

  我們可以看到,真正傳送給 redis-server 執行的命令不只是加鎖的指令碼,還有 WAIT 命令!

  只是因為非同步執行命令,只關注了加鎖指令碼的執行結果,而並沒有關注 WAIT 命令的執行結果

  也就是說 3.14.0 也有 WAIT 命令,並且在阿里雲 redis 的代理模式下執行是失敗的,只是 redisson 並沒有去管 WAIT 命令的執行結果

  所以只要加鎖命令執行是成功的,那麼 Redisson 就認為執行結果是成功的

  這也就是 3.14.0 執行成功,沒有報異常的原因

  我們再來看看 3.14.1 

  真正傳送給 redis-server 執行的命令有加鎖指令碼,也有 WAIT 命令

  兩個命令的執行結果都有關注

  加鎖指令碼執行是成功的, redis 已經有對應的記錄

  而阿里雲 redis 的代理模式是不支援 WAIT 命令,所以 WAIT 命令是執行失敗的

  而最終的執行結果是所有命令的執行結果,所以最終執行結果是失敗的!

  問題處理

  那麼如何正確的升級到生產環境了?

  1、將 redisson 版本降到 3.14.0 

    不去關注 WAIT 命令的執行結果,相當於沒有 WAIT 命令

    這個可能產生什麼問題( redisson 引入 WAIT 命令的意圖),轉動你們智慧的頭腦,評論區告訴我答案

  2、阿里雲 redis 改成直連模式

總結

  1、環境一致的重要性

    測試環境一定要保證和生產環境一致

    否則就會出現和樓主一樣的問題,其他環境都沒問題,就生產有問題

    環境不一致,排查問題也很棘手

  2、 Redisson 很早就會附加 WAIT 命令,只是從 3.14.1 開始才關注 WAIT 命令的執行結果

  3、對於維護中的老專案,程式碼能不動就不動,設定能不動就不動