从库存超卖问题分析锁和分布式锁的应用(二)

从库存超卖问题分析锁和分布式锁的应用(一)
从库存超卖问题分析锁和分布式锁的应用(二)
分布式锁的最佳实践之Redisson
分布式锁的最佳实践之Zookeeper
分布式锁的最佳实践之MySQL

本文从一个经典的库存超卖问题分析说明常见锁的应用,假设库存资源存储在Redis里面。

假设我们的减库存代码如下:

@Autowired
StringRedisTemplate redisTemplate;

public void deduct(){
    String stock = redisTemplate.opsForValue().get("stock");
    if(StringUtils.hasLength(stock)){
        Integer st = Integer.valueOf(stock);
        if(st>0){
            redisTemplate.opsForValue().set("stock",String.valueOf(--st));
        }
    }
}

此时方法操作是先读后写,非原子性操作,是存在并发问题的。如何解决该问题,有三种方案:

  • JVM本地锁
  • Redis乐观锁
  • Redis实现分布式锁

JVM本地锁的实现与优缺点在从库存超卖问题分析锁和分布式锁的应用(一)已经分析过了,这里不再赘述。

【1】Redis乐观锁

也就是watchmultiexec组合指令的使用。

watch可以监控一个或多个key的值,如果在事务(exec)执行之前,key的值发生变化则取消事务执行。

multi用来开启事务,exec用来提交/执行事务。

watch stock
multi
set stock 5000
exec

代码修改如下:

public void deduct(){
    this.redisTemplate.execute(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            operations.watch("stock");
            // 1. 查询库存信息
            Object stock = operations.opsForValue().get("stock");
            // 2. 判断库存是否充足
            int st = 0;
            if (stock != null && (st = Integer.parseInt(stock.toString())) > 0) {
                // 3. 扣减库存
                operations.multi();//开启事务
                operations.opsForValue().set("stock", String.valueOf(--st));
                List exec = operations.exec();//执行事务
                if (exec == null || exec.size() == 0) {
                    try {
                    // 这里睡眠一下,降低竞争,提高乐观锁的吞吐量
                        Thread.sleep(50);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //再次递归
                    deduct();
                }
                return exec;
            }
            return null;
        }
    });
}

这种方式确实可以解决并发问题,但也可能在高并发的情况下由于不断重试(CAS思想)出现性能问题、连接被耗尽的情况。

【2】Redis分布式锁

① 独占排它:基于setnx思想简单实现

借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。同时有多个客户端发送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)。

// 递归思想
public void deduct(){
     //获取锁
     Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1111");
     //如果获取不到则递归重试
     if(!lock){
         deduct();
     }else{
         try{
             String stock = redisTemplate.opsForValue().get("stock");
             if(StringUtils.hasLength(stock)){
                 Integer st = Integer.valueOf(stock);
                 if(st>0){
                     redisTemplate.opsForValue().set("stock",String.valueOf(--st));
                 }
             }
         }finally {
             //释放锁
             redisTemplate.delete("lock");
         }
     }
 }

或者使用while思想:

public void deduct(){
//当setnx刚刚获取到锁,当前服务器宕机,导致del释放锁无法执行,进而导致锁无法锁无法释放(死锁)
     while (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent("lock", "xxx"))){
         try {
             Thread.sleep(100);
         }catch (Exception e){
             e.printStackTrace();
         }
     }
     try{
         String stock = redisTemplate.opsForValue().get("stock");
         if(StringUtils.hasLength(stock)){
             Integer st = Integer.valueOf(stock);
             if(st>0){
                 redisTemplate.opsForValue().set("stock",String.valueOf(--st));
             }
         }
     }finally {
         //释放锁
         redisTemplate.delete("lock");
     }
 }

这种方式存在问题:当setnx刚刚获取到锁,当前服务器宕机,导致del释放锁无法执行,进而导致锁无法锁无法释放(死锁)

解决方案:给锁设置过期时间,自动释放锁。

设置过期时间两种方式:

  1. 通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)
  2. 使用set指令设置过期时间:set key value ex 3 nx(既达到setnx的效果,又设置了过期时间)

② 防死锁优化

修改while中获取锁的逻辑如下所示:

while (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent("lock", "xxx",3, TimeUnit.SECONDS)){
    try {
        Thread.sleep(100);
    }catch (Exception e){
        e.printStackTrace();
    }
}

这种方式解决了死锁问题但是可能会释放其他服务器的锁。

场景:如果业务逻辑的执行时间是7s。执行流程如下

  1. index1业务逻辑没执行完,3秒后锁被自动释放。
  2. index2获取到锁,执行业务逻辑,3秒后锁被自动释放。
  3. index3获取到锁,执行业务逻辑
  4. index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁,导致index3的业务只
    执行1s就被别人释放。最终等于没锁的情况。

解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁

③ 防误删优化

如下这里设置锁的密钥为UUID,加锁者持有。

public void deduct(){
    String uuid = UUID.randomUUID().toString();
    while (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent("lock", uuid,3, TimeUnit.SECONDS)){
        try {
            Thread.sleep(100);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
    try{
        String stock = redisTemplate.opsForValue().get("stock");
        if(StringUtils.hasLength(stock)){
            Integer st = Integer.valueOf(stock);
            if(st>0){
                redisTemplate.opsForValue().set("stock",String.valueOf(--st));
            }
        }
    }finally {
        //释放锁
        if(uuid.equals(redisTemplate.opsForValue().get("lock"))){
            redisTemplate.delete("lock");
        }
    }
}

这种方式仍旧存在问题:删除操作缺乏原子性。

场景:

  1. index1执行删除时,查询到的lock值确实和uuid相等
  2. index1执行删除前,lock刚好过期时间已到,被redis自动释放
  3. index2获取了lock
  4. index1执行删除,此时会把index2的lock删除

解决方案:没有一个命令可以同时做到判断 + 删除,所有只能通过其他方式实现(LUA脚本

④原子性: lua脚本保证删除原子性

redis采用单线程架构,可以保证单个命令的原子性,但是无法保证一组命令在高并发场景下的原子性。

如下AB两个进程示例:

在这里插入图片描述
在串行场景下:A和B的值肯定都是3。在并发场景下:A和B的值可能在0-6之间。

极限情况下1:则A的结果是0,B的结果是3

在这里插入图片描述

极限情况下2:则A和B的结果都是6

在这里插入图片描述

如果redis客户端通过lua脚本把3个命令一次性发送给redis服务器,那么这三个指令就不会被其他客户端指令打断。Redis 也保证脚本会以原子性(atomic)的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。 这和使用 MULTI/ EXEC 包围的事务很类似。

但是MULTI/ EXEC方法来使用事务功能,将一组命令打包执行,无法进行业务逻辑的操作。这期间有某一条命令执行报错(例如给字符串自增),其他的命令还是会执行,并不会回滚。

优化代码如下所示:

public void deduct(){
     String uuid = UUID.randomUUID().toString();
     while (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent("lock", uuid,3, TimeUnit.SECONDS))){
         try {
             Thread.sleep(100);
         }catch (Exception e){
             e.printStackTrace();
         }
     }
     try{
         String stock = redisTemplate.opsForValue().get("stock");
         if(StringUtils.hasLength(stock)){
             int st = Integer.parseInt(stock);
             if(st>0){
                 redisTemplate.opsForValue().set("stock",String.valueOf(--st));
             }
         }
     }finally {
         // 先判断是否自己的锁,再解锁
         String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                 "then " +
                 "   return redis.call('del', KEYS[1]) " +
                 "else " +
                 "   return 0 " +
                 "end";
         this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList("lock"), uuid);
//            //释放锁
//            if(uuid.equals(redisTemplate.opsForValue().get("lock"))){
//                redisTemplate.delete("lock");
//            }
     }
 }

到这里似乎完美解决了我们考虑到的几点问题,那么结束了吗?

并没有,目前这种方式不支持可重入性、并且集群环境下也存在失效情况。更甚者如果由于异常情况,获取锁后服务逻辑未执行完毕,锁就自动释放了呢

⑤ 可重入性

首先我们参考ReentrantLock的源码分析其加锁解锁逻辑如下。

可重入锁加锁流程:ReentrantLock.lock() --> NonfairSync.lock() --> AQS.acquire(1) --> NonfairSync.tryAcquire(1) --> Sync.nonfairTryAcquire(1)

  1. CAS获取锁,如果没有线程占用锁(state==0),加锁成功并记录当前线程是有锁线程
  2. 如果state的值不为0,说明锁已经被占用。
  3. 再次重复12步骤后如果未加锁成功则判断当前线程是否是持有锁线程,如果是则重入(state + 1)
  4. 否则加锁失败,入队等待

可重入锁解锁流程:ReentrantLock.unlock() --> AQS.release(1) --> Sync.tryRelease(1)

  1. 判断当前线程是否是有锁线程,不是则抛出异常
  2. 对state的值减1之后,判断state的值是否为0,为0则解锁成功,返回true并唤醒下一个等待对象
  3. 如果减1后的值不为0,则返回false

对于Redis来说,其有hash数据结构,我们可以使用hexists、hincrby来基本实现可重入锁。

  • HEXISTS key field:这个命令用于检查哈希表 key 中,给定域 field 是否存在。如果域 field 存在于哈希表中,返回 1;如果域 field 不存在于哈希表中,返回 0。
  • HINCRBY key field increment :HINCRBY 命令在 Redis 中的工作方式是,如果指定的 key 不存在,Redis 会自动创建一个新的哈希(hash)结构与该 key 关联,然后执行 HINCRBY 命令来增加指定 field 的值。同样地,如果 key 存在但指定的 field 在哈希中不存在,那么在执行 HINCRBY 命令之前,该 field 的值会被初始化为 0,然后进行递增操作。

加锁流程

  1. 判断锁是否存在(exists),不存在则直接获取锁并计数为1
  2. 如果锁存在则判断是否自己的锁(hexists),如果是自己的锁则重入:hincrby key field increment
  3. 否则重试:递归 循环
if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1
then
	redis.call('hincrby', KEYS[1], ARGV[1], 1)
	redis.call('expire', KEYS[1], ARGV[2])
	return 1
else 
	return 0
end

解锁流程

  1. 判断自己的锁是否存在(hexists),不存在则返回nil
  2. 如果自己的锁存在,则减1(hincrby -1),判断减1后的值是否为0,为0则释放锁(del)并返回1
  3. 如果减1后的值不为0,返回0
if redis.call('hexists', KEYS[1], ARGV[1]) == 0
then
	return nil
elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0
then 
	return redis.call('del', KEYS[1])
else 
	return 0
end

封装分布式锁如下:

public class DistributedRedisLock implements Lock {

    private StringRedisTemplate redisTemplate;

    private String lockName;

    private String uuid;

    private long expire = 30;


    // 线程局部变量,可以在线程内共享参数,同一个线程再次获取锁的时候UUID是一样的,保证了可重入性
    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName ){
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid = THREAD_LOCAL.get();
        if (StringUtils.isEmpty(uuid)) {
            this.uuid = UUID.randomUUID().toString();
            THREAD_LOCAL.set(uuid);
        }

    }

    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        
    }
    @Override
    public boolean tryLock() {
        try {
            return this.tryLock(-1L, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    /**
     * 加锁方法
     * @param time
     * @param unit
     * @return
     * @throws InterruptedException
     */
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time != -1){
            this.expire = unit.toSeconds(time);
        }
        String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "   redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
                "   redis.call('expire', KEYS[1], ARGV[2]) " +
                "   return 1 " +
                "else " +
                "   return 0 " +
                "end";
        while (Boolean.FALSE.equals(this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), this.uuid, String.valueOf(expire)))){
            Thread.sleep(50);
        }
        return true;
    }

    /**
     * 解锁方法
     */
    @Override
    public void unlock() {
        String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
                "then " +
                "   return nil " +
                "elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
                "then " +
                "   return redis.call('del', KEYS[1]) " + //这里会返回1
                "else " +
                "   return 0 " +
                "end";
        Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockName),this.uuid);
        if (flag == null){
            throw new IllegalMonitorStateException("this lock doesn't belong to you!");
        }else if (flag == 1) {
            THREAD_LOCAL.remove(); //如果为1 ,说明删除锁成功
        }

    }

    @Override
    public Condition newCondition() {
        return null;
    }
}

测试方法如下:

public void deduct() {
    DistributedRedisLock redisLock = new DistributedRedisLock(redisTemplate, "lock");
    redisLock.lock();

    try {
        // 1. 查询库存信息
        String stock = redisTemplate.opsForValue().get("stock");

        // 2. 判断库存是否充足
        if (stock != null && stock.length() != 0) {
            int st = Integer.parseInt(stock);
            if (st > 0) {
                // 3.扣减库存
                redisTemplate.opsForValue().set("stock", String.valueOf(--st));
            }
        }
    } finally {
        redisLock.unlock();
    }
}

这里虽然解决了可重入问题,但是仍未解决业务逻辑未执行完毕,锁自动过期问题。

⑥ 自动续期

定时任务(时间驱动 Timer定时器) + lua脚本:判断自己的锁是否存在(hexists),如果存在则重置过期时间。

if redis.call('hexists', KEYS[1], ARGV[1]) == 1
then
	return redis.call('expire', KEYS[1], ARGV[2])
else 
	return 0
end

自动续期方法如下:

private static final Timer TIMER = new Timer();
/**
   * 开启定时器,自动续期
   */
private void renewExpire(){
  String script = "if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end";
  TIMER.schedule(new TimerTask() {
    @Override
    public void run() {
      // 如果uuid为空,则终止定时任务
      if (!StringUtils.isEmpty(uuid)) {
        redisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Collections.singletonList(lockName), uuid,String.valueOf(expire));
        renewExpire();
      }
    }
  }, expire * 1000 / 3);
}

在加锁成功后执行该方法:

@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
    if (time != -1){
        this.expire = unit.toSeconds(time);
    }
    String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
            "then " +
            "   redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
            "   redis.call('expire', KEYS[1], ARGV[2]) " +
            "   return 1 " +
            "else " +
            "   return 0 " +
            "end";
    while (Boolean.FALSE.equals(this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), this.uuid, String.valueOf(expire)))){
        Thread.sleep(50);
    }
    //获取锁成功,自动续期
    renewExpire();
    return true;
}

最终完整代码如下:

public class DistributedRedisLock implements Lock {

    private StringRedisTemplate redisTemplate;

    private String lockName;

    private String uuid;

    private long expire = 30;

    // 线程局部变量,可以在线程内共享参数
    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName ){
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid = THREAD_LOCAL.get();
        if (StringUtils.isEmpty(uuid)) {
            this.uuid = UUID.randomUUID().toString();
            THREAD_LOCAL.set(uuid);
        }

    }

    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }


    @Override
    public boolean tryLock() {
        try {
            return this.tryLock(-1L, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    /**
     * 加锁方法
     * @param time
     * @param unit
     * @return
     * @throws InterruptedException
     */
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time != -1){
            this.expire = unit.toSeconds(time);
        }
        String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "   redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
                "   redis.call('expire', KEYS[1], ARGV[2]) " +
                "   return 1 " +
                "else " +
                "   return 0 " +
                "end";
        while (Boolean.FALSE.equals(this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), this.uuid, String.valueOf(expire)))){
            Thread.sleep(50);
        }
        //获取锁成功,自动续期
        renewExpire();
        return true;
    }

    /**
     * 解锁方法
     */
    @Override
    public void unlock() {
        String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
                "then " +
                "   return nil " +
                "elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
                "then " +
                "   return redis.call('del', KEYS[1]) " + //这里会返回1
                "else " +
                "   return 0 " +
                "end";
        Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockName),this.uuid);
        if (flag == null){
            throw new IllegalMonitorStateException("this lock doesn't belong to you!");
        }else if (flag == 1) {
            THREAD_LOCAL.remove();
        }

    }

    @Override
    public Condition newCondition() {
        return null;
    }

    private static final Timer TIMER = new Timer();
    /**
       * 开启定时器,自动续期
       */
    private void renewExpire(){
      String script = "if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end";
      TIMER.schedule(new TimerTask() {
        @Override
        public void run() {
          // 如果uuid为空,则终止定时任务
          if (!StringUtils.isEmpty(uuid)) {
            redisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Collections.singletonList(lockName), uuid,String.valueOf(expire));
            renewExpire();
          }
        }
      }, expire * 1000 / 3);
    }
}

此时还剩下最后一个问题,集群问题:在集群情况下,导致锁机制失效:

  1. 客户端程序10010,从主中获取锁
  2. 从还没来得及同步数据,主挂了
  3. 于是从升级为主
  4. 客户端程序10086就从新主中获取到锁,导致锁机制失效

⑦单点故障: 红锁算法

redis集群状态下的问题:

  1. 客户端A从master获取到锁
  2. 在master将锁同步到slave之前,master宕掉了。
  3. slave节点被晋级为master节点
  4. 客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。

安全失效

解决集群下锁失效,参照redis官方网站针对redlock文档:https://redis.io/topics/distlock

在算法的分布式版本中,我们假设有N个Redis服务器。这些节点是完全独立的,因此我们不使用复制或任何其他隐式协调系统。 前面已经描述了如何在单个实例中安全地获取和释放锁,在分布式锁算法中,将使用相同的方法在单个实例中获取和释放锁。 将N设置为5是一个合理的值,因此需要在不同的计算机或虚拟机上运行5个Redis主服务器,确保它们以独立的方式发生故障。

为了获取锁,客户端执行以下操作:

  1. 客户端以毫秒为单位获取当前时间的时间戳,作为起始时间
  2. 客户端尝试在所有N个实例中顺序使用相同的键名、相同的随机值来获取锁定。每个实例尝试获取锁都需要时间,客户端应该设置一个远小于总锁定时间的超时时间。例如,如果自动释放时间为10秒,则尝试获取锁的超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点进行通信:如果某个实例不可用,尽快尝试与下一个实例进行通信。
  3. 客户端获取当前时间 减去在步骤1中获得的起始时间,来计算获取锁所花费的时间。当且仅当客户端能够在大多数实例(至少3个)中获取锁时,并且获取锁所花费的总时间小于锁有效时间(过期时间),则认为已获取锁。
  4. 如果获取了锁,则将锁有效时间减去 获取锁所花费的时间,获取剩余锁定时间(还剩多少时间锁过期)。
  5. 如果客户端由于某种原因(无法锁定N / 2 + 1个实例或有效时间为负)而未能获得该锁,它将尝试解锁所有实例(即使没有锁定成功的实例)。

值得强调的是,对于未能获得大多数锁的客户端,尽快释放(部分)获得的锁有多么重要,这样就不必等待锁定期满才能再次获得锁(但是,如果发生了网络分区,并且客户端不再能够与Redis实例进行通信,则在等待密钥到期时需要付出可用性损失)。

红锁算法的实现可以参考Redisson中的RedissonRedLock。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

流烟默

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值