Redis分布式锁的超卖问题

redis分布式锁造成的事故

public SeckillActivityRequestVo seckillHandler(SeckillActivityRequestVo request){
    
    
  
  //定义返回对象
  SeckillActivityRequestVo response;
  
  //redis的key
  String key = "key" + request.getSeckillId;
  try{
    
    
    
    //获取分布式锁
    Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, "val", 10, TimeUnit.SECONDS);
    if(lockFlag) {
    
    
      
      //HTTP请求用户服务进行相关校验
      //用户活动校验
       
      //库存校验
      Object stock = redisTemplate.opsForHash().get(key+":info", "stock");
      assert stock != null;
      
      if(Integer.parseInt(stock.toString()) <= 0) {
    
    
        
        //已经没有库存了,
      } else {
    
    
        
        redisTemplate.opsForHash().increment(key+":info","stock",-1);
        
        //生成订单信息
        //发布订单创建成功是将
        //构建相应VO
      }
      
    }
    
  } finally {
    
    
    
    //释放锁
    StringRedisTemplement.delete("key");
    
  }
  
  return response;
}

以上代码,通过分布式锁过期时间有效期10s来保障业务逻辑有足够的执行时间;采用try-finally语句块保证锁一定会及时释放。

事故原因:

稀有商品吸引了大量新用户,其中不乏羊毛党,采用专业的手段刷单。因此用户系统需要提前做好防备:例如接入阿里云人机验证、三要素认证等挡住大量非法用户。正因如此,用户服务一直处于较高的运行负载中。

抢购活动开始的一瞬间,大量的用户校验请求抵达用户服务,导致用户服务网关出现了短暂的相应延迟,有些请求的响应时长超过10s,但由于HTTP请求的响应超时(HTTP响应超时时间30S),导致接口一直阻塞再用户校验那里,10s后,分布式锁已经失效,此时有新的请求进来可以拿到锁,也就是锁被覆盖了。这些阻塞的接口执行完成之后,又会执行释放锁的逻辑,就把其他线程的锁释放了,导致新的请求也可以竞争到锁(恶行循环)。这个时候只能依赖库存校验,但是库存校验又不是原子性的,采用get and compare的方法,就发生了超卖。

事故分析:

  1. 没有其他系统风险容错处理

    由于用户服务吃紧,网关响应延迟,但没有任何对应方式,这是超卖的导火索。

  2. 看似安全的分布式锁其实一点都不安全

    虽然采用了set key value [EX seconds] [PX milliseconds] [NX|XX]的方式,但是如果线程A执行的时间较长没有来得及释放,锁就过期了,此时线程B是可以获取到锁的。当线程A执行完成之后,释放锁,实际上就把线程B的锁释放掉了。这个时候,线程C又是可以获取到锁的,而此时如果线程B执行完释放锁实际上就是释放的线程C设置的锁。这是超卖的直接原因。

  3. 非原子性的库存校验

    非原子性库存校验导致在并发场景下,库存校验结果不准确。这是超卖的根本原因。

解决方案

  1. 实现相对安全的分布式锁

    相对安全的定义:set、del是一一映射的,不会出现把其他线程的锁del的情况。从实际情况的角度来看,即使做到了set、del一一映射,也无法保证业务的绝对安全。因为锁的过期时间始终是有界的,除非不设置过期时间或者把过期时间设置很长,但是这样也会带来其他问题。要想事项相对安全的分布式锁,必须依赖key的value值,在释放锁的时候,通过value值的唯一性来保证不会误删。基于LUA脚本实现原子性的get and compare

    public void safeUnLock(String key, String val) {
          
          
      
      String luaScript = "local in = ARGV[1] local curr = redis.call('get', KEYS[1]) if in == curr then redis.call('del',KEYS[1]) end return 'OK'";
      RedisScript<String> redisScript = RedisScript.of(luaScript);
      redisTemplate.execute(redisScript,Collection.singletonList(ley), Collections.singletonList(value));
    }
    

    通过LUA脚本来实现安全地解锁。

  2. 实现安全地库存校验

    如果对并发有深入的了解的话,会发现类似 get and compare / read and save等操作都是非原子性的。如果要实现原子性,我么你可以借助LUA脚本来实现。

    //redis会返回操作后的结果,这个过程是原子性的
    Long currStory = redisTemolate.opsForHash().increment("key","stock", -1);
    

改进后的代码

public SeckillActivityRequestVo seckillHandler(SeckillActivityRequestVo request){
    
    
  
  //定义返回对象
  SeckillActivityRequestVo response;
  
  //redis的key
  String key = "key" + request.getSeckillId;
  String val = UUID.randomUUID().toString();
  try{
    
    
    
    //获取分布式锁
    Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, val, 10, TimeUnit.SECONDS);
    if(lockFlag) {
    
    
      
      //HTTP请求用户服务进行相关校验
      //用户活动校验
       
      //库存校验
      Long currStory = redisTemolate.opsForHash().increment("key","stock", -1);
      
      if(currStory <= 0) {
    
    
        
        //已经没有库存了,
      } else {
    
    
        
        //生成订单信息
        //发布订单创建成功是将
        //构建相应VO
      }
      
    }
    
  } finally {
    
    
    
    //释放锁
    distributedLocker.safedUnLock(key, val);
    
  }
  
  return response;
}

分布式锁有必要吗

改进之后,其实可以发现,我们借助于redis本身的原子性扣减库存,也是可以保证不会超卖的。对的。但是如果没有这一层锁的话,那么所有请求进来都会走一遍业务逻辑,由于依赖了其他系统,此时就会造成对其他系统的压力增大。这会增加的性能损耗和服务不稳定性,得不偿失。基于分布式锁可以在一定程度上拦截一些流量。

分布式锁的选型

有人提出用RedLock来实现分布式锁。RedLock的可靠性更高,但其代价是牺牲一定的性能。在本场景,这点可靠性的提升远不如性能的提升带来的性价比高。如果对于可靠性极高要求的场景,则可以采用RedLock来实现。

猜你喜欢

转载自blog.csdn.net/baidu_41934937/article/details/122855349