一次 Redis 事務使用不當引發的生產事故

2022-10-25 12:01:58

這是悟空的第 170 篇原創文章

官網:http://www.passjava.cn

你好,我是悟空。

本文主要內容如下:

一、前言

最近專案的生產環境遇到一個奇怪的問題:

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

初步排查:建立一個客服事件時,會用到 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,如下所示:

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

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

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

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

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

繼續看程式碼,發現在操作 Redis 的 ServiceImpl 實現類的上面新增了一個 @Transactional 註解,推測是不是這個註解影響了 Redis 的操作結果。

2.3 驗證推測二

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

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

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

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

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

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

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

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

2.4 推測三

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

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

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

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

難道開啟了 Redis 事務,還能影響 Spring 事務中的 Redis 操作?

2.5 驗證推測三

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

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

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

2.5.1 執行 Redis 事務

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

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

2.5.2 @Transactional 中執行 Redis 命令

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

多次執行這個命令返回的結果都是 null,這不就正好重現了!

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

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

用 Postman 呼叫這個介面後,正常返回自增後的結果,並不是返回 null。說明在非 @Transactional 中執行 Redis 操作並沒有受到 Redis 事務的影響。

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

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

三、原始碼解析

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

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

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

接著往下看,關鍵程式碼如下所示,當開啟了 Redis 事務支援,且新增了 @Transactional 註解時,就會執行 Redis 的 mutil 命令。

關鍵程式碼:conn.multi();

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 事務期間,在 @Transactional 註解的方法裡面執行 Redis 命令,則還是會造成返回結果為 null。

4.2 方案二

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

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

程式碼如下所示:

接下來在測試的 Service 類中注入兩個不同的 StringRedisTemplate 範例,程式碼如下所示:

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

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

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

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

另外關於 Redis 事務使用還有一個坑,就是 Redis 連線未釋放,導致獲取不到連線了,這是下一個話題了~

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

- END -

關於我

8 年網際網路開發經驗,擅長微服務、分散式、架構設計。目前在一家大型上市公司從事基礎架構和效能優化工作。

InfoQ 簽約作者、藍橋簽約作者、阿里雲專家博主、51CTO 紅人。