当前位置: 首页 > 后端技术 > Java

用户重复注册分析-多线程事务锁定导致的bug_0

时间:2023-04-01 16:48:38 Java

一个递归过程。在线客户端用户微信扫码登录时需要绑定其他手机号。绑定手机后,用户购买客户端产品下线再登录时,发现用户账号ID已经更改,不再是用户登录时自动登录的用户账号ID先绑定手机号。查询在线数据库后发现,同一个手机生成了多个账号ID。至此,问题重现了两次。分析过程中发现数据库中一个手机号生成了多个用户账号。第一反应是用户在绑定手机号的过程中多次点击绑定按钮,导致绑定接口被多次调用,导致多个线程并发调用用户。注册接口生成多个账号。为了验证我们的猜想,直接查看绑定手机后的用户注册方式/**根据用户手机号注册*///启动@Transactional事务注解@Transactional(rollbackFor=Exception.class)publicbooleanuserRegister(LoginReqBody主体,BaseReqHeader标头,BaseResp响应){RedisLockredisLock=redisCache.getRedisLock(RedisNameEnum.USER_REGISTER_LOCK.get(""),10);布尔锁;尝试{锁=redisLock.lock();//使用redis分布式锁if(lock){//查询数据库看用户手机号是否插入成功。如果存在,则退出操作。MemberDOmember=mapper.findByMobile(body.getAccount(),body.getRegRes());if(Objects.nonNull(member)){resp.setResultFail(ReturnCodeEnum.USER_EXIST);返回假;}//执行用户注册操作,包括插入用户表,订单表,是否邀请...}}catch(Exceptione){log.error("用户注册失败:",e);thrownewException("用户注册失败");}finally{redisLock.unLock();}//添加注册日志并上报给数据分析平台...returntrue;}复制代码乍一看代码,分布式环境下,先加一个分布式锁,保证同一时间只能一个线程执行,然后判断数据库中是否有用户手机信息,有则退出,有则退出不存在然后执行用户注册操作。我觉得逻辑上没有问题,但是线上环境确实存在同一个手机号重复注册的问题。首先代码包含在@Transactional注解中,就是执行自动事务中的注册逻辑。下面博主带大家回忆一下,MySQL事务有4个隔离级别:Readuncommitted:readuncommitted,只要其他事务修改了数据,即使没有commit,本事务也能看到修改后的数据值Readcommitted:Readcommitted,other事务提交对数据的修改后,事务可以读取修改后的数据值。Repeatableread:可重复读,无论其他事务是否修改和提交数据,在本次事务中看到的数据值始终不受其他事务的影响。Serializable:序列化,一个事务一个一个的执行。MySQL数据库默认使用可重复读(Repeatableread)。隔离级别越高,越能保证数据的完整性和一致性,但对并发性能的影响也越大。MySQL默认的隔离级别是readrepeatableread。上述场景,也就是说无论其他线程事务是否提交数据,当前线程事务中看到的数据值始终不受其他事务的影响,无法读取到其他线程事务未提交的数据.下面结合上面的代码给出分析过程:上面的注册逻辑包含在Spring提供的自动事务中,整个方法都在事务中。锁定也在事务中执行。最后,我们注册的线程B无法查询到当前事务中另一个注册线程A未提交的数据。例如,当用户进行注册操作,重复点击注册按钮时,假设同时执行线程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修改事务的范围,尽量减少事务的操作代码,保证事务提交在锁结束前完成。代码如下,开启手动事务,让其他线程可以锁定在代码块中。查看最新数据@AutowiredprivatePlatformTransactionManagerplatformTransactionManager;@AutowiredprivateTransactionDefinitiontransactionDefinition;privatebooleanuserRegister(LoginReqBodybody,BaseReqHeaderheader,BaseRespresp){RedisLockredisLock=redisCache.getRedisLock(RedisNameEnum.USER_REGISTER_LOCK.get(""),10);booleanlock;TransactionStatustransaction=null;try{lock=redisLock.lock();//使用redis分布式锁if(lock){//查询数据库,查看用户手机号是否插入成功,存在则退出操作MemberDOmember=mapper.findByMobile(body.getAccount(),body.getRegRes());如果(Objects.nonNull(成员)){resp.setResultFail(ReturnCodeEnum.USER_EXIST);返回假;}//手动开启事务transaction=platformTransactionManager.getTransaction(transactionDefinition);//执行用户注册操作,包括插入用户表、订单表、是否邀请。..//手动提交事务platformTransactionManager.commit(transaction);...}}catch(Exceptione){log.error("用户注册失败:",e);如果(事务!=null){platformTransactionManager.rollback(事务);}returnfalse;}finally{redisLock.unLock();}//添加注册日志并上报给数据分析平台...returntrue;}复制代码3.2用户注册时在注册界面添加防重复提交处理下面是一个基于AOP切面+注解实现的限流逻辑/**限流枚举*/publicenumLimitType{//defaultCUSTOMER,//byipaddrIP}/**自定义接口限流*@authorjacky*/@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public@interfaceLimit{booleanuseAccount()默认true;Stringname()默认"";Stringkey()默认"";Stringprefix()默认"";intperiod();intcount();LimitTypelimitType()defaultLimitType.CUSTOMER;}/**Limiteraspect*/@Slf4j@Aspect@ComponentpublicclassLimitAspect{@AutowiredprivateStringRedisTemplatestringRedisTemplate;@Pointcut("@注释(com.dogame.dragon.sparrow.frameworkk.common.annotation.Limit)")publicvoidpointcut(){}@Around("pointcut()")publicObjectaround(ProceedingJoinPointjoinPoint)throwsThrowable{ServletRequestAttributesattrs=(ServletRequestAttributes)RequestContextHolder.getRequestAttributes();HttpServletRequest请求=attrs.getRequest();MethodSignaturesignature=(MethodSignature)joinPoint.getSignature();MethodsignatureMethod=signature.getMethod();Limit限制=signatureMethod.getAnnotation(Limit.class);booleanuseAccount=limit.useAccount();LimitTypelimitType=limit.limitType();Stringkey=limit.key();if(StringUtils.isEmpty(key)){if(limitType==LimitType.IP){key=IpUtils.getIpAddress(request);}else{键=signatureMethod.getName();}}if(useAccount){LoginMemberloginMember=LocalContext.getLoginMember();if(loginMember!=null){key=键+"_"+loginMember.getAccount();}}Stringjoin=StringUtils.join(limit.prefix(),key,"_",request.getRequestURI().replaceAll("/","_"));Liststrings=Collections.singletonList(join);字符串luaScript=buildLuaScript();RedisScriptredisScript=newDefaultRedisScript<>(luaScript,Long.class);Longcount=stringRedisTemplate.execute(redisScript,strings,limit.count()+"",limit.period()+"");if(null!=count&&count.intValue()<=limit.count()){log.info("{}访问的key是{},描述为[{}]的一个接口",count,strings,限制名称());返回joinPoint.proceed();}else{thrownewDragonSparrowException("短时间内限制访问次数");}}/***限流脚本*/privateStringbuildLuaScript(){return"localc"+"\nc=redis.call('get',KEYS[1])"+"\nifcandtonumber(c)>tonumber(ARGV[1])then"+"\nreturnc;"+"\nend"+"\nc=redis.call('incr',KEYS[1])"+"\niftonumber(c)==1then"+"\nredis.call('expire',KEYS[1],ARGV[2])"+"\nend"+"\nreturnc;";}}CopyCode3.3前端对绑定手机按钮添加防连接处理四、总结线上项目需要多思考使用Spring提供的自动事务注解尽量减少交易影响的范围,对于注册等按钮,前后端都需要加入防重复点击处理