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

本文涉及的产品
RDS MySQL DuckDB 分析主实例,集群系列 4核8GB
RDS MySQL DuckDB 分析主实例,基础系列 4核8GB
RDS AI 助手,专业版
简介: 用户重复注册分析-多线程事务中加锁引发的bug

1687007783668.png

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第3天,点击查看活动详情

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

一 复现过程

线上客户端用户使用微信扫码登陆时需要再绑定一个手机号,在绑定手机后,用户购买客户端商品下线再登录,发现用户账号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())操作,发现用户手机不存在数据库中,进行注册操作(添加用户信息入库等),执行完毕,释放锁。执行后续添加注册日志,上报到数据分析平台操作,注意此时事务还未提交。
  2. 线程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 提供的自动事务注解使用要多加思考,尽可能减少事务影响范围,针对注册等按钮要在前后端添加防重复点击处理

目录
相关文章
|
5月前
|
设计模式 消息中间件 安全
【JUC】(3)常见的设计模式概念分析与多把锁使用场景!!理解线程状态转换条件!带你深入JUC!!文章全程笔记干货!!
JUC专栏第三篇,带你继续深入JUC! 本篇文章涵盖内容:保护性暂停、生产者与消费者、Park&unPark、线程转换条件、多把锁情况分析、可重入锁、顺序控制 笔记共享!!文章全程干货!
395 1
|
6月前
|
数据采集 存储 弹性计算
高并发Java爬虫的瓶颈分析与动态线程优化方案
高并发Java爬虫的瓶颈分析与动态线程优化方案
|
存储 NoSQL Redis
Redis 新版本引入多线程的利弊分析
【10月更文挑战第16天】Redis 新版本引入多线程是一个具有挑战性和机遇的改变。虽然多线程带来了一些潜在的问题和挑战,但也为 Redis 提供了进一步提升性能和扩展能力的可能性。在实际应用中,我们需要根据具体的需求和场景,综合评估多线程的利弊,谨慎地选择和使用 Redis 的新版本。同时,Redis 开发者也需要不断努力,优化和完善多线程机制,以提供更加稳定、高效和可靠的 Redis 服务。
348 1
线程CPU异常定位分析
【10月更文挑战第3天】 开发过程中会出现一些CPU异常升高的问题,想要定位到具体的位置就需要一系列的分析,记录一些分析手段。
353 0
|
并行计算 安全 Java
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
在Python开发中,GIL(全局解释器锁)一直备受关注。本文基于CPython解释器,探讨GIL的技术本质及其对程序性能的影响。GIL确保同一时刻只有一个线程执行代码,以保护内存管理的安全性,但也限制了多线程并行计算的效率。文章分析了GIL的必要性、局限性,并介绍了多进程、异步编程等替代方案。尽管Python 3.13计划移除GIL,但该特性至少要到2028年才会默认禁用,因此理解GIL仍至关重要。
1133 16
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
|
调度 开发者
核心概念解析:进程与线程的对比分析
在操作系统和计算机编程领域,进程和线程是两个基本而核心的概念。它们是程序执行和资源管理的基础,但它们之间存在显著的差异。本文将深入探讨进程与线程的区别,并分析它们在现代软件开发中的应用和重要性。
538 4
|
供应链 安全 NoSQL
PHP 互斥锁:如何确保代码的线程安全?
在多线程和高并发环境中,确保代码段互斥执行至关重要。本文介绍了 PHP 互斥锁库 `wise-locksmith`,它提供多种锁机制(如文件锁、分布式锁等),有效解决线程安全问题,特别适用于电商平台库存管理等场景。通过 Composer 安装后,开发者可以利用该库确保在高并发下数据的一致性和安全性。
241 6
|
Java 关系型数据库 MySQL
【JavaEE“多线程进阶”】——各种“锁”大总结
乐/悲观锁,轻/重量级锁,自旋锁,挂起等待锁,普通互斥锁,读写锁,公不公平锁,可不可重入锁,synchronized加锁三阶段过程,锁消除,锁粗化
|
Java 应用服务中间件 测试技术
Java21虚拟线程:我的锁去哪儿了?
【10月更文挑战第8天】
428 0
|
安全 调度 数据安全/隐私保护
iOS线程锁
iOS线程锁
228 0

热门文章

最新文章