我跟你說@RefreshScope跟Spring事件監(jiān)聽一起用有坑!
本文記錄一下我在 Spring 自帶的事件監(jiān)聽類添加 @RefreshScope 注解時(shí)遇到的坑,原本這兩個(gè)東西單獨(dú)使用是各自安好,但當(dāng)大家將它們組合在一起時(shí),會(huì)發(fā)現(xiàn)我們的事件監(jiān)聽代碼被重復(fù)執(zhí)行。希望大家引以為鑒,避免重復(fù)踩坑。耐心看完,你一定會(huì)有所收獲!
前置描述
最近有一個(gè)用戶拉新的需求,需要在新用戶注冊(cè)時(shí)判斷用戶是否有對(duì)應(yīng)的邀請(qǐng)關(guān)系,如果有則需要給新用戶贈(zèng)送系統(tǒng)資源。
原有的用戶注冊(cè)邏輯里使用了 Spring 自帶的事件監(jiān)聽工具,也就是 applicationEventPublisher(事件發(fā)布類)以及 ApplicationListener(事件監(jiān)聽類),在用戶注冊(cè)完畢寫入用戶記錄并生成 token 后,會(huì)觸發(fā) RegisterEvent(注冊(cè)事件)的發(fā)布。偽代碼如下,
// 1. 用戶注冊(cè),寫入數(shù)據(jù)庫
RegisterResponseVO registerResponseVO = memberRegisterService.register(new RegisterRequestVO(request);
// 2. 生成token
String token = getToken(memberEntity.getId(), request.getSource());
log.info("login mobile {} login token {}", request.getMobile(), token);
// 3. 發(fā)布注冊(cè)事件,會(huì)觸發(fā)登錄日志監(jiān)聽、優(yōu)惠券贈(zèng)送監(jiān)聽等
applicationEventPublisher.publishEvent(new RegisterEvent(request, memberEntity, token));
由于之前代碼已經(jīng)使用事件監(jiān)聽邏輯,所以這里我們的新用戶注冊(cè)判斷邀請(qǐng)關(guān)系的邏輯就直接新建一個(gè) NewUserInvitedListener 監(jiān)聽類即可。偽代碼如下,
@Slf4j
@RefreshScope
@AllArgsConstructor
@Component
public class NewUserInvitedListener implements ApplicationListener<RegisterEvent> {
@Async("asyncServiceExecutor")
@Override
public void onApplicationEvent(RegisterEvent registerEvent) {
UserLoginRequestVO requestVO = registerEvent.getRequestVO();
MemberEntity memberEntity = registerEvent.getMemberEntity();
log.info("================ NewUserInvitedListener =============== registerEvent is {}", registerEvent);
// 1. 校驗(yàn)邏輯
validateUser(memberEntity);
// 2. 判斷用戶是否有邀請(qǐng)關(guān)系
// 3. 如果有則贈(zèng)送系統(tǒng)資源
...
}
}
OK,代碼邏輯也不復(fù)雜,寫完提測(cè)交給測(cè)試下班(周五下午寫完)。
發(fā)現(xiàn)問題
周一一來,測(cè)試就在群里 @ 后端人員說是新用戶贈(zèng)送的系統(tǒng)資源送了兩次,說實(shí)話我一開始是不太信的,直到我去查了日志,發(fā)現(xiàn) NewUserInvitedListener 監(jiān)聽類的日志確實(shí)被打印了兩次,也就是說我們的 NewUserInvitedListener 監(jiān)聽類被觸發(fā)了兩次。
OK,到這里我們的問題就確確實(shí)實(shí)產(chǎn)生了,接下來就是解決問題。
解決思路
問題產(chǎn)生通常都有很多種解決方法,我們?nèi)绾芜x擇一個(gè)最適合我們當(dāng)前場(chǎng)景的方法才能體現(xiàn)出我們對(duì)業(yè)務(wù)、技術(shù)的理解。在這個(gè)監(jiān)聽類重復(fù)觸發(fā)的場(chǎng)景里,就有多種解決方式,我簡(jiǎn)單列舉幾個(gè),
- 添加冪等處理,防止重復(fù)執(zhí)行
- 加鎖,防止重復(fù)執(zhí)行
- 解決下為什么監(jiān)聽類會(huì)重復(fù)觸發(fā)
這三個(gè)解決方案各有優(yōu)劣,通過對(duì)監(jiān)聽類的業(yè)務(wù)邏輯添加冪等邏輯或者加鎖邏輯都是可以解決的,但是這不是問題根源,問題根源是在于監(jiān)聽類為什么會(huì)被重復(fù)觸發(fā)。在本文中,我也將帶著大家一步一步探索并解決這個(gè)問題。
檢查下之前的事件監(jiān)聽類是否也有重復(fù)觸發(fā)的問題
因?yàn)檫@個(gè)代碼是照著之前的邏輯寫的,新加的 NewUserInvitedListener 被發(fā)現(xiàn)重復(fù)觸發(fā),那以前的 MemberLoginLogListener 是否也有重復(fù)觸發(fā)的問題。偽代碼如下,
@Slf4j
@Component
@AllArgsConstructor
public class MemberLoginLogListener implements ApplicationListener<RegisterEvent> {
private MemberLoginLogService memberLoginLogService;
@Async("asyncServiceExecutor")
@Override
public void onApplicationEvent(RegisterEvent event) {
MemberEntity memberEntity = event.getMemberEntity();
log.info("================ MemberLoginLogListener ===============, mobile is {}", memberEntity.getMobile());
MemberLoginLogEntity memberLoginLogEntity = MemberLoginLogConvertor.buildLoginLogEntity(event.getRequestVO(),
event.getMemberEntity());
memberLoginLogEntity.setToken(event.getToken());
memberLoginLogService.save(memberLoginLogEntity);
}
}
查詢 MemberLoginLogListener 監(jiān)聽類的日志,發(fā)現(xiàn)只有一次打印,說明之前寫的 MemberLoginLogListener 監(jiān)聽類沒有重復(fù)觸發(fā)的問題,那這里就很奇怪了。對(duì)比一下 NewUserInvitedListener 監(jiān)聽類與 MemberLoginLogListener 監(jiān)聽類的差別,很明顯我們發(fā)現(xiàn) NewUserInvitedListener 監(jiān)聽類上多了一個(gè) @RefreshScope 注解。
OK,問題有可能就是 @RefreshScope 注解導(dǎo)致,我們?nèi)サ?@RefreshScope 注解在看看日志打印。
去掉 @RefreshScope 注解
當(dāng)我們?nèi)サ?@RefreshScope 注解后,神奇的事情發(fā)生了,NewUserInvitedListener 監(jiān)聽類的日志打印正常了,只觸發(fā)了一次!OK,到這里我們也就發(fā)現(xiàn)了問題出在 @RefreshScope 注解上。
如何搜索問題
雖然我們知道了問題出在 @RefreshScope 注解上,但是我們?cè)趺聪蛩阉饕婷枋鲞@個(gè)問題嘞?
很多人發(fā)現(xiàn)了問題,但是不知道如何描述問題,怎么描述問題才能讓別人一聽就懂,從而能給你提供幫助。你需要把問題的重點(diǎn)描述出來,搜索引擎才能給予精準(zhǔn)幫助。
在我們這個(gè)新用戶注冊(cè)判斷邀請(qǐng)關(guān)系的場(chǎng)景里,很顯然我們的搜索詞可以是 “spring 事件監(jiān)聽重復(fù)觸發(fā) @RefreshScope”可以看到我的搜索關(guān)鍵詞有 3 個(gè),分別是 spring、事件監(jiān)聽重復(fù)觸發(fā)以及 @RefreshScope。讓我們來看看搜索結(jié)果。
圖片
前 5 個(gè)搜索結(jié)果中,只有第五個(gè)的標(biāo)題可能符合我們的搜索內(nèi)容,我們點(diǎn)進(jìn)去看一看。
圖片
很遺憾,跟我們的問題場(chǎng)景并不相符,我們并沒有搜索到我們想要的東西。在這里我們的搜索關(guān)鍵詞“spring 事件監(jiān)聽重復(fù)觸發(fā) @RefreshScope”并沒有給予我們幫助。
回到問題本身
既然我們的問題已經(jīng)定位到了,在于 @RefreshScope 會(huì)導(dǎo)致監(jiān)聽類的重復(fù)觸發(fā),可是這個(gè)關(guān)鍵詞并沒有相關(guān)搜索結(jié)果,那么我們只能換個(gè)角度。
為什么會(huì)重復(fù)觸發(fā)?
在 NewUserInvitedListener 監(jiān)聽類中,我們使用 @Component 注解,默認(rèn)注冊(cè)了一個(gè)單例 bean,這個(gè) bean 用于接收用戶注冊(cè)事件。既然 bean 是單一的,那就是說 Spring 發(fā)送了 2 次 RegisterEvent 事件嗎?結(jié)合上文提到的 MemberLoginLogListener 監(jiān)聽類只觸發(fā)一次的日志,很顯然,Spring 只會(huì)發(fā)送了 1 次 RegisterEvent 事件。
難道說問題在于 Spring 里出現(xiàn)了兩個(gè) NewUserInvitedListener 類型的 bean?
那么到這里恭喜我們終于定位到了重復(fù)觸發(fā)問題的根源。
如果大家了解 @RefreshScope 的原理相信大家已經(jīng)猜出來了。
@RefreshScope 原理
Spring 中 @scope 注解的原理就是在創(chuàng)建 Scope=singleton 的 Bean 時(shí),IOC 會(huì)保存實(shí)例在一個(gè) Map 中,保證這個(gè) Bean 在一個(gè) IOC 上下文有且僅有一個(gè)實(shí)例。
SpringCloud 新增了一個(gè)自定義的作用域:refresh(可以理解為“動(dòng)態(tài)刷新”),同樣用了一種獨(dú)特的方式改變了 Bean 的管理方式,使得其可以通過外部化配置(.properties)的刷新,在應(yīng)用不需要重啟的情況下熱加載新的外部化配置的值。
這個(gè) scope 是如何做到熱加載的呢?RefreshScope 主要做了以下動(dòng)作:?jiǎn)为?dú)管理 Bean 生命周期
創(chuàng)建 Bean 的時(shí)候如果是 RefreshScope 就緩存在一個(gè)專門管理的 ScopeMap 中,這樣就可以管理 Scope 是 Refresh 的 Bean 的生命周期了(所以含 RefreshScope 的其實(shí)一共創(chuàng)建了兩個(gè) bean)。
重新創(chuàng)建 Bean
外部化配置刷新之后,會(huì)觸發(fā)一個(gè)動(dòng)作,這個(gè)動(dòng)作將上面的 ScopeMap 中的 Bean 清空,這樣這些 Bean 就會(huì)重新被 IOC 容器創(chuàng)建一次,使用最新的外部化配置的值注入類中,達(dá)到熱加載新值的效果。
看完 @RefreshScope 的原理相信大家已經(jīng)知道了出現(xiàn)兩個(gè) NewUserInvitedListener 類型 bean 的原因是在于 @RefreshScope 導(dǎo)致。這是由于 @RefreshScope 注解的內(nèi)部實(shí)現(xiàn)創(chuàng)建了另外一個(gè)相同類型的 NewUserInvitedListener bean,導(dǎo)致我們的新用戶監(jiān)聽邏輯被重復(fù)執(zhí)行。
回到搜索關(guān)鍵詞
假如我是說假如,假如我們不知道 @RefreshScope 的原理,自然不知道項(xiàng)目中出現(xiàn)了兩個(gè) NewUserInvitedListener 類型的 bean 是 @RefreshScope 導(dǎo)致。那么我們?cè)趺赐ㄟ^搜索關(guān)鍵詞來找到這個(gè)問題嘞?
到這里也就是本文的重點(diǎn)所在,怎么通過搜索關(guān)鍵詞來解決我們的問題。
先定義問題
在這個(gè)場(chǎng)景里我們使用的是 Spring 項(xiàng)目,問題本質(zhì)是 @RefreshScope 在 Spring 自帶的事件監(jiān)聽類搭配使用時(shí),由于 @RefreshScope 會(huì)導(dǎo)致 bean 重復(fù)進(jìn)而導(dǎo)致重復(fù)觸發(fā)。
總結(jié)關(guān)鍵詞
在上面的先定義問題中,我們提煉一下關(guān)鍵詞,
- Spring:這個(gè)關(guān)鍵詞在 Spring 項(xiàng)目中必帶,大家應(yīng)該沒有意見把
- @RefreshScope:我們的問題根源,搜索也得帶上
- 生成同一個(gè) bean:這是一個(gè)描述語句,簡(jiǎn)要描述一下我們發(fā)現(xiàn)的問題
看一看搜索結(jié)果,
圖片
點(diǎn)進(jìn)第一個(gè)結(jié)果,
圖片
OK,大功告成,看到我們框選中的地方了嗎,上文的 @RefreshScope 原理解釋,就是復(fù)制與這里。
貼一下原文地址:https://blog.csdn.net/m0_71777195/article/details/127223544
一些思考
實(shí)話實(shí)說,我在測(cè)試給我上報(bào)問題,到發(fā)現(xiàn)這個(gè)問題來自于 @RefreshScope 注解只用了 10 分鐘,如上文所說,我通過對(duì)比以前寫的 MemberLoginLogListener 監(jiān)聽類,早早的定位到問題來自于 @RefreshScope 注解。可是到我完整修復(fù)這個(gè)問題,提交到測(cè)試環(huán)境,卻花了 2 個(gè)半小時(shí),原因是因?yàn)槲以谘芯窟@個(gè)問題的根源,這也是這篇文章的由來。
假如說這個(gè)問題發(fā)生在線上,那么我根本不可能花這么多時(shí)間來研究,我需要的就是迅速解決這個(gè)問題并修復(fù)上線,避免影響更多用戶。
一樣的,大家在遇到這種相似問題時(shí),如果境況緊急出現(xiàn)在生產(chǎn)環(huán)境,大家本著對(duì)工作負(fù)責(zé)的態(tài)度,應(yīng)該迅速解決并做故障復(fù)盤。如果是出現(xiàn)在測(cè)試環(huán)境我們可以本著對(duì)技術(shù)執(zhí)著可以認(rèn)真專研下這個(gè)問題。
其實(shí)我還想說的是在這個(gè)問題里,我能 10 分鐘定位到問題來自于 @RefreshScope 注解,可能也有運(yùn)氣成分。但是很多情況下當(dāng)我們照驢子畫馬寫代碼,發(fā)現(xiàn)出了問題時(shí),這種情況大部分還是我們“畫蛇添足”導(dǎo)致。大家可以通過對(duì)比以前代碼迅速找出問題原因。
找出了問題后是如何解決問題。這篇文章里,我給大家講了講我的搜索關(guān)鍵詞心得。第一是講重點(diǎn)、第二是找到問題本質(zhì),這樣才能從搜索引擎嘴里找出我們想要的答案。