天天看點

Redis事務引發的線上生産事故完整複盤,學到了!

作者:大雜燴聚集地
Redis事務引發的線上生産事故完整複盤,學到了!
一、前言

最近項目的生産環境遇到一個奇怪的問題:

現象:每天早上客服人員在背景建立客服事件時,都會建立失敗。當我們重新開機這個微服務後,背景就可以正常建立了客服事件了。到第二天早上又會建立失敗,又得重新開機這個微服務才行。

初步排查:建立一個客服事件時,會用到 Redis 的遞增操作來生成一個唯一的分布式 ID 作為事件 id。代碼如下所示:

return redisTemplate.opsForValue().increment("count", 1);
           

而恰巧每天早上這個遞增操作都會傳回 null,進而導緻後面的一系列邏輯出錯,儲存客服事件失敗。當重新開機微服務後,這個遞增操作又正常了。

那麼排查的方向就是 Redis 的操作為什麼會傳回 null 了,以及為什麼重新開機就又恢複正常了。
二、排查

根據上面的資訊,我們先來看看 Redis 的自增操作在什麼情況下會傳回 null。

2.1 推測一

根據重新開機後就恢複正常,我們推測晚上執行了大量的 job,大量 Redis 連接配接未釋放,當早上再來執行 Redis 操作時,執行失敗。重新開機後,連接配接自動釋放了。

但是其他有使用到 Redis 的業務功能又是正常的,是以推測一的方向有問題,排除。

2.2 推測二

可能是 Redis 事務造成的問題。這個推測的依據是根據下面的代碼來排查的。

直接看 redisTemplate 遞增的方法 increment,如下所示:

Redis事務引發的線上生産事故完整複盤,學到了!

官方注釋已經說明什麼情況下會傳回 null:

  • 當在 pipeline(管道)中使用這個 increment 方法時會傳回 null。
  • 當在 transaction(事務)中使用這個 increment 方法時會傳回 null。

事務提供了一種将多個指令打包,然後一次性、有序地執行機制.

多個指令會被入列到事務隊列中,然後按先進先出(FIFO)的順序執行。

事務在執行過程中不會被中斷,當事務隊列中的所有指令都被執行完畢之後,事務才會結束。(内容來自 Redis 設計與實作)

繼續看代碼,發現在操作 Redis 的 ServiceImpl 實作類的上面添加了一個 @Transactional 注解,推測是不是這個注解影響了 Redis 的操作結果。

2.3 驗證推測二

如下面的表格所示,第二行中沒有添加 Spring 的事務注解 @Transactional時,執行 Redis 的遞增指令肯定是正常的,而接下來要驗證的是表格中的第一行:加了 @Transactional 是否對 Redis 的指令有影響。

Redis事務引發的線上生産事故完整複盤,學到了!

為了驗證上面的推論,我寫了一個 Demo 程式。

Controller 類,定義了一個 API,用來模拟前端發起的請求:

Redis事務引發的線上生産事故完整複盤,學到了!

Service 實作類,定義了一個方法,用來遞增 Redis 中的 count 鍵,每次遞增 1,然後傳回指令執行後的結果。而且這個 Service 方法加了@Transactional 注解。

Redis事務引發的線上生産事故完整複盤,學到了!

Postman 測試下,發現每發一次請求,count 都會遞增 1,并沒有傳回 null。

Redis事務引發的線上生産事故完整複盤,學到了!

然後到 Redis 中檢視資料,count 的值也是遞增後的值 38,也不是 null。

Redis事務引發的線上生産事故完整複盤,學到了!

通過這個實驗說明在 @Transactional 注解的方法裡面執行 Redis 的操作并不會傳回 null,結論我記錄到了表格中。

Redis事務引發的線上生産事故完整複盤,學到了!

是以說上面的推論不成立(加了 @Transactional 注解并不影響),到這裡線索似乎斷了。

2.4 推測三

然後跟當時做這塊功能的開發人員說明了情況,告訴他可能是 Redis 事務造成的,然後問有沒有其他同學在淩晨執行過 Redis 事務相關的 Job。

他說最近有同僚加過 Redis 的事務功能,在淩晨執行 Job 的時候用到事務。我将這位同僚加的代碼簡化後如下所示:

Redis事務引發的線上生産事故完整複盤,學到了!

下面是針對這段代碼的解釋,簡單來說就是開啟事務,将 Redis 指令順序放到一個隊列中,然後最後一起執行,且保證原子性。

setEnableTransactionSupport表示是否開啟事務支援,預設不開啟。

Redis事務引發的線上生産事故完整複盤,學到了!
難道開啟了 Redis 事務,還能影響 Spring 事務中的 Redis 操作?

2.5 驗證推測三

如下表,序号 3 和 序号 4 的場景都是開啟了 Redis 的事務支援,兩個場景的差別是是否加了 @Transactional 注解。

Redis事務引發的線上生産事故完整複盤,學到了!

為了驗證上面的場景,我們來做個實驗:

  • 先開啟 Redis 事務支援,然後執行 Redis 的事務指令 multi 和 exec 。
  • 驗證場景 3:在 @Transactional 注解的方法中執行 Redis 的遞增操作。
  • 驗證場景 4:在非 @Transactional 注解的方法中執行 Redis 的遞增操作

2.5.1 執行 Redis 事務

首先就用 Redis 的 multi 和 exec 指令來設定兩個 key 的值。

Redis事務引發的線上生産事故完整複盤,學到了!

如下圖所示,設定成功了。

Redis事務引發的線上生産事故完整複盤,學到了!

2.5.2 @Transactional 中執行 Redis 指令

接下來在标注有 @Transactional 注解的方法中執行 Redis 的遞增操作。

Redis事務引發的線上生産事故完整複盤,學到了!

多次執行這個指令傳回的結果都是 null,這不就正好重制了!

Redis事務引發的線上生産事故完整複盤,學到了!

再來看 Redis 中 count 的值,發現每執行一次 API 請求調用,都會遞增 1,是以雖然指令傳回的是 null,但最後 Redis 中存放的還是遞增後的結果。

Redis事務引發的線上生産事故完整複盤,學到了!
Redis事務引發的線上生産事故完整複盤,學到了!

接下來我們驗證下場景 4,先執行 Redis 事務操作,然後在不添加 @Transactional 注解的方法中執行 Redis 遞增操作。

Redis事務引發的線上生産事故完整複盤,學到了!

用 Postman 調用這個接口後,正常傳回自增後的結果,并不是傳回 null。說明在非 @Transactional 中執行 Redis 操作并沒有受到 Redis 事務的影響。

Redis事務引發的線上生産事故完整複盤,學到了!

四個場景的結論如下所示,隻有第三個場景下,Redis 的遞增操作才會傳回 null。

Redis事務引發的線上生産事故完整複盤,學到了!

問題原因找到了,說明 RedisTemplete 開啟了 Redis 事務支援後,在 @Transactional 中執行的 Redis 指令也會被認為是在 Redis 事務中執行的,要執行的遞增指令會被放到隊列中,不會立即傳回執行後的結果,傳回的是一個 null,需要等待事務送出時,隊列中的指令才會順序執行,最後 Redis 資料庫的鍵值才會遞增。

三、源碼解析

那我們就看下為什麼開啟了 Redis 事務支援,效果就不一樣了。

找到 Redis 執行指令的核心方法, execute 方法。

Redis事務引發的線上生産事故完整複盤,學到了!

然後一步一步點進去看,關鍵代碼就是 211 行到 216 行,有一個邏輯判斷,當開啟了 Redis 事務支援後,就會去綁定一個連接配接(bindConnection),否則就去擷取新的 Redis 連接配接(getConnection)。這裡我們是開啟了的,是以再到 bindConnection方法中檢視如何綁定連接配接的。

Redis事務引發的線上生産事故完整複盤,學到了!

接着往下看,關鍵代碼如下所示,當開啟了 Redis 事務支援,且添加了 @Transactional 注解時,就會執行 Redis 的 mutil 指令。

關鍵代碼:conn.multi();

Redis事務引發的線上生産事故完整複盤,學到了!

Redis Multi 指令用于标記一個事務塊的開始,事務塊内的多條指令會按照先後順序被放進一個隊列當中,最後由 EXEC 指令原子性(atomic)地執行。

真相大白,開啟 Redis 事務支援 + @Transactional 注解後,最後其實是标記了一個 Redis 事務塊,後續的操作指令是在這個事務塊中執行的。

比如下面的的遞增指令并不會傳回遞增後的結果,而是傳回 null。

stringRedisTemplate.opsForValue().increment("count", 1);
           

而我們的生産環境重新開機服務後,開啟的 Redis 事務支援又被重置為預設值了,是以後續的 Redis 遞增操作都能正常執行。

四、修複方案

目前想到了兩種解決方案:

  • 方案一:每次 Redis 的事務操作完成後,關閉 Redis 事務支援,然後再執行 @Transactional 中的 Redis 指令。(有弊端)
  • 方案二:建立兩個 StringRedisTemplate,一個專門用來執行 Redis 事務,一個用來執行普通的 Redis 指令。

4.1 方案一

方案一的寫法如下,先開啟事務支援,事務執行之後,再關閉事務支援。

Redis事務引發的線上生産事故完整複盤,學到了!

但是這種寫法有個弊端,如果在執行 Redis 事務期間,在 @Transactional 注解的方法裡面執行 Redis 指令,則還是會造成傳回結果為 null。

Redis事務引發的線上生産事故完整複盤,學到了!

4.2 方案二

弄兩個 RedisTemplate Bean,一個是用來執行 Redis 事務的,一個是用來執行普通 Redis 指令的(不支援事務)。不同的地方引入不同的 Bean 就可以了。

先建立一個 RedisConfig 檔案,自動裝配兩個 Bean。一個 Bean 名為 stringRedisTemplate 代表不支援事務的,執行指令後立即傳回實際的執行結果。另外一個 Bean 名為 stringRedisTemplateTransaction,代表開啟 Redis 事務支援的。

代碼如下所示:

Redis事務引發的線上生産事故完整複盤,學到了!

接下來在測試的 Service 類中注入兩個不同的 StringRedisTemplate 執行個體,代碼如下所示:

Redis事務引發的線上生産事故完整複盤,學到了!

Redis 事務的操作改寫成這樣,且不需要手動開啟 Redis 事務支援了。用到的 StringRedisTemplate 是支援事務的那個執行個體。

Redis事務引發的線上生産事故完整複盤,學到了!

在 Spring 的 @Tranactional 中執行的 Redis 指令如下所示,用到的 StringRedisTemplate 是不支援事務的那個執行個體。

Redis事務引發的線上生産事故完整複盤,學到了!

然後還是按照上面場景 3 的測試步驟,先執行 testRedisMutil 方法,再執行 testTransactionAnnotations 方法。

驗證結果:Redis 遞增操作正常傳回 count 的值,修複完成。

另外關于 Redis 事務使用還有一個坑,就是 Redis 連接配接未釋放,導緻擷取不到連接配接了,這是下一個話題了~

參考資料:https://blog.csdn.net/qq_34021712/article/details/79606551

原文連結:悟空聊架構

Redis事務引發的線上生産事故完整複盤,學到了!

繼續閱讀