java多線程事務(wù)加鎖引發(fā)bug用戶重復(fù)注冊(cè)解決分析
一 復(fù)現(xiàn)過(guò)程
文記錄博主線上項(xiàng)目一次用戶重復(fù)注冊(cè)問(wèn)題的分析過(guò)程與解決方案
- 博主github地址: github.com/wayn111
線上客戶端用戶使用微信掃碼登陸時(shí)需要再綁定一個(gè)手機(jī)號(hào),在綁定手機(jī)后,用戶購(gòu)買客戶端商品下線再登錄,發(fā)現(xiàn)用戶賬號(hào)ID被變更,已經(jīng)不是用戶剛綁定手機(jī)號(hào)時(shí)自動(dòng)登錄的用戶賬號(hào)ID,查詢線上數(shù)據(jù)庫(kù),發(fā)現(xiàn)同一個(gè)手機(jī)生成了多個(gè)賬號(hào)id,至此問(wèn)題復(fù)現(xiàn)
二 分析過(guò)程
發(fā)現(xiàn)數(shù)據(jù)庫(kù)中一個(gè)手機(jī)號(hào)生成了多個(gè)用戶賬號(hào),第一反應(yīng)是用戶在綁定手機(jī)號(hào)過(guò)程中,多次點(diǎn)擊綁定按鈕,導(dǎo)致綁定接口被調(diào)用多次,造成多線程并發(fā)調(diào)用用戶注冊(cè)接口,進(jìn)而生成多個(gè)賬號(hào)。為了驗(yàn)證我們的猜想,直接查看綁定手機(jī)后的用戶注冊(cè)方法
/** * 根據(jù)用戶手機(jī)號(hào)進(jìn)行注冊(cè)操作 */ // 啟動(dòng)@Transactional事務(wù)注解 @Transactional(rollbackFor = Exception.class) public boolean userRegister(LoginReqBody body, BaseReqHeader header, BaseResp<BaseRespHeader, LoginRespBody> resp) { RedisLock redisLock = redisCache.getRedisLock(RedisNameEnum.USER_REGISTER_LOCK.get(""), 10); boolean lock; try { lock = redisLock.lock(); // 使用redis分布式鎖 if (lock) { // 查詢數(shù)據(jù)庫(kù)該用戶手機(jī)號(hào)是否插入成功,已存在則退出操作 MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes()); if (Objects.nonNull(member)) { resp.setResultFail(ReturnCodeEnum.USER_EXIST); return false; } // 執(zhí)行用戶注冊(cè)操作,包含插入用戶表、訂單表、是否被邀請(qǐng) ... } } catch (Exception e) { log.error("用戶注冊(cè)失?。?, e); throw new Exception("用戶注冊(cè)失敗"); } finally { redisLock.unLock(); } // 添加注冊(cè)日志,上報(bào)到數(shù)據(jù)分析平臺(tái)... return true; }
初看代碼,在分布式環(huán)境中,先加分布式鎖保證同時(shí)只能被一個(gè)線程執(zhí)行,然后判斷數(shù)據(jù)庫(kù)中是否存在用戶手機(jī)信息,已存在則退出,不存在則執(zhí)行用戶注冊(cè)操作,咋以為邏輯上沒(méi)有問(wèn)題,但是線上環(huán)境確實(shí)就是出現(xiàn)了相同手機(jī)號(hào)重復(fù)注冊(cè)的問(wèn)題,首先代碼被 @Transactional
注解包含,就是在自動(dòng)事務(wù)中執(zhí)行注冊(cè)邏輯
現(xiàn)在博主帶大家回憶一下,MySQL
事務(wù)的隔離級(jí)別有4個(gè)
- Read uncommitted:讀取未提交,其他事務(wù)只要修改了數(shù)據(jù),即使未提交,本事務(wù)也能看到修改后的數(shù)據(jù)值。
- Read committed:讀取已提交,其他事務(wù)提交了對(duì)數(shù)據(jù)的修改后,本事務(wù)就能讀取到修改后的數(shù)據(jù)值。
- Repeatable read:可重復(fù)讀,無(wú)論其他事務(wù)是否修改并提交了數(shù)據(jù),在這個(gè)事務(wù)中看到的數(shù)據(jù)值始終不受其他事務(wù)影響。
- Serializable:串行化,一個(gè)事務(wù)一個(gè)事務(wù)的執(zhí)行。
- MySQL數(shù)據(jù)庫(kù)默認(rèn)使用可重復(fù)讀( Repeatable read)。
隔離級(jí)別越高,越能保證數(shù)據(jù)的完整性和一致性,但是對(duì)并發(fā)性能的影響也越大,MySQL的默認(rèn)隔離級(jí)別是讀可重復(fù)讀。在上述場(chǎng)景里,也就是說(shuō),無(wú)論其他線程事務(wù)是否提交了數(shù)據(jù),當(dāng)前線程所在事務(wù)中看到的數(shù)據(jù)值始終不受其他事務(wù)影響
說(shuō)人話(劃重點(diǎn)):就是在 MySQL
中一個(gè)線程所在事務(wù)是讀不到另一個(gè)線程事務(wù)未提交的數(shù)據(jù)的
下面結(jié)合上述代碼給出分析過(guò)程:上述注冊(cè)邏輯都包含在 Spring
提供的自動(dòng)事務(wù)中,整個(gè)方法都在事務(wù)中。而加鎖也在事務(wù)中執(zhí)行。最終導(dǎo)致我們注冊(cè) 線程B
在當(dāng)前事物中查詢不到另一個(gè)注冊(cè) 線程A
所在事物未提交的數(shù)據(jù), 舉個(gè)例子
eg:
- 當(dāng)用戶執(zhí)行注冊(cè)操作,重復(fù)點(diǎn)擊注冊(cè)按鈕時(shí),假設(shè)線程A和B同時(shí)執(zhí)行到
redisLock.lock()
時(shí),假設(shè)線程A獲取到鎖,線程B進(jìn)入自旋等待,線程A執(zhí)行mapper.findByMobile(body.getAccount(), body.getRegRes())
操作,發(fā)現(xiàn)用戶手機(jī)不存在數(shù)據(jù)庫(kù)中,進(jìn)行注冊(cè)操作(添加用戶信息入庫(kù)等),執(zhí)行完畢,釋放鎖。執(zhí)行后續(xù)添加注冊(cè)日志,上報(bào)到數(shù)據(jù)分析平臺(tái)操作,注意此時(shí)事務(wù)還未提交。 - 線程B終于獲取到鎖,執(zhí)行
mapper.findByMobile(body.getAccount(), body.getRegRes())
操作,在我們一開(kāi)始的假設(shè)中,以為這里會(huì)返回用戶已存在,但是實(shí)際執(zhí)行結(jié)果并不是這樣的。原因就是線程A的事務(wù)還未提交,線程B讀不到線程A未提交事務(wù)的數(shù)據(jù)也就是說(shuō)查不到用戶已注冊(cè)信息,至此,我們知道了用戶重復(fù)注冊(cè)的原因。
三 解決方案
給出三種解決方案
3.1 修改事務(wù)范圍
將事務(wù)的操作代碼最小化,保證在加鎖結(jié)束前完成事務(wù)提交,代碼如下開(kāi)啟手動(dòng)事務(wù),這樣其他線程在加鎖代碼塊中就能看到最新數(shù)據(jù)
@Autowired private PlatformTransactionManager platformTransactionManager; @Autowired private TransactionDefinition transactionDefinition; private boolean userRegister(LoginReqBody body, BaseReqHeader header, BaseResp<BaseRespHeader, LoginRespBody> resp) { RedisLock redisLock = redisCache.getRedisLock(RedisNameEnum.USER_REGISTER_LOCK.get(""), 10); boolean lock; TransactionStatus transaction = null; try { lock = redisLock.lock(); // 使用redis分布式鎖 if (lock) { // 查詢數(shù)據(jù)庫(kù)該用戶手機(jī)號(hào)是否插入成功,已存在則退出操作 MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes()); if (Objects.nonNull(member)) { resp.setResultFail(ReturnCodeEnum.USER_EXIST); return false; } // 手動(dòng)開(kāi)啟事務(wù) transaction = platformTransactionManager.getTransaction(transactionDefinition); // 執(zhí)行用戶注冊(cè)操作,包含插入用戶表、訂單表、是否被邀請(qǐng) ... // 手動(dòng)提交事務(wù) platformTransactionManager.commit(transaction); ... } } catch (Exception e) { log.error("用戶注冊(cè)失?。?, e); if (transaction != null) { platformTransactionManager.rollback(transaction); } return false; } finally { redisLock.unLock(); } // 添加注冊(cè)日志,上報(bào)到數(shù)據(jù)分析平臺(tái)... return true; }
3.2 在用戶注冊(cè)時(shí)針對(duì)注冊(cè)接口添加防重復(fù)提交處理
下面給出一個(gè)基于 AOP
切面 + 注解實(shí)現(xiàn)的限流邏輯
/** * 限流枚舉 */ public enum LimitType { // 默認(rèn) CUSTOMER, // by ip addr IP } /** * 自定義接口限流 * * @author jacky */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Limit { boolean useAccount() default true; String name() default ""; String key() default ""; String prefix() default ""; int period(); int count(); LimitType limitType() default LimitType.CUSTOMER; } /** * 限制器切面 */ @Slf4j @Aspect @Component public class LimitAspect { @Autowired private StringRedisTemplate stringRedisTemplate; @Pointcut("@annotation(com.dogame.dragon.sparrow.framework.common.annotation.Limit)") public void pointcut() { } @Around("pointcut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attrs.getRequest(); MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method signatureMethod = signature.getMethod(); Limit limit = signatureMethod.getAnnotation(Limit.class); boolean useAccount = limit.useAccount(); LimitType limitType = limit.limitType(); String key = limit.key(); if (StringUtils.isEmpty(key)) { if (limitType == LimitType.IP) { key = IpUtils.getIpAddress(request); } else { key = signatureMethod.getName(); } } if (useAccount) { LoginMember loginMember = LocalContext.getLoginMember(); if (loginMember != null) { key = key + "_" + loginMember.getAccount(); } } String join = StringUtils.join(limit.prefix(), key, "_", request.getRequestURI().replaceAll("/", "_")); List<String> strings = Collections.singletonList(join); String luaScript = buildLuaScript(); RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class); Long count = stringRedisTemplate.execute(redisScript, strings, limit.count() + "", limit.period() + ""); if (null != count && count.intValue() <= limit.count()) { log.info("第{}次訪問(wèn)key為 {},描述為 [{}] 的接口", count, strings, limit.name()); return joinPoint.proceed(); } else { throw new DragonSparrowException("短時(shí)間內(nèi)訪問(wèn)次數(shù)受限制"); } } /** * 限流腳本 */ private String buildLuaScript() { return "local c" + "\nc = redis.call('get',KEYS[1])" + "\nif c and tonumber(c) > tonumber(ARGV[1]) then" + "\nreturn c;" + "\nend" + "\nc = redis.call('incr',KEYS[1])" + "\nif tonumber(c) == 1 then" + "\nredis.call('expire',KEYS[1],ARGV[2])" + "\nend" + "\nreturn c;"; } }
- 前端針對(duì)綁定手機(jī)按鈕添加防止連點(diǎn)處理
四 總結(jié)
線上項(xiàng)目對(duì)于 Spring
提供的自動(dòng)事務(wù)注解使用要多加思考,盡可能減少事務(wù)影響范圍,針對(duì)注冊(cè)等按鈕要在前后端添加防重復(fù)點(diǎn)擊處理
以上就是java多線程事務(wù)加鎖引發(fā)bug用戶重復(fù)注冊(cè)解決分析的詳細(xì)內(nèi)容,更多關(guān)于java多線程事務(wù)重復(fù)注冊(cè)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
idea編寫(xiě)yml、yaml文件以及其優(yōu)先級(jí)的使用
本文主要介紹了idea編寫(xiě)yml、yaml文件以及其優(yōu)先級(jí)的使用,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2023-07-07SpringBoot整合Swagger框架過(guò)程解析
這篇文章主要介紹了SpringBoot整合Swagger框架過(guò)程解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-05-05基于java計(jì)算買賣股票的最佳時(shí)機(jī)
這篇文章主要介紹了基于java計(jì)算買賣股票的最佳時(shí)機(jī),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-10-10JAVA實(shí)戰(zhàn)練習(xí)之圖書(shū)管理系統(tǒng)實(shí)現(xiàn)流程
隨著網(wǎng)絡(luò)技術(shù)的高速發(fā)展,計(jì)算機(jī)應(yīng)用的普及,利用計(jì)算機(jī)對(duì)圖書(shū)館的日常工作進(jìn)行管理勢(shì)在必行,本篇文章手把手帶你用Java實(shí)現(xiàn)一個(gè)圖書(shū)管理系統(tǒng),大家可以在過(guò)程中查缺補(bǔ)漏,提升水平2021-10-10詳解Java如何向http/https接口發(fā)出請(qǐng)求
這篇文章主要為大家詳細(xì)介紹了Java如何實(shí)現(xiàn)向http/https接口發(fā)出請(qǐng)求,文中的示例代碼講解詳細(xì),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2025-01-01詳解Java如何優(yōu)雅的實(shí)現(xiàn)字典翻譯
當(dāng)我們?cè)贘ava應(yīng)用程序中需要對(duì)字典屬性進(jìn)行轉(zhuǎn)換返回給前端時(shí),如何簡(jiǎn)單、方便、并且優(yōu)雅的處理是一個(gè)重要問(wèn)題。在本文中,我們將介紹如何使用Java中的序列化機(jī)制來(lái)優(yōu)雅地實(shí)現(xiàn)字典值的翻譯,從而簡(jiǎn)化開(kāi)發(fā)2023-04-04myBatis組件教程之緩存的實(shí)現(xiàn)與使用
這篇文章主要給大家介紹了關(guān)于myBatis組件教程之緩存的實(shí)現(xiàn)與使用的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2018-11-11MyBatis自定義typeHandler的完整實(shí)例
這篇文章主要給大家介紹了關(guān)于MyBatis自定義typeHandler的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家學(xué)習(xí)或者使用MyBatis具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-04-04