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