本文記錄博主線上專案一次用戶重復注冊問題的分析程序與解決方案
- 博主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 提供的自動事務注解使用要多加思考,盡可能減少事務影響范圍,針對注冊等按鈕要在前后端添加防重復點擊處理
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/539705.html
標籤:其他
上一篇:從 695. 島嶼的最大面積 入手深度優先搜素DFS
下一篇:PTA作業6-8電信系列總結
