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

来源:https://www.cnblogs.com/youzhibing/archive/2023/09/11/17440742.html
-Advertisement-
Play Games

開心一刻 昨晚和一個朋友聊天 我:處對象嗎,咱倆試試? 朋友:我有對象 我:我不信,有對象不公開? 朋友:不好公開,我當的小三 問題背景 程式在生產環境穩定的跑著 直到有一天,公司執行組件漏洞掃描,有漏洞的 jar 要進行升級修複 然後我就按著掃描報告將有漏洞的 jar 修複到指定的版本 自己在開發 ...


開心一刻

  昨晚和一個朋友聊天

  我:處對象嗎,咱倆試試?

  朋友:我有對象

  我:我不信,有對象不公開?

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

問題背景

  程式在生產環境穩定的跑著

  直到有一天,公司執行組件漏洞掃描,有漏洞的 jar 要進行升級修複

  然後我就按著掃描報告將有漏洞的 jar 修複到指定的版本

  自己在開發環境也做了主流業務的測試,沒有任何異常,穩如老狗

  提測之後,測試小姐姐也沒測出問題,一切都是這麼美好

  結果升級到生產後,生產日誌瘋狂報錯: org.redisson.client.RedisException: ERR unknown command 'WAIT' 

  完整的異常堆棧信息類似如下

org.redisson.client.RedisException: ERR unknown command 'WAIT'. channel: [id: 0x84149c6e, L:/192.168.2.40:3592 - R:/47.98.21.100:6379] command: (WAIT), params: [1, 1000]

    at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:346)
    at org.redisson.client.handler.CommandDecoder.decodeCommandBatch(CommandDecoder.java:247)
    at org.redisson.client.handler.CommandDecoder.decodeCommand(CommandDecoder.java:189)
    at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:117)
    at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:102)
    at io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:508)
    at io.netty.handler.codec.ReplayingDecoder.callDecode(ReplayingDecoder.java:366)
    at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:276)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
    at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
    at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
    at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919)
    at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166)
    at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:719)
    at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:655)
    at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:581)
    at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:493)
    at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
    at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
    at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
    at java.lang.Thread.run(Thread.java:748)
View Code

  突然來個這個鬼玩意,腦闊有點疼

  先讓運維同事回滾,然後就開始了我的問題排查之旅

問題排查與處理

  項目搭建

  示例代碼:redisson-spring-boot-demo,執行如下 test 方法即可進行測試

  項目很簡單,通過 redisson-spring-boot-starter 引入 redisson 

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

  配置方式有很多種,官網文檔做了說明,有 4 種配置方式:README.md

  方式 1:

  方式 2:

  方式 3:

  方式 4:

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

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

  既然你們都提出來了,那我就不能不管,誰讓我太愛你們了,盤它!

  從哪盤,怎麼盤?

  源碼之下無密碼,我們就從源碼去盤,找到自動配置類

  (關於 spring-boot 的自動配置,參考:springboot2.0.3源碼篇 - 自動配置的實現,發現也不是那麼複雜

   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 ,你說是不是?

  那這個問題肯定有前輩碰到過,我們去 redisson 的issues看看

  直接搜索關鍵字: WAIT 

  點進去你就會發現

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

  我立馬找運維確認,生產確實用的是阿裡雲 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、對於維護中的老項目,代碼能不動就不動,配置能不動就不動


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 數據來源:House Prices - Advanced Regression Techniques 參考文獻: Comprehensive data exploration with Python 1. 導入數據 import pandas as pd import warnings warnin ...
  • SpringBoot-Learning系列之Kafka整合 本系列是一個獨立的SpringBoot學習系列,本著 What Why How 的思想去整合Java開發領域各種組件。 消息系統 主要應用場景 流量消峰(秒殺 搶購)、應用解耦(核心業務與非核心業務之間的解耦) 非同步處理、順序處理 實時數據 ...
  • 前言 最近為一個公眾號h5商城接入了微信支付功能,查找資料過程中踩了很多坑,以此文章記錄一下和大家分享 前期準備 公眾號認證 微信支付功能需要開通企業號併進行資質認證,費用一年300,且需企業營業執照等信息,對公賬戶打款驗證 登錄微信公眾平臺https://mp.weixin.qq.com/,創建服 ...
  • 本文深入探討了Go語言中的代碼包和包引入機制,從基礎概念到高級應用一一剖析。文章詳細講解瞭如何創建、組織和管理代碼包,以及包引入的多種使用場景和最佳實踐。通過閱讀本文,開發者將獲得全面而深入的理解,進一步提升Go開發的效率和質量。 關註公眾號【TechLeadCloud】,分享互聯網架構、雲服務技術 ...
  • 在併發編程中我們為啥一般選用創建多個線程去處理任務而不是創建多個進程呢?這是因為線程之間切換的開銷小,適用於一些要求同時進行並且又要共用某些變數的併發操作。而進程則具有獨立的虛擬地址空間,每個進程都有自己獨立的代碼和數據空間,程式之間的切換會有較大的開銷。 ...
  • 日誌是應用程式的重要組成部分。無論是服務端程式還是客戶端程式都需要日誌做為錯誤輸出或者業務記錄。在這篇文章中,我們結合log4rs聊聊rust 程式中如何使用日誌。 ...
  • 本章筆者將介紹一種通過Metasploit生成ShellCode並將其註入到特定PE文件內的Shell植入技術。該技術能夠劫持原始PE文件的入口地址,在PE程式運行之前執行ShellCode反彈,執行後掛入後臺並繼續運行原始程式,實現了一種隱蔽的Shell訪問。而我把這種技術叫做位元組註入反彈。位元組註... ...
  • 1.網關介紹 如果沒有網關,難道不行嗎?功能上是可以的,我們直接調用提供的介面就可以了。那為什麼還需要網關? 因為網關的作用不僅僅是轉發請求而已。我們可以試想一下,如果需要做一個請求認證功能,我們可以接入到 API 服務中。但是倘若後續又有服務需要接入,我們又需要重覆接入。這樣我們不僅代碼要重覆編寫 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...