多线程加锁(抽奖活动用户重复中奖的bug查询)
问题背景:
原来写了一段代码,经反馈出现了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请求上百次的场景没有测试到。单人瞬间多次请求抽奖接口,正常情况下比较少见,但是也不排除恶意攻击。前端可以在上一个接口没有返回的时候把按钮置灰,不让用户进行点击。当然后端也要做相应的处理,前后端双重保险更安全。