本文記錄博主線上項目一次用戶重覆註冊問題的分析過程與解決方案 博主github地址: github.com/wayn111 一 復現過程 線上客戶端用戶使用微信掃碼登陸時需要再綁定一個手機號,在綁定手機後,用戶購買客戶端商品下線再登錄,發現用戶賬號ID被變更,已經不是用戶剛綁定手機號時自動登錄的用戶 ...
本文記錄博主線上項目一次用戶重覆註冊問題的分析過程與解決方案
- 博主github地址: github.com/wayn111
一 復現過程
線上客戶端用戶使用微信掃碼登陸時需要再綁定一個手機號,在綁定手機後,用戶購買客戶端商品下線再登錄,發現用戶賬號ID被變更,已經不是用戶剛綁定手機號時自動登錄的用戶賬號ID,查詢線上資料庫,發現同一個手機生成了多個賬號id,至此問題復現
二 分析過程
發現資料庫中一個手機號生成了多個用戶賬號,第一反應是用戶在綁定手機號過程中,多次點擊綁定按鈕,導致綁定介面被調用多次,造成多線程併發調用用戶註冊介面,進而生成多個賬號。為了驗證我們的猜想,直接查看綁定手機後的用戶註冊方法
/**
* 根據用戶手機號進行註冊操作
*/
// 啟動@Transactional事務註解
@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) {
// 查詢資料庫該用戶手機號是否插入成功,已存在則退出操作
MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes());
if (Objects.nonNull(member)) {
resp.setResultFail(ReturnCodeEnum.USER_EXIST);
return false;
}
// 執行用戶註冊操作,包含插入用戶表、訂單表、是否被邀請
...
}
} catch (Exception e) {
log.error("用戶註冊失敗:", e);
throw new Exception("用戶註冊失敗");
} finally {
redisLock.unLock();
}
// 添加註冊日誌,上報到數據分析平臺...
return true;
}
初看代碼,在分散式環境中,先加分散式鎖保證同時只能被一個線程執行,然後判斷資料庫中是否存在用戶手機信息,已存在則退出,不存在則執行用戶註冊操作,咋以為邏輯上沒有問題,但是線上環境確實就是出現了相同手機號重覆註冊的問題,首先代碼被 @Transactional
註解包含,就是在自動事務中執行註冊邏輯
現在博主帶大家回憶一下,MySQL
事務的隔離級別有4個
- Read uncommitted:讀取未提交,其他事務只要修改了數據,即使未提交,本事務也能看到修改後的數據值。
- Read committed:讀取已提交,其他事務提交了對數據的修改後,本事務就能讀取到修改後的數據值。
- Repeatable read:可重覆讀,無論其他事務是否修改並提交了數據,在這個事務中看到的數據值始終不受其他事務影響。
- Serializable:串列化,一個事務一個事務的執行。
- MySQL資料庫預設使用可重覆讀( Repeatable read)。
隔離級別越高,越能保證數據的完整性和一致性,但是對併發性能的影響也越大,MySQL的預設隔離級別是讀可重覆讀。在上述場景里,也就是說,無論其他線程事務是否提交了數據,當前線程所在事務中看到的數據值始終不受其他事務影響
說人話(劃重點):就是在 MySQL
中一個線程所在事務是讀不到另一個線程事務未提交的數據的
下麵結合上述代碼給出分析過程:上述註冊邏輯都包含在 Spring
提供的自動事務中,整個方法都在事務中。而加鎖也在事務中執行。最終導致我們註冊 線程B
在當前事物中查詢不到另一個註冊 線程A
所在事物未提交的數據, 舉個例子
eg:
- 當用戶執行註冊操作,重覆點擊註冊按鈕時,假設線程A和B同時執行到
redisLock.lock()
時,假設線程A獲取到鎖,線程B進入自旋等待,線程A執行mapper.findByMobile(body.getAccount(), body.getRegRes())
操作,發現用戶手機不存在資料庫中,進行註冊操作(添加用戶信息入庫等),執行完畢,釋放鎖。執行後續添加註冊日誌,上報到數據分析平臺操作,註意此時事務還未提交。
- 線程B終於獲取到鎖,執行
mapper.findByMobile(body.getAccount(), body.getRegRes())
操作,在我們一開始的假設中,以為這裡會返回用戶已存在,但是實際執行結果並不是這樣的。原因就是線程A的事務還未提交,線程B讀不到線程A未提交事務的數據也就是說查不到用戶已註冊信息,至此,我們知道了用戶重覆註冊的原因。
三 解決方案:
給出三種解決方案
3.1 修改事務範圍,將事務的操作代碼最小化,保證在加鎖結束前完成事務提交,代碼如下開啟手動事務,這樣其他線程在加鎖代碼塊中就能看到最新數據
@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) {
// 查詢資料庫該用戶手機號是否插入成功,已存在則退出操作
MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes());
if (Objects.nonNull(member)) {
resp.setResultFail(ReturnCodeEnum.USER_EXIST);
return false;
}
// 手動開啟事務
transaction = platformTransactionManager.getTransaction(transactionDefinition);
// 執行用戶註冊操作,包含插入用戶表、訂單表、是否被邀請
...
// 手動提交事務
platformTransactionManager.commit(transaction);
...
}
} catch (Exception e) {
log.error("用戶註冊失敗:", e);
if (transaction != null) {
platformTransactionManager.rollback(transaction);
}
return false;
} finally {
redisLock.unLock();
}
// 添加註冊日誌,上報到數據分析平臺...
return true;
}
3.2 在用戶註冊時針對註冊介面添加防重覆提交處理
下麵給出一個基於 AOP
切麵 + 註解實現的限流邏輯
/**
* 限流枚舉
*/
public enum LimitType {
// 預設
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("第{}次訪問key為 {},描述為 [{}] 的介面", count, strings, limit.name());
return joinPoint.proceed();
} else {
throw new DragonSparrowException("短時間內訪問次數受限制");
}
}
/**
* 限流腳本
*/
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;";
}
}
3.3 前端針對綁定手機按鈕添加防止連點處理
四 總結
線上項目對於 Spring
提供的自動事務註解使用要多加思考,儘可能減少事務影響範圍,針對註冊等按鈕要在前後端添加防重覆點擊處理