沒想到,Redis 遇到 @Transactional注解,有這么大個坑
大家好,我是小富~
最近項目的生產(chǎn)環(huán)境遇到一個奇怪的問題:
現(xiàn)象:每天早上客服人員在后臺創(chuàng)建客服事件時,都會創(chuàng)建失敗。當(dāng)我們重啟這個微服務(wù)后,后臺就可以正常創(chuàng)建了客服事件了。到第二天早上又會創(chuàng)建失敗,又得重啟這個微服務(wù)才行。
初步排查:創(chuàng)建一個客服事件時,會用到 Redis 的遞增操作來生成一個唯一的分布式 ID 作為事件 id。代碼如下所示:
return?redisTemplate.opsForValue().increment("count",?1);
而恰巧每天早上這個遞增操作都會返回 null,進(jìn)而導(dǎo)致后面的一系列邏輯出錯,保存客服事件失敗。當(dāng)重啟微服務(wù)后,這個遞增操作又正常了。
那么排查的方向就是 Redis 的操作為什么會返回 null 了,以及為什么重啟就又恢復(fù)正常了。
二、排查
根據(jù)上面的信息,我們先來看看 Redis 的自增操作在什么情況下會返回 null。
2.1 推測一
根據(jù)重啟后就恢復(fù)正常,我們推測晚上執(zhí)行了大量的 job,大量 Redis 連接未釋放,當(dāng)早上再來執(zhí)行 Redis 操作時,執(zhí)行失敗。重啟后,連接自動釋放了。
但是其他有使用到 Redis 的業(yè)務(wù)功能又是正常的,所以推測一的方向有問題,排除。
2.2 推測二
可能是 Redis 事務(wù)造成的問題。這個推測的依據(jù)是根據(jù)下面的代碼來排查的。
直接看 redisTemplate 遞增的方法 increment,如下所示:

官方注釋已經(jīng)說明什么情況下會返回 null:
- 當(dāng)在 pipeline(管道)中使用這個 increment 方法時會返回 null。
- 當(dāng)在 transaction(事務(wù))中使用這個 increment 方法時會返回 null。
事務(wù)提供了一種將多個命令打包,然后一次性、有序地執(zhí)行機制.
多個命令會被入列到事務(wù)隊列中,然后按先進(jìn)先出(FIFO)的順序執(zhí)行。
事務(wù)在執(zhí)行過程中不會被中斷,當(dāng)事務(wù)隊列中的所有命令都被執(zhí)行完畢之后,事務(wù)才會結(jié)束。(內(nèi)容來自 Redis 設(shè)計與實現(xiàn))
繼續(xù)看代碼,發(fā)現(xiàn)在操作 Redis 的 ServiceImpl 實現(xiàn)類的上面添加了一個 @Transactional 注解,推測是不是這個注解影響了 Redis 的操作結(jié)果。
2.3 驗證推測二
如下面的表格所示,第二行中沒有添加 Spring 的事務(wù)注解 @Transactional時,執(zhí)行 Redis 的遞增命令肯定是正常的,而接下來要驗證的是表格中的第一行:加了 @Transactional 是否對 Redis 的命令有影響。

為了驗證上面的推論,我寫了一個 Demo 程序。
Controller 類,定義了一個 API,用來模擬前端發(fā)起的請求:

Service 實現(xiàn)類,定義了一個方法,用來遞增 Redis 中的 count 鍵,每次遞增 1,然后返回命令執(zhí)行后的結(jié)果。而且這個 Service 方法加了@Transactional 注解。

Postman 測試下,發(fā)現(xiàn)每發(fā)一次請求,count 都會遞增 1,并沒有返回 null。

然后到 Redis 中查看數(shù)據(jù),count 的值也是遞增后的值 38,也不是 null。

通過這個實驗說明在 @Transactional 注解的方法里面執(zhí)行 Redis 的操作并不會返回 null,結(jié)論我記錄到了表格中。

所以說上面的推論不成立(加了 @Transactional 注解并不影響),到這里線索似乎斷了。
2.4 推測三
然后跟當(dāng)時做這塊功能的開發(fā)人員說明了情況,告訴他可能是 Redis 事務(wù)造成的,然后問有沒有其他同學(xué)在凌晨執(zhí)行過 Redis 事務(wù)相關(guān)的 Job。
他說最近有同事加過 Redis 的事務(wù)功能,在凌晨執(zhí)行 Job 的時候用到事務(wù)。我將這位同事加的代碼簡化后如下所示:

下面是針對這段代碼的解釋,簡單來說就是開啟事務(wù),將 Redis 命令順序放到一個隊列中,然后最后一起執(zhí)行,且保證原子性。
setEnableTransactionSupport表示是否開啟事務(wù)支持,默認(rèn)不開啟。

難道開啟了 Redis 事務(wù),還能影響 Spring 事務(wù)中的 Redis 操作?
2.5 驗證推測三
如下表,序號 3 和 序號 4 的場景都是開啟了 Redis 的事務(wù)支持,兩個場景的區(qū)別是是否加了 @Transactional 注解。

為了驗證上面的場景,我們來做個實驗:
- 先開啟 Redis 事務(wù)支持,然后執(zhí)行 Redis 的事務(wù)命令 multi ?和 exec 。
- 驗證場景 3:在 @Transactional 注解的方法中執(zhí)行 Redis 的遞增操作。
- 驗證場景 4:在非 @Transactional 注解的方法中執(zhí)行 Redis 的遞增操作
2.5.1 執(zhí)行 Redis 事務(wù)
首先就用 Redis 的 multi 和 exec 命令來設(shè)置兩個 key 的值。

如下圖所示,設(shè)置成功了。

2.5.2 @Transactional 中執(zhí)行 Redis 命令
接下來在標(biāo)注有 @Transactional 注解的方法中執(zhí)行 Redis 的遞增操作。

多次執(zhí)行這個命令返回的結(jié)果都是 null,這不就正好重現(xiàn)了!

再來看 Redis 中 count 的值,發(fā)現(xiàn)每執(zhí)行一次 API 請求調(diào)用,都會遞增 1,所以雖然命令返回的是 null,但最后 Redis 中存放的還是遞增后的結(jié)果。


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

用 Postman 調(diào)用這個接口后,正常返回自增后的結(jié)果,并不是返回 null。說明在非 @Transactional 中執(zhí)行 Redis 操作并沒有受到 Redis 事務(wù)的影響。

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

問題原因找到了,說明 RedisTemplete 開啟了 Redis 事務(wù)支持后,在 @Transactional 中執(zhí)行的 Redis 命令也會被認(rèn)為是在 Redis 事務(wù)中執(zhí)行的,要執(zhí)行的遞增命令會被放到隊列中,不會立即返回執(zhí)行后的結(jié)果,返回的是一個 null,需要等待事務(wù)提交時,隊列中的命令才會順序執(zhí)行,最后 Redis 數(shù)據(jù)庫的鍵值才會遞增。
三、源碼解析
那我們就看下為什么開啟了 Redis 事務(wù)支持,效果就不一樣了。
找到 Redis 執(zhí)行命令的核心方法, execute 方法。

然后一步一步點進(jìn)去看,關(guān)鍵代碼就是 211 行到 216 行,有一個邏輯判斷,當(dāng)開啟了 Redis 事務(wù)支持后,就會去綁定一個連接(bindConnection),否則就去獲取新的 Redis 連接(getConnection)。這里我們是開啟了的,所以再到 bindConnection方法中查看如何綁定連接的。

接著往下看,關(guān)鍵代碼如下所示,當(dāng)開啟了 Redis 事務(wù)支持,且添加了 @Transactional 注解時,就會執(zhí)行 Redis 的 mutil 命令。
關(guān)鍵代碼:conn.multi();

Redis Multi 命令用于標(biāo)記一個事務(wù)塊的開始,事務(wù)塊內(nèi)的多條命令會按照先后順序被放進(jìn)一個隊列當(dāng)中,最后由 EXEC 命令原子性(atomic)地執(zhí)行。
真相大白,開啟 Redis 事務(wù)支持 + @Transactional 注解后,最后其實是標(biāo)記了一個 Redis 事務(wù)塊,后續(xù)的操作命令是在這個事務(wù)塊中執(zhí)行的。
比如下面的的遞增命令并不會返回遞增后的結(jié)果,而是返回 null。
stringRedisTemplate.opsForValue().increment("count",?1);
而我們的生產(chǎn)環(huán)境重啟服務(wù)后,開啟的 Redis 事務(wù)支持又被重置為默認(rèn)值了,所以后續(xù)的 Redis 遞增操作都能正常執(zhí)行。
四、修復(fù)方案
目前想到了兩種解決方案:
- 方案一:每次 Redis 的事務(wù)操作完成后,關(guān)閉 Redis 事務(wù)支持,然后再執(zhí)行 @Transactional 中的 Redis 命令。(有弊端)
- 方案二:創(chuàng)建兩個 StringRedisTemplate,一個專門用來執(zhí)行 Redis 事務(wù),一個用來執(zhí)行普通的 Redis 命令。
4.1 方案一
方案一的寫法如下,先開啟事務(wù)支持,事務(wù)執(zhí)行之后,再關(guān)閉事務(wù)支持。

但是這種寫法有個弊端,如果在執(zhí)行 Redis 事務(wù)期間,在 @Transactional 注解的方法里面執(zhí)行 Redis 命令,則還是會造成返回結(jié)果為 null。

4.2 方案二
弄兩個 RedisTemplate Bean,一個是用來執(zhí)行 Redis 事務(wù)的,一個是用來執(zhí)行普通 Redis 命令的(不支持事務(wù))。不同的地方引入不同的 Bean 就可以了。
先創(chuàng)建一個 RedisConfig 文件,自動裝配兩個 Bean。一個 Bean 名為 stringRedisTemplate 代表不支持事務(wù)的,執(zhí)行命令后立即返回實際的執(zhí)行結(jié)果。另外一個 Bean 名為 stringRedisTemplateTransaction,代表開啟 Redis 事務(wù)支持的。
代碼如下所示:

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

Redis 事務(wù)的操作改寫成這樣,且不需要手動開啟 Redis 事務(wù)支持了。用到的 StringRedisTemplate 是支持事務(wù)的那個實例。

在 Spring 的 @Tranactional 中執(zhí)行的 Redis 命令如下所示,用到的 StringRedisTemplate 是不支持事務(wù)的那個實例。

然后還是按照上面場景 3 的測試步驟,先執(zhí)行 testRedisMutil 方法,再執(zhí)行 testTransactionAnnotations 方法。
驗證結(jié)果:Redis 遞增操作正常返回 count 的值,修復(fù)完成。
另外關(guān)于 Redis 事務(wù)使用還有一個坑,就是 Redis 連接未釋放,導(dǎo)致獲取不到連接了,這是下一個話題了~
參考資料:https://blog.csdn.net/qq_34021712/article/details/79606551
- END -
說在最后

一年一度的消費日雙十一即將到來!

為了讓盡可能多的喜歡Java的人學(xué)習(xí) 到有趣和有用知識, 我們現(xiàn)在推出年度套餐!
在11月13日前,年度訂閱Code Gym
可獲得 5折以上 的優(yōu)惠 哦!
除了打折價,還有一份個人獎金在等著你。
機不可失,先到先得!

C odeGym 背后的小故 事
Code Gym 由充滿激情的 Java開發(fā)人員于 2018年創(chuàng)立。2019年, Code Gym 在全球擁有十 萬名用戶。 從那時起, 我們的團(tuán)隊不斷 創(chuàng)新 Code Gym 課程,使我們的用戶人數(shù)達(dá)到 95萬人。我們的任務(wù)是為大家提供最新的 Java學(xué)習(xí)體驗。
Java是一種最常用也最容易學(xué)習(xí)的編程語言,掌握了它,不僅能夠輕松炫技,更打開了高薪的大門。Java 語言程序員的平均薪資為 7.5萬美元。
Code Gym 是一門面向?qū)嵺`的交互式編程課程,其中80%的內(nèi)容為實踐,20%的內(nèi)容為基本Java理論。這才是成為一名真正的Java開發(fā)人員所需要的東西。本學(xué)習(xí)課程非常適合初學(xué)者以及想要擴(kuò)展技能的軟件開發(fā)人員。
我們相信,只要用正確的方法,每個人都能掌握編碼技能。學(xué)習(xí)代碼應(yīng)該是以實踐為重點。事實上,任何人都可以學(xué)習(xí)編程——你不需要成為數(shù)學(xué)天才,只 要有學(xué)習(xí)的 欲望就足夠?qū)W會編程,程序員并不是天生的。
加入 Code Gym , 懂 點代 碼!
-
大量的練習(xí)讓你為真正的工作做好準(zhǔn)備
要學(xué)習(xí) ?Java? 語言并成為一名程序員,你需 要大量編寫代碼。采用實踐第一的方法是 Code Gym 的顯著特點。有超過1200個不同難度的任務(wù),你可以對所學(xué)的每個主要Java主題進(jìn)行大量的練習(xí)。這些數(shù)量足以讓你獲得足夠的經(jīng)驗找到一份工作。Code Gym 還為大家?guī)碓S多妙趣橫生的Java實踐體驗——聊天應(yīng)用程序、自動化餐廳應(yīng)用程序、HTML 編輯器、ATM模擬器等。和我們一起學(xué) Java,別跟丟了~!
-
邊玩游戲邊學(xué)習(xí)
學(xué)習(xí)不應(yīng)該是一個無聊的事情!Code Gym 教程使用最新技術(shù)讓你的學(xué)習(xí)更輕松、更有趣和更富有成效:可視化的課程進(jìn)展、有趣的編程故事、激勵機制、玩代碼游戲……聽起來很有趣,是不是?但 在這里不都是編碼游戲,而是一個很酷的工具來創(chuàng)建你自己的游戲,或者更準(zhǔn)確地說,創(chuàng)建自己的一些老派經(jīng)典游戲的版本。
-
即使解決方法驗證
普通課堂上,你需要等很長時間老師才有空檢查你的作業(yè)。 不要再浪費時間等待了! 我們的全能虛擬導(dǎo)師會在眨眼間來檢查你所有的解決方法并將給出針對此代碼的評論列表,能精確地告訴你哪個地方不滿足要求。
-
不再使學(xué)習(xí)Java一件孤單的事情
如果你在學(xué)習(xí)中遇到了瓶頸和困難,可以在Code Gym 社區(qū)中發(fā)帖交流,大家會熱心地幫助你。在社區(qū)中,你也可以和來自全世界的學(xué)習(xí)者一起分享你的學(xué)習(xí)收獲。在這里不僅有機會學(xué)習(xí)Java編程技術(shù),還能鍛煉自己的英語能力,相輔相成。

Code Gym
想換個新工作或者開啟新副業(yè)?
想成為家族群里最亮眼的明星?
想成為一名 Java 開發(fā)人員?
事不宜遲,你知道該怎么做。
抓住機會開始學(xué)習(xí) Java!
用最低的價格購最優(yōu)的課程,這樣福利怎能錯過!


即日起 ?至? 11 月13日
CodeGym超低限時折扣
一年期高級版訂閱服務(wù)
僅需? ¥391? 登陸即享
? ?

未注冊過的新?戶,先注冊,再訂閱
1.? 點擊鏈接注冊
https://codegym.cc/zh/
2.? 點擊 “ 課程 ” 尋找菜單
3.? 點擊菜單享受折扣優(yōu)惠
已經(jīng)注冊的 ? 戶 ,
在登陸狀態(tài)下打開鏈接直接訂閱
https://codegym.cc/zh/sale
如有問題可以通過微信公眾號后臺發(fā)私信溝通,或者到官?社區(qū)留?。
點擊閱讀原文?即刻獲取課程!
