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

這是悟空的第 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 紅人。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章