关于后端:用户重复注册分析多线程事务中加锁引发的bug

45次阅读

共计 5284 个字符,预计需要花费 14 分钟才能阅读完成。

一 复现过程
线上客户端用户应用微信扫码登陆时须要再绑定一个手机号,在绑定手机后,用户购买客户端商品下线再登录,发现用户账号 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 提供的主动事务注解应用要多加思考,尽可能减少事务影响范畴,针对注册等按钮要在前后端增加防反复点击解决

正文完
 0