用户优惠券超领的问题由踩坑到优雅完成

最近完成了一个功能,出了个bug,是单用户优惠券领超了。需求是扣减库存后-->发放优惠券。

  问题:什么是单用户超领优惠券


◦优惠券限制1人限制1张,有些人却领了2张
◦优惠券限制1人限制2张,有些人却领了3或者4张
比如说:

 有个生发洗发水100元,有个10元优惠券,每人限制领劵1张
​隔壁老王,使用时间暂停来发现问题,并发领劵
​A线程原先查询出来没有领劵,要再插入领劵记录前暂停
然后B线程原先查询出来也没有领劵,则插入领劵记录,然后A线程也插入领劵记录
老王就有了两个优惠券
​问题来源核心:对资源的修改没有加锁,导致多个线程可以同时操作,从而导致数据不正确
​解决问题:分布式锁 或者 细粒度分布式锁

解决问题思路:

本地锁:synchronize、lock等,锁在当前进程内,集群部署下依旧存在问题
分布式锁:redis、zookeeper等实现,虽然还是锁,但是多个进程共用的锁标记,可以用Redis、Zookeeper、Mysql

等都可以

设计分布式锁应该考虑的东西
◦排他性
 在分布式应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行
◦容错性
分布式锁一定能得到释放,比如客户端奔溃或者网络中断
◦满足可重入、高性能、高可用
◦注意分布式锁的开销、锁粒度

解决方案之 踩坑一

基于redis实现分布式锁
◦加锁 SETNX key value
setnx 的含义就是 SET if Not Exists,有两个参数 setnx(key, value),该方法是原子性操作
​如果 key 不存在,则设置当前 key 成功,返回 1;
​如果当前 key 已经存在,则设置当前 key 失败,返回 0

◦解锁 del (key)
得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入,调用 del(key)

配置锁超时 expire (key,30s)
客户端奔溃或者网络中断,资源将会永远被锁住,即死锁,因此需要给key配置过期时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放
伪代码如下:

methodA(){
  String key = "coupon_66"

  if(setnx(key,1) == 1){
      expire(key,30,TimeUnit.MILLISECONDS)
      try {
          //做对应的业务逻辑
          //查询用户是否已经领券
          //如果没有则扣减库存
          //新增领劵记录
      } finally {
          del(key)
      }
  }else{

    //睡眠100毫秒,然后自旋调用本方法
    methodA()
  }
}
存在的坑是?????

--------------------------华丽分割线---------------踩坑中-------------------------

多个命令之间不是原子性操作,如setnx和expire之间,如果setnx成功,但是expire失败,且宕机了,则这个资源就是死锁
使用原子命令:设置和配置过期时间  setnx / setex
如: set key 1 ex 30 nx
java里面 redisTemplate.opsForValue().setIfAbsent("seckill_1",1,30,TimeUnit.MILLISECONDS)

业务超时,存在其他线程勿删,key 30秒过期,假如线程A执行很慢超过30秒,则key就被释放了,其他线程B就得到了锁,这个时候线程A执行完成,而B还没执行完成,结果就是线程A删除了线程B加的锁

可以在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁, 那 value 应该是存当前线程的标识或者uuid
​String key = "coupon_66"
String value = Thread.currentThread().getId()

if(setnx(key,value) == 1){
    expire(key,30,TimeUnit.MILLISECONDS)
    try {
        //做对应的业务逻辑
    } finally {
      //删除锁,判断是否是当前线程加的
      if(get(key).equals(value)){
          //还存在时间间隔
          del(key)
        }
    }
}else{
  
  //睡眠100毫秒,然后自旋调用本方法

}
----------------------------那么坑又来了------------------------------------------------

当线程A获取到正常值时,返回带代码中判断期间锁过期了,线程B刚好重新设置了新值,线程A那边有判断value是自己的标识,然后调用del方法,结果就是删除了新设置的线程B的值
◾核心还是判断和删除命令 不是原子性操作导致
核心是保证多个指令原子性,加锁使用setnx setex 可以保证原子性,那解锁使用 判断和删除怎么保证原子性
◦多个命令的原子性:采用 lua脚本+redis, 由于【判断和删除】是lua脚本执行,所以要么全成功,要么全失败
//获取lock的值和传递的值一样,调用删除操作返回1,否则返回0
​String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
​//Arrays.asList(lockKey)是key列表,uuid是参数
Integer result = redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(lockKey), uuid);
 采用lua脚本的删除锁方式--------------------------------------代码如下---------------------------------

/**
* 原生分布式锁 开始
* 1、原子加锁 设置过期时间,防止宕机死锁
* 2、原子解锁:需要判断是不是自己的锁
*/
String uuid = CommonUtil.generateUUID();
String lockKey = "lock:coupon:"+couponId;
Boolean nativeLock=redisTemplate.opsForValue().setIfAbsent(lockKey,uuid,Duration.ofSeconds(30));
    if(nativeLock){
      //加锁成功
      log.info("加锁:{}",nativeLock);
      try {
           //执行业务  TODO
        }finally {
           String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";

                Integer result = redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(lockKey), uuid);
                log.info("解锁:{}",result);
            }

        }else {
            //加锁失败,睡眠100毫秒,自旋重试
            try {
                TimeUnit.MILLISECONDS.sleep(100L);
            } catch (InterruptedException e) { }
            return addCoupon( couponId, couponCategory);
        }
        //原生分布式锁 结束

◦遗留一个问题,锁的过期时间,如何实现锁的自动续期 或者 避免业务执行时间过长,锁过期了?
◾原生方式的话,一般把锁的过期时间设置久一点,比如10分钟时间

-------------------------------上述实现lua+redis靠谱,但是太过繁琐------------------正解如下--------------------------------

使用Redisson,pom.xml
<dependency>
      <groupId>org.redisson</groupId>
      <artifactId>redisson</artifactId>
      <version>3.10.1</version>
</dependency>

创建redisson客户端

@Configuration
@Data
public class AppConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private String redisPort;


    @Value("${spring.redis.password}")
    private String redisPwd;


    /**
     * 配置分布式锁的redisson
     * @return
     */
    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();

        //单机方式
        config.useSingleServer().setPassword(redisPwd).setAddress("redis://"+redisHost+":"+redisPort);

        //集群
        //config.useClusterServers().addNodeAddress("redis://192.31.21.1:6379","redis://192.31.21.2:6379")

        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }

}
 

---------------------使用如下----------------

       String lockKey = "lock:coupon:"+couponId;
        RLock rLock = redissonClient.getLock(lockKey);

        //多个线程进入,会阻塞等待释放锁,默认30秒,然后有watch dog自动续期
        rLock.lock();

        //加锁10秒钟过期,没有watch dog功能,无法自动续期
        //rLock.lock(10,TimeUnit.SECONDS);

        log.info("领劵接口加锁成功:{}",Thread.currentThread().getId());
        
        try {
         //这是业务逻辑咯
            }

        }finally {
               rLock.unlock();
        }

猜你喜欢

转载自blog.csdn.net/wnn654321/article/details/114993268