開心一刻 昨晚和一個朋友聊天 我:處對象嗎,咱倆試試? 朋友:我有對象 我:我不信,有對象不公開? 朋友:不好公開,我當的小三 問題背景 程式在生產環境穩定的跑著 直到有一天,公司執行組件漏洞掃描,有漏洞的 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、對於維護中的老項目,代碼能不動就不動,配置能不動就不動