V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
wayn111
V2EX  ›  程序员

用户重复注册分析-多线程事务中加锁引发的 bug

  •  
  •   wayn111 ·
    wayn111 · 2022-12-10 22:52:57 +08:00 · 1349 次点击
    这是一个创建于 718 天前的主题,其中的信息可能已经有所发展或是发生改变。

    本文记录博主线上项目一次用户重复注册问题的分析过程与解决方案

    一 复现过程

    线上客户端用户使用微信扫码登陆时需要再绑定一个手机号,在绑定手机后,用户购买客户端商品下线再登录,发现用户账号 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:

    1. 当用户执行注册操作,重复点击注册按钮时,假设线程 A 和 B 同时执行到 redisLock.lock()时,假设线程 A 获取到锁,线程 B 进入自旋等待,线程 A 执行mapper.findByMobile(body.getAccount(), body.getRegRes())操作,发现用户手机不存在数据库中,进行注册操作(添加用户信息入库等),执行完毕,释放锁。执行后续添加注册日志,上报到数据分析平台操作,注意此时事务还未提交。
    1. 线程 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 提供的自动事务注解使用要多加思考,尽可能减少事务影响范围,针对注册等按钮要在前后端添加防重复点击处理

    git00ll
        1
    git00ll  
       2022-12-11 13:45:22 +08:00
    1. 数据库添加唯一索引 兜底可以避免这种问题
    2.事务内不建议存在除数据库以外的 io 操作,这里的加锁过程在事务内访问 redis 是不合理的
    chenjau
        2
    chenjau  
       2022-12-11 15:32:28 +08:00
    数据库手机号字段唯一不就完了吗? 前端提交加 disabled.
    这种东西又是 redis 又是多线程的, 复杂了.
    wayn111
        3
    wayn111  
    OP
       2022-12-11 15:45:27 +08:00 via Android
    @git00ll 第二条代码修改后是这样的,第一条的话,历史原因已经无法添加唯一索引了
    wayn111
        4
    wayn111  
    OP
       2022-12-11 15:46:24 +08:00 via Android
    @chenjau 历史原因,唯一索引加不了😂
    noyle
        5
    noyle  
       2022-12-11 17:10:13 +08:00
    @wayn111 没有经验,问个离题的问题,如果是这样的话,能不能做账户升级?用户登录时检测记录是否唯一,如果唯一就放到新表里,如果不唯一就引导用户处理重复记录(合并等)。
    wayn111
        6
    wayn111  
    OP
       2022-12-11 20:15:41 +08:00
    @noyle 业务上可以这么做,考虑实现成本
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1080 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 380ms · UTC 22:31 · PVG 06:31 · LAX 14:31 · JFK 17:31
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.