美文网首页
多线程加锁(抽奖活动用户重复中奖的bug查询)

多线程加锁(抽奖活动用户重复中奖的bug查询)

作者: 燃灯道童 | 来源:发表于2022-12-16 11:48 被阅读0次

    问题背景:
    原来写了一段代码,经反馈出现了bug。抽奖活动规则不支持重复中奖,但是同一个用户中了两个五等奖,奖项是一个优惠code,一个用户只能领一个。但是用户查询自己的中奖纪录的时候是有两个。

    抽奖的逻辑代码:
    进入抽奖方法,先对该用户进行加锁;如果该用户没有被加锁,则执行抽奖的流程;然后更新礼品的库存;保存用户中奖记录;最后释放锁。

            String key = "dec_lottery_lock_" + request.getOpenId();
            long time = System.currentTimeMillis();
            if (!RedisUtil.tryLock(key, String.valueOf(time))) {
                log.info("the key is "+key+" in LotteryDrawService locked.");
                return null;
            }
            try {
                // call lottery logic
                 responseDTO = toDrow(request,lotterys,lotteryConfig,counter);
    
                // update inventory (if it is ‘thanks for your participation’, don't need to update the inventory)
                if(responseDTO.getGiftName().indexOf("谢谢")==-1){
                    updateGiftInventory(responseDTO.getGiftId(),request.getSettingId());
                }
    
                // save winning records
                saveAwardRecord(request, responseDTO);
            } catch (Exception e) {
                log.error("an exception is appear when lottery draw,the exception information is: "+e);
                return responseDTO;
            } finally {
                //unlock
                RedisUtil.unlock(key, String.valueOf(time));
            }
    

    进入抽奖方法的加锁解锁的逻辑代码

    //加锁
        public static Boolean tryLock(String key, String value) {
            if (stringRedisTemplate.opsForValue().setIfAbsent(key, value)) {
                return true;
            }
            String currentValue = stringRedisTemplate.opsForValue().get(key);
            if (StringUtils.isNotEmpty(currentValue) && Long.parseLong(currentValue) < System.currentTimeMillis()) {
                //获取上一个锁的时间 如果高并发的情况可能会出现已经被修改的问题  所以多一次判断保证线程的安全
                String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, value);
                if (StringUtils.isNotEmpty(oldValue) && oldValue.equals(currentValue)) {
                    return true;
                }
            }
            return false;
        }
    
    //解锁
        public static void unlock(String key, String value) {
            String currentValue = stringRedisTemplate.opsForValue().get(key);
            if (StringUtils.isNotEmpty(currentValue) && currentValue.equals(value)) {
                stringRedisTemplate.opsForValue().getOperations().delete(key);
            }
        }
    

    刚看到这个问题有点懵,一直考虑的方向是代码的事务问题,当用户存储中奖纪录的事务还没提交的时候,新的线程又进来了。但是数据库中存储了两条,相隔时间为1s。如果是事务的问题不应该存入两条且间隔时间这么长。
    静下心来重新捋下代码,重新看加锁的逻辑。

    原因:
    原来是加锁的逻辑,为了防止多线程请求导致数据错乱,加锁时添加了双重判断。第二层的判断原意是好的,但也是导致这个问题的原因。可以看接下来的代码。
    同一个用户在锁还没释放的时候,再次进入请求方法的时候应直接返回null;如上加锁代码,在判断多线程时,取得redis中的当前值和原来的值其实是相等的,所以在抽奖方法没走完之前,该用户再次请求进来依旧能抽奖,导致同一个用户中奖了两次。

    加锁的逻辑,主要涉及redis的setIfAbsent和getAndSet两个方法。
    setIfAbsent 键不存在则新增,返回true;键存在则不改变已经有的值,无法赋值,返回false
    getAndSet 获取原来key键对应的值 并重新赋新值

    尝试还原原意
    一,是多线程的时候,第二个线程进来的时候,更新redis的值。但是这样也是不解决问题,必须让用户第一次抽奖未结束,第二次进来时给提前返回才能解决问题。

        public static Boolean tryLock(String key, String value) {
            //setIfAbsent 键不存在则新增,返回true;键存在则不改变已经有的值,无法赋值,返回false
            if (stringRedisTemplate.opsForValue().setIfAbsent(key, value)) {
                return true;
            }
    //        String currentValue = stringRedisTemplate.opsForValue().get(key);
            String currentValue = value;
    
            if (StringUtils.isNotEmpty(currentValue) && Long.parseLong(currentValue) < System.currentTimeMillis()) {
                //获取上一个锁的时间 如果高并发的情况可能会出现已经被修改的问题  所以多一次判断保证线程的安全
                //getAndSet 获取原来key键对应的值 并重新赋新值
                String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, value);
    //            String oldValue = stringRedisTemplate.opsForValue().get(key);
                log.info("锁中原来的值为:{},当前的值为:{},结果值为:{}", oldValue, currentValue, oldValue.equals(currentValue));
                if (StringUtils.isNotEmpty(oldValue) && oldValue.equals(currentValue)) {
                    return true;
                }
            }
            return false;
        }
    

    二, 是第二个线程进来的时候进行判断,如果只是判断的话,这段代码删掉,保留第一个即可。

        public static Boolean tryLock(String key, String value) {
            //setIfAbsent 键不存在则新增,返回true;键存在则不改变已经有的值,无法赋值,返回false
            if (stringRedisTemplate.opsForValue().setIfAbsent(key, value)) {
                return true;
            }
    //        String currentValue = stringRedisTemplate.opsForValue().get(key);
            String currentValue = value;
    
            if (StringUtils.isNotEmpty(currentValue) && Long.parseLong(currentValue) < System.currentTimeMillis()) {
                //获取上一个锁的时间 如果高并发的情况可能会出现已经被修改的问题  所以多一次判断保证线程的安全
    //            String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, value);
                String oldValue = stringRedisTemplate.opsForValue().get(key);
                log.info("锁中原来的值为:{},当前的值为:{},结果值为:{}", oldValue, currentValue, oldValue.equals(currentValue));
                if (StringUtils.isNotEmpty(oldValue) && oldValue.equals(currentValue)) {
                    return true;
                }
            }
            return false;
        }
    

    调整成这样之后,表中还是会存在多条数据,看了value值为获取当前时间的毫秒数才明白,
    System.currentTimeMillis()获取当前的总毫秒数,1秒=1000毫秒(ms),当我进行压测1秒500的时候会存入数据,1秒1000个线程时存入的数据更多了。因为1秒拆分成1000份执行代码的时候,存入redis中的新值和老值相等的概率还是很大的。

    最终调整的代码如下,再次用压测工具验证成功。

        public static Boolean tryLock(String key, String value) {
            //setIfAbsent 键不存在则新增,返回true;键存在则不改变已经有的值,无法赋值,返回false
            if (stringRedisTemplate.opsForValue().setIfAbsent(key, value)) {
                return true;
            }
            return false;
        }
    

    做压测的时,单个用户高并发的进行抽奖的场景验证得不充分,单人1s请求上百次的场景没有测试到。单人瞬间多次请求抽奖接口,正常情况下比较少见,但是也不排除恶意攻击。前端可以在上一个接口没有返回的时候把按钮置灰,不让用户进行点击。当然后端也要做相应的处理,前后端双重保险更安全。

    相关文章

      网友评论

          本文标题:多线程加锁(抽奖活动用户重复中奖的bug查询)

          本文链接:https://www.haomeiwen.com/subject/cbbgfdtx.html