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

2022-12-16  本文已影响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请求上百次的场景没有测试到。单人瞬间多次请求抽奖接口,正常情况下比较少见,但是也不排除恶意攻击。前端可以在上一个接口没有返回的时候把按钮置灰,不让用户进行点击。当然后端也要做相应的处理,前后端双重保险更安全。

上一篇下一篇

猜你喜欢

热点阅读