手写实现简易版本的Redission分布式锁

158 阅读7分钟

大家好,我是小趴菜,今天我们来一步一步的实现一个简易版本的Redission的分布式锁

我们从一个简单的扣减库存例子开始

public String deductStock(Integer id,Integer count){

    //查询商品库存数量
    Integer stockCount = stockMapper.selectStockById(id);

    //判断库存是否充足
    if(stockCount < count) {
        return "库存不足";
    }

    //更新库存
    stockMapper.updateStockById(id,stockCount - count);
    return "库存扣减成功";
}

如果在并发情况下,是有可能发生超卖的情况的,为了避免这种情况发生,我们一般采取的方案之一就是加锁

jvm锁

1:ReentrantLock

public String deductStockJdkLock(Integer id,Integer count){
    //获取锁
    reentrantLock.lock();
    try{
        //查询商品库存数量
        Integer stockCount = stockMapper.selectStockById(id);

        //判断库存是否充足
        if(stockCount < count) {
            return "库存不足";
        }

        //更新库存
        stockMapper.updateStockById(id,stockCount - count);
    }finally {
        reentrantLock.unlock();
    }
    return "库存扣减成功";
}

2:synchronized

public synchronized String deductStockSync(Integer id,Integer count){

    //查询商品库存数量
    Integer stockCount = stockMapper.selectStockById(id);

    //判断库存是否充足
    if(stockCount < count) {
        return "库存不足";
    }

    //更新库存
    stockMapper.updateStockById(id,stockCount - count);
    return "库存扣减成功";
}

以上就是基于JVM来实现的锁,使用非常简单,但是也有它的局限性,比如:

  • 1:在多例模式下,锁可能会失效(后续篇章解释)
  • 2:在事务模式下,也有可能出现超卖的现象(后续篇章解释)
  • 3:集群模式下会失效

以上两种锁简单,不是我们介绍的重点,大家可以忽略

基于MySql的悲观锁,乐观锁

public String deductStockMySqlPessimistic(Integer id,Integer count) {
    Integer result = stockMapper.updateStockByGoodsIdAndCount(id, count);
    if(result > 0) {
        return "库存扣减成功";
    }
    return "库存不足";
}
@Update("update t_stock set stock = stock - #{count} where id = #{id} and stock >= #{count}")
Integer updateStockByGoodsIdAndCount(@Param("id") Integer id, @Param("count") Integer count);

悲观锁主要基于数据库的行锁来实现,乐观锁也就是基于版本号来实现了,这里就不做展示了

基于Redis实现分布式锁

首先我们要了解分布式锁的几种特性

  • 1:互斥性
  • 2:可重入性
  • 3:死锁
  • 4:锁的正确释放
  • 5:阻塞和非阻塞
  • 6:公平和非公平

那Redis实现分布式锁的原理是怎么样的呢?

屏幕截图 2024-10-30 191152.png

如果这个key不存在,那么返回的就是null,所以我们可以在获取锁之前,判断这个key是否存在,如果存在,说明已经有其它线程获取到锁了,如果没有,那么就可以尝试去获取锁,Redis也提供了一个命令 setnx来实现

版本一:最简单版本

@Override
public void lock() {
   //1:使用setnx指令进行加锁
    while (true) {
        Boolean lockResult = stringRedisTemplate.opsForValue().setIfAbsent(this.lockName, "1");
        if(lockResult != null && lockResult) {
            //加锁成功
            break;
        }

        //获取锁失败,循环尝试获取锁
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            throw new RuntimeException();
        }
    }
}

@Override
public void unlock() {
    stringRedisTemplate.delete(this.lockName);
}

我们实现了一个最简单版本的一个分布式锁,但是会发现这个版本的存在很多问题,比如

  • 不满足可重入性
  • 存在死锁的可能

如果线程-A获取到锁之后,程序突然崩溃了,这时候没有执行释放锁的操作,那么就造成了死锁,因为我们没有给锁设置超时时间,所以即使程序恢复了,但是这把锁还是一直存在的,这时候其它线程就用员拿不到这把锁了

版本二:增加过期时间避免死锁

@Override
public void lock() {
    lock(defaultExpireTime,TimeUnit.SECONDS);
}

@Override
public void lock(long expireTime, TimeUnit timeUnit) {
    //1:使用setnx指令进行加锁
    while (true) {
        Boolean lockResult = stringRedisTemplate.opsForValue().setIfAbsent(this.lockName, "1",expireTime, timeUnit);
        if(lockResult != null && lockResult) {
            //加锁成功
            break;
        }

        //加锁失败,循环获取锁
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            throw new RuntimeException();
        }
    }
}

@Override
public void unlock() {
    stringRedisTemplate.delete(this.lockName);
}

版本二我们给锁加了超时时间,即使出现线程获取到锁之后,程序崩溃了,在到达超时时间之后也会主动释放这把锁,就不会造成死锁了,其它线程之后也能正常的获取到锁,但是版本二依然存在问题

  • 不满足可重入性
  • 无法正确释放锁

假设一下,我们给锁的超时时间设置为10秒,这时候业务执行的时间要30秒,这时候线程-A获取到锁了,然后开始执行业务逻辑,这时候到第10秒的时候,由于锁的超时时间到了,就主动释放掉这把锁了,那么其它线程就能获取到这把锁了,然后线程-A在30秒的时候业务执行完了,执行了释放锁的操作,但是这时候获取到锁的线程并不是线程-A了,也就是线程-A把其它线程的锁给释放了

1.png

版本三:增加UUID实现准确释放锁

@Override
public void lock() {
    lock(defaultExpireTime,TimeUnit.SECONDS);
}

@Override
public void lock(long expireTime, TimeUnit timeUnit) {
    //1:使用setnx指令进行加锁
    while (true) {
        //设置value的值为uuid
        Boolean lockResult = stringRedisTemplate.opsForValue().setIfAbsent(this.lockName, this.lockValue,expireTime, timeUnit);
        if(lockResult != null && lockResult) {
            //加锁成功
            break;
        }

        //加锁失败,循环获取锁
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            throw new RuntimeException();
        }
    }
}

@Override
public void unlock() {
   /**
     * 注意,这里查询+删除不是原子操作,现在线程一来查询,然后判断uuid相同,
     *  然后进入if分支里面去,但是此时,因为锁的超时时间到了,线程一自己释放了这把锁,也就在此时,线程二获取到了这把锁
     *  那么这时候线程一还是会执行delete操作,但是这时候删除的就是线程二的锁了,就造成了误删了
     */
    //判断当前持有锁的线程是否是本线程
    String lockValueResult = stringRedisTemplate.opsForValue().get(this.lockName);
    if(this.lockValue.equalsIgnoreCase(lockValueResult)) {
        //说明是当前线程
        stringRedisTemplate.delete(this.lockName);
    }
}

我们可以发现即使我们加了UUID也不能保证锁能准确的被释放,其实最主要的还是因为释放锁并不是原子性操作,所以接下来我们可以使用lua脚本来实现

版本四:redis+lua

@Override
public void lock() {
    lock(defaultExpireTime,TimeUnit.SECONDS);
}

@Override
public void lock(Long expireTime, TimeUnit timeUnit) {
    while (true) {
        //使用lua脚本加锁
        String luaLockScript = "if(redis.call('exists',KEYS[1]) == 0) then redis.call('set',KEYS[1],ARGV[1]) redis.call('pexpire',KEYS[1],ARGV[2]) return 1;else return 0;end;";
        Long lockResult = stringRedisTemplate.execute(new DefaultRedisScript<>(luaLockScript, Long.class),
                Collections.singletonList(this.lockName),
                this.lockValue,
                expireTime.toString());

        if(lockResult != null && lockResult.equals(1L)) {
            //加锁成功
            break;
        }

        //加锁失败,循环获取锁
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            throw new RuntimeException();
        }
    }
}

@Override
public void unlock() {
   //释放锁
    String luaUnLockScript = "if(redis.call('exists',KEYS[1]) == 0) then return 0;end;if(redis.call('get',KEYS[1]) == ARGV[1]) then redis.call('del', KEYS[1]) return 1;else return 0;end;";
    stringRedisTemplate.execute(new DefaultRedisScript<>(luaUnLockScript, Long.class),
            Collections.singletonList(this.lockName),
            this.lockValue);
}

使用redis+lua就可以锁的【互斥性】,【死锁】,【锁的正确释放】,但是它依然不具备锁的可重入性

版本五:redis+lua+可重入性

@Override
public void lock() {
    lock(defaultExpireTime,TimeUnit.SECONDS);
}

@Override
public void lock(Long expireTime, TimeUnit timeUnit) {
    while (true) {
        //使用lua脚本加锁
        String luaLockScript = "if(redis.call('exists',KEYS[1]) == 0) then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('pexpire',KEYS[1],ARGV[2]) return 1;end;if(redis.call('hexists',KEYS[1],ARGV[1]) == 1) then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('pexpire',KEYS[1],ARGV[2]) return 1;else return 0;end;";
        Long lockResult = stringRedisTemplate.execute(new DefaultRedisScript<>(luaLockScript, Long.class),
                Collections.singletonList(this.lockName),
                this.lockValue,
                expireTime.toString());

        if(lockResult != null && lockResult.equals(1L)) {
            //加锁成功
            break;
        }

        //加锁失败,循环获取锁
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            throw new RuntimeException();
        }
    }
}

@Override
public void unlock() {
    //释放锁
    String luaUnLockScript = "if(redis.call('hexists',KEYS[1],ARGV[1]) == 0) then return 0;end local lockCount = redis.call('hincrby',KEYS[1],ARGV[1],-1) if(lockCount > 0) then redis.call('pexpire',KEYS[1],ARGV[2]) return 1;else redis.call('del',KEYS[1]) return 1; end;";
    stringRedisTemplate.execute(new DefaultRedisScript<>(luaUnLockScript, Long.class),
            Collections.singletonList(this.lockName),
            this.lockValue,
            this.defaultExpireTime);
}

现在我们通过lua脚本实现了【互斥性】,【死锁】,【锁的正确释放】,【可重入锁】,但是还有一个很重要的问题,就是锁的续期时间,在获取锁的时候,我们没办法准确的去评估我们的业务的执行时间,所以需要在锁即将过期的时候,给锁进行一个续期

版本五:redis + lua + 可重入性 + 异步线程实现锁自动续期

@Override
public void lock() {
    lock(defaultExpireTime,TimeUnit.SECONDS);
}

@Override
public void lock(Long expireTime, TimeUnit timeUnit) {
    while (true) {
        //使用lua脚本加锁
        String luaLockScript = "if(redis.call('exists',KEYS[1]) == 0) then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('pexpire',KEYS[1],ARGV[2]) return 1;end;if(redis.call('hexists',KEYS[1],ARGV[1]) == 1) then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('pexpire',KEYS[1],ARGV[2]) return 1;else return 0;end;";
        Long lockResult = stringRedisTemplate.execute(new DefaultRedisScript<>(luaLockScript, Long.class),
                Collections.singletonList(this.lockName),
                this.lockValue,
                expireTime.toString());

        if(lockResult != null && lockResult.equals(1L)) {
            //加锁成功,实现锁自动延期
            new Thread(() -> {
                while (true) {
                    String expireLuaScript = "if(redis.call('exists',KEYS[1],ARGV[1]) == 0) then return 0;else redis.call('pexpire',KEYS[1],ARGV[2]) return 1;end";
                    Long expireResult = stringRedisTemplate.execute(new DefaultRedisScript<>(expireLuaScript, Long.class),
                            Collections.singletonList(this.lockName),
                            this.lockValue,
                            expireTime.toString());
                    if(expireResult == null || expireResult.equals(0L)) {
                        break;
                    }

                    try {
                        //在锁超时的一半就开始尝试续期
                        Thread.sleep(expireTime / 2);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }).start();
            break;
        }

        //加锁失败,循环获取锁
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            throw new RuntimeException();
        }
    }
}

@Override
public void unlock() {
    //释放锁
    String luaUnLockScript = "if(redis.call('hexists',KEYS[1],ARGV[1]) == 0) then return 0;end local lockCount = redis.call('hincrby',KEYS[1],ARGV[1],-1) if(lockCount > 0) then redis.call('pexpire',KEYS[1],ARGV[2]) return 1;else redis.call('del',KEYS[1]) return 1; end;";
    stringRedisTemplate.execute(new DefaultRedisScript<>(luaUnLockScript, Long.class),
            Collections.singletonList(this.lockName),
            this.lockValue,
            this.defaultExpireTime);
}

至此我们就完成了一个简易版本的Redission的分布式锁了,有兴趣的好兄弟可以关注下我的公众号:斌斌的小趴菜, 感谢大家