开启掘金的成长之旅!这是我参加“掘金每日新项目·12月更新挑战赛”的第3天,点击查看活动详情。本文记录博主线上项目用户重复注册问题的分析过程及解决方法。博主github地址:github。com/wayn111-重现流程在线客户端用户微信扫码登录需要绑定手机号。绑定手机后,用户购买客户端产品,再次登录,发现用户账号ID已更改。不是用户首次绑定手机号时自动登录的用户账号ID。查询在线数据库后发现,同一个手机生成了多个账号ID。至此,问题重现。在分析过程中,发现数据库中的一个手机号生成了多个用户账号,第一反应是用户在绑定手机号的过程中多次点击绑定按钮,导致绑定接口被调用多次,导致多个线程并发调用用户注册接口,从而产生多个账号。为了验证我们的猜想,直接查看绑定手机后的用户注册方式/***根据用户手机号注册*///启动@Transactional事务注解@Transactional(rollbackFor=Exception.class)publicbooleanuserRegister(LoginReqBodybody,BaseReqHeaderheader,BaseRespresp){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("用户注册失败");}最后{redisLock.unLock();}//添加注册日志,上报给数据分析平台...returntrue;}先看代码,在分布式环境下,先加一个分布式锁,保证一次只能一个线程执行,然后判断有没有数据库中的用户手机信息,存在则退出,不存在则执行用户注册操作。本来以为逻辑上没有问题,但是线上环境确实存在同一个手机号重复注册的问题。首先代码包含在@Transactional注解中,就是执行自动事务中的注册逻辑。现在博客提醒大家,MySQL事务有4个隔离级别。Readuncommitted:读未提交。只要其他事务修改了数据,即使没有提交,这个事务仍然可以看到修改后的数据值。Readcommitted:读提交,其他事务提交对数据的修改后,本事务可以读取修改后的数据值。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);布尔锁;TransactionStatus事务=空;尝试{锁=redisLock.lock();//使用redis分布式锁if(lock){//查询数据库,是否成功插入用户手机号,存在则退出操作MemberDOmember=mapper.findByMobile(body.getAccount(),body.getRegRes());如果(Objects.nonNull(成员)){resp.setResultFail(ReturnCodeEnum.USER_EXIST);返回假;}//手动开启事务transaction=platformTransactionManager.getTransaction(transactiononDefinition);//进行用户注册操作,包括插入用户表,订单表,是否邀请...//手动提交交易platformTransactionManager.commit(transaction);...}}catch(Exceptione){log.error("用户注册失败:",e);如果(事务!=null){platformTransactionManager.rollback(事务);}返回假;}最后{redisLock.unLock();}//添加注册日志和上报到数据分析平台...returntrue;}3.2用户注册时在注册界面添加防重复提交处理下面是一个基于AOP切面+注解的限流逻辑/***限流枚举*/publicenumLimitType{//DefaultCUSTOMER,//byipaddrIP}/***自定义接口限流**@authorjacky*/@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public@interfaceLimit{booleanuseAccount()默认为真;字符串名称()默认"";Stringkey()默认"";Stringprefix()默认"";整数期间();整数计数();LimitTypelimitType()默认的LimitType。*限制器切面*/@Slf4j@Aspect@ComponentpublicclassLimitAspect{@AutowiredprivateStringRedisTemplatestringRedisTemplate;@Pointcut("@annotation(com.dogame.dragon.sparrow.framework.common.annotation.Limit)")publicvoidpointcut(){}@Around("pointcut()")publicObjectaround(ProceedingJoinPointjoinPoint)throwsThrowable{ServletRequestAttributes属性=(ServletRequestAttributes)RequestContextHolder.getRequestAttributes();HttpServletRequest请求=attrs.getRequest();MethodSignature签名=(MethodSignature)joinPoint.getSignature();方法signatureMethod=signature.getMethod();limitlimit=signatureMethod.getAnnotation(Limit.class);布尔useAccount=limit.useAccount();限制类型limitType=limit.limitType();字符串键=limit.key();if(StringUtils.isEmpty(key)){if(limitType==LimitType.IP){key=IpUtils.getIpAddress(请求);}else{key=signatureMethod.getName();}}if(useAccount){LoginMemberloginMember=LocalContext.getLoginMember();if(loginMember!=null){key=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为{},描述为[{}]interface",count,strings,limit.name());returnjoinPoint.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;";}}3.3前端添加防止绑定手机按键的连接点4.线上项目总结Spring提供的自动事务注解的使用需要多思考,尽可能减少交易影响范围,对注册等按钮在前后端添加防重复点击处理