一、分布式锁的使用场景

1、描述一个分布式锁使用的场景

  • 在电商购物场景中,某个用户选择了一件商品(X商品),然后他点击下单,这时候会为该用户对该商品生产一个订单(xxx-order),并且预占一个该商品的库存(也就是将该商品的库存数量减一),该订单的状态是等待支付(或未支付)。
  • 此时用户可选择去支付或者在下单界面等待一直不去支付
  • 如果用户选择支付,则支付完成后修改订单状态为已支付。下单流程完成。
  • 如果用户一直在下单界面等待,不去支付,则走下面流程
  • 当订单超过规定的时间不支付的话(比如默认5分钟)我们不能一直保留这个订单(因为这个订单占用了一个库存),我们必须将这个订单关闭,并且将预占的库存释放掉(这样别才能购买到这件商品)。
  • 一般我们都是采用定时任务轮询,定期的去捞取超过规定时间且没有支付的订单,将这些订单的状态改成已关闭(或已过期),并且释放库存(安全性问题产生了)。
  • 在分布式系统中我们为了保证高可用性,一个服务一般都会部署多个实例,所以有可能多个实例的定时任务都会去执行关闭订单释放库存的操作。
  • 比如:我们有个专门释放库存的服务,该服务部署了两个实例(A和B),设置每10秒执行一次。
  • A实例开始捞取超过五分钟还未支付完成的订单数据,执行关闭xxx-order订单、释放X商品的操作,
  • 这时候B实例的定时任务也开始执行了,B实例开始捞取超过5分钟还没支付完成的订单,这时候仍然捞取到了xxx-order订单,然后开始对该订单进行关闭,并且恢复X商品的库存。
  • 那么,就会造成xxx-order被关闭了两次,库存被释放了两次(原本只应该被释放一次)。
  • 所以导致了数据安全问题的发生。
  • 关于以上场景我们就需要用分布式锁去解决问题。

二、分布式锁应该具备的基本特性

  1. 多进程可见(必须):多进程可见,否则就无法实现分布式锁的效果
  2. 互斥(必须):同一时刻只能有一个进程获取到锁,执行任务后释放锁
  3. 可重入(可选):同一个任务再次获取该锁时不会被死锁
  4. 阻塞锁(可选):获取锁失败时,具备重试机制,尝试再次获取锁
  5. 性能好(可选):效率高,应对高并发场景
  6. 高可用(可选):避免锁服务宕机或处理好宕机的补救措施

三、如何实现分布式锁

I)、实现方式一(setnx + del命令)

最简单的实现方式就是通过setnx + del 命令来实现加锁和解锁,但这种实现方式会有很多问题,但作为探究的开始我们通过分析每一种方式的优缺点,慢慢深入,然后找出最佳的实现方案。

1、通过setnx + del命令来加锁释放锁

  • 获取锁:setnx key value
  • 释放锁:del key

redis set 分布式锁 redis分布式锁的使用_redis set 分布式锁

2、存在的问题

①、死锁
  • 当一个客户端A获取到锁后,然后A客户端挂了,导致该锁一直被占有,不能被释放,形成死锁。

redis set 分布式锁 redis分布式锁的使用_redis set 分布式锁_02

②、主从模式下产生多个客户端获取一把锁成功
  • 在主从模式下,当A客户端发起获取锁的请求(setnx key value)并且成功获取到锁,此时该锁信息还没同步到slave节点,恰巧master节点挂掉了。
  • 然后其中一个slave被选举为新的master(此时该master上没有A客户端的加锁信息)
  • 此时B客户端来获取锁了(setnx key value),发现新的master上没有任何客户端进行加锁,此时B客户端顺利获得到锁。
  • 这种情况下,A客户端和B客户端都获取到了同一把锁(违背了互斥性)。
  • redis set 分布式锁 redis分布式锁的使用_redis set 分布式锁_03

③、锁误删问题
  • 在②问题的条件下,如果A客户端先执行完了,开始发起释放锁的请求(执行del key命令),那么这时候A客户端的解锁操作会将B客户端所加的锁给释放了。

3、问题解决

  • 先解决上面的问题①和问题③,问题②留在后面统一讨论。
  • 关于问题①,我们可以为加锁的key设置过期时间。这样即使持有锁的客户端挂掉了,到了指定的过期时间后锁自动释放,不会造成死锁。这样便引出了下面的实现方式二
  • 关于问题③,我们只需要在获取锁的时候(setnx key value),在value里设置一个全局唯一的ID代表客户端身份,每次释放锁前先检查value中的这个全局唯一ID是不是自己的,如果是才释放,否则释放锁失败。

问题①解决方式:

为了保证这两条命令的原子性,需要使用lua脚本来执行如下命令

  • setnx key value
  • setnx 表示,没有这个key才才能设置value成功。
  • expire key

**注意:**需要通过lua脚本保证这两条命令的原子性。不然会导致如下问题:

1、当A线程首先执行setnx key value成功获取到锁,但是还没来得及执行第二条命令expire key的时候突然A线程所在的服务挂掉了,那么也会导致锁一直被A线程占有,形成死锁。

2、A线程先执行setnx key value,但并没有获取锁成功(假如此时锁被B线程持有),然后执行expire key命令,设置key的过期时间,这时候就会将原来的过期时间延长。

II)、实现方式二(set nx ex命令)

关于上面的实现方式一中的对于死锁问题的解决方案,我们需要用lua脚本去保证加锁命令和添加过期时间的命令的原子性。其实在Redis中已经为我们提供了这样的命令,通过一条命令来执行加锁和设置过期时间的操作,该命令就是 set nx ex

1、set nx ex命令实现分布式锁

Redis提供set nx ex命令来保证加锁和设置过期时间的原子性,使用set nx ex命令就可以避免上面的两条命令的非原子性问题

  • 获取锁:set key value nx ex 10 这条命令nx表示没有才设置,ex表示过期时间
  • eg:set test-lock value-xxx nx ex 10
  • 释放锁:del key
  • eg:del lock

redis set 分布式锁 redis分布式锁的使用_redis set 分布式锁_04

2、存在的问题

①、锁自动释放问题
  • 假如现在A客户端获取到了锁,正在执行任务,但是任务并没有执行完(由于GC或系统卡顿或业务流程耗时较长导致),但是此时A客户端持有的锁过期时间到了。
  • 此时B客户端来抢锁并且抢到锁了(这时的情况就是:A客户端B客户端同时持有锁,不能保证锁的互斥性了)。

redis set 分布式锁 redis分布式锁的使用_redis_05

②、误删锁问题
  • 在基于问题①的情况下,A客户端还没执行完任务,但锁的过期时间到了,导致B客户端也获取到了锁。
  • 这时候A客户端任务执行完毕了,A线程开始执行del lock 命令释放锁,结果A线程就会把B线程持有的锁给释放掉了。
③、主从模式下产生多个客户端获取一把锁成功
  • 和上面实现方式一的问题原因一样!最后讨论。

3、问题解决

  • 关于问题①,使用看门狗(watch dog)解决锁自动过期的问题。
  • 在加锁成功后同时启动一个定时任务,该定时任务每隔一定的时间(redisson中默认是锁过期时间的 1/3)就执行一次,去检查一下持有锁的客户端的任务是否执行完毕。
  • 若没有执行完毕则延长该锁的过期时间
  • 若已经执行完了,则该定时任务会自动关闭(不然会对系统造成很大压力)

redis set 分布式锁 redis分布式锁的使用_redis_06

  • 关于问题②,设置锁的value的时候往value里给个唯一标识。删除锁的时候通过唯一标识判断一下,是自己的锁才释放。
  • 关于问题③,留在后面讨论

III)、实现方式三(可重入锁实现)

1、可重入锁分析

要实现可重入锁,那么一定是要记住每一个客户端的唯一ID,并且需要记录锁被重入的次数(解锁时用)。

总结一下:要实现可重入锁我们需要做到以下几点

  • set key value的时候需要在value中存储客户端唯一ID
  • 需要存放锁被重入的次数

经过上面分析,我们知道了Redis的string类型已经不能满足我们可重入锁的需求了,此时我们需要选择一个合适的数据类型。这不正好就是Redis的hash结构吗。所以我们需要使用Redis的hash结构来完成可重入锁的实现。

redis set 分布式锁 redis分布式锁的使用_客户端_07

2、什么时候会用到可重入分布式锁的场景

使用的场景比较少,比如某些递归方法,或在方法调用中,被调用的方法中还会再次获取该锁的情况。

3、加锁

redis set 分布式锁 redis分布式锁的使用_redis_08

①、加锁流程
  1. 判断锁是否存在:exists key
  2. 如果不存在,则说明此时还没有人持有锁,于是自己开始加锁hset key threadId 1
  3. 如果存在,判断锁是否是自己持有的hexists key threadId
  1. 如果是自己持有的锁,则将重入次数加一:hincrby key threadId 1
  2. 如果不是自己持有的,则表示此次获取锁失败。
  1. 加锁成功,设置锁的过期时间:expire key seconds

redis set 分布式锁 redis分布式锁的使用_加锁_09

②、加锁lua脚本如下
-- 获取外部传递的加锁参数
local key = KEYS[1]
local threadId = ARGV[1]
local releaseTime = ARGV[2]
-- 判断该锁是否存在
if(redis.call("exists", key)==0) then
  -- 如果不存在,则通过hset命令加锁
  redis.call('hset', key, threadId, '1')
  -- 设置锁的过期时间
  redis.call('expire', key, threadId, releaseTime)
  return 1
end;
-- 如果存在,则判断锁是否是自己持有的(重入)
if(redis.call('hexists', key, threadId) == 1) then
  -- 如果该锁是自己持有的,则将锁的重入次数加一
  redis.call('hincrby', key, threadId, '1')
  -- 重置锁过期时间
  redis.call('expire', key, threadId, releaseTime)
  return 1
end
return 0

4、解锁

①、解锁流程
  1. 锁是否存在:exists key
  2. 如果锁存在(上面加锁流程保证了互斥性 + watch dog以后这里锁不会被别人释放),则将重入次数减一:hincrby key thread-01 1
  3. 判断重入次数减一后的结果是否是0
  1. 如果是则可以删除key了(说明锁已经被释放完了)
  2. 如果不是,则不删除key(重入锁还没有被释放完)

redis set 分布式锁 redis分布式锁的使用_加锁_10

②、释放锁lua脚本如下
-- 获取外部传递的解锁参数
local key = KEYS[1]
local threadId = ARGV[1]
local releaseTime = ARGV[2]
-- 判断当前锁是否自己持有
if(redis.call('hexists', key, threadId) == 0) then
  -- 不是自己持有则直接返回
  return nil; 
end;
-- 是自己的锁,则重入次数减一
local count = redis.call('hincrby', key, threadId, -1);

-- 判断重入次数是否已经为0
if(count > 0) then
  -- 如果大于0,则不能释放锁,重置有效期然后返回
  redis.call('expire', key, releaseTime);
  return nil;
else
  -- 等于0,则说明可以直接删除,释放锁
  redis.call('del', key)
  return nil;
end;

5、加锁解锁流程总结

redis set 分布式锁 redis分布式锁的使用_redis set 分布式锁_11

6、存在的问题

①、锁到期自动释放问题

②、锁误删问题

③、主从模式下产生多个客户端获取一把锁成功问题

和上面实现方式二存在的问题一样!!!

7、Java代码自己实现分布式锁

上面介绍了几种使用redis + lua命令来实现分布式锁的方案,但并没有用Java代码去体现,这里选取一种最复杂的(可重入锁)使用Java代码去简单实现一下。基于redisson框架去自己封装分布式锁的内容,下节记录。

①、项目结构如下

gitee仓库地址:https://gitee.com/mr_wenpan/stone-monster-backend/tree/master

redis set 分布式锁 redis分布式锁的使用_客户端_12

②、定义锁接口用于获取和释放锁
public interface RedisLock {

    /**
     * 尝试获取锁
     *
     * @return boolean
     * @author Mr_wenpan@163.com 2021/7/24 3:39 下午
     */
    boolean tryLock();

    /**
     * 释放锁
     *
     * @return void
     * @author Mr_wenpan@163.com 2021/7/23 10:51 下午
     */
    void unlock();
}
③、锁实现类
@Slf4j
@Component
public class ReentrantRedisLock implements RedisLock {

    private static final DefaultRedisScript<Long> LOCK_SCRIPT;

    private static final DefaultRedisScript<Object> UNLOCK_SCRIPT;

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 锁信息
     */
    private LockInfo lockInfo;

    public ReentrantRedisLock(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    static {
        // 加载获取锁的脚本
        LOCK_SCRIPT = new DefaultRedisScript<>();
        LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("script.lua/lock.lua")));
        LOCK_SCRIPT.setResultType(Long.class);

        // 加载释放锁的脚本
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("script.lua/unlock.lua")));
    }

    @Override
    public boolean tryLock() {
        if (Objects.isNull(lockInfo)) {
            log.warn("请设置锁信息再获取锁.");
            return false;
        }
        if (Objects.isNull(lockInfo.getLeaseTime())) {
            log.error("必须要设置锁的过期释放时间,以免造成死锁.");
            return false;
        }
        // 设置获取锁的线程的唯一标识,便于后面重入解锁
        lockInfo.setThreadUniqueIdentifier(UUID.randomUUID().toString() + Thread.currentThread().getId());
        Long result = stringRedisTemplate.execute(LOCK_SCRIPT, Collections.singletonList(lockInfo.getName()),
                lockInfo.getThreadUniqueIdentifier(), String.valueOf(lockInfo.getLeaseTime()));
        System.out.println("result=" + result);
        return result != null && result.intValue() == 1;
    }

    @Override
    public void unlock() {
        log.info("释放锁:{},线程唯一ID:{}", lockInfo.getName(), lockInfo.getThreadUniqueIdentifier());
        stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(lockInfo.getName()),
                lockInfo.getThreadUniqueIdentifier());
    }

    public void setLockInfo(LockInfo lockInfo) {
        this.lockInfo = lockInfo;
    }
}
④、锁测试类
@Slf4j
@Component
public class ClearOrderTask {

    @Autowired
    private ReentrantRedisLock reentrantRedisLock;

    @Scheduled(cron = "0/10 * * ? * *")
    public void clearOrderTask2() throws InterruptedException {
        LockInfo lockInfo = LockInfo.builder().name("test-lock").leaseTime(100).build();
        reentrantRedisLock.setLockInfo(lockInfo);
        boolean isLock = reentrantRedisLock.tryLock();

        if (!isLock) {
            log.error("获取锁失败了,定时任务执行结束.....");
            return;
        }
        try {
            // 获取到锁,开始执行作废订单恢复预减库存操作
            log.info("获取锁成功,开始作废订单,恢复预减库存.....");
            clearOrder();
        } finally {
            log.warn("作废订单,恢复预减库存完毕,开始释放锁......");
            reentrantRedisLock.unlock();
        }

    }

    public void clearOrder() throws InterruptedException {
        log.info("捞取到时间过期订单数据,开始作废订单......");
        TimeUnit.SECONDS.sleep(2);
        log.info("订单作废完毕,开始恢复预减库存.......");
    }
}
⑤、加锁解锁lua脚本

加锁脚本

local key = KEYS[1]
local threadId = ARGV[1]
local releaseTime = ARGV[2]
if(redis.call('exists', key)==0) then
  redis.call('hset', key, threadId, 1)
  redis.call('expire', key, releaseTime)
  return 1
end
if(redis.call('hexists', key, threadId)==1) then
  redis.call('hincrby', key, threadId, 1)
  redis.call('expire', key, releaseTime)
  return 1
end
return 0

解锁脚本

local key = KEYS[1]
local threadId = ARGV[1]
local releaseTime = ARGV[2]
-- 判断当前锁是否自己持有
if(redis.call('hexists', key, threadId) == 0) then
  -- 不是自己持有则直接返回
  return nil;
end;
-- 是自己的锁,则重入次数减一
local count = redis.call('hincrby', key, threadId, -1);

-- 判断重入次数是否已经为0
if(count > 0) then
  -- 如果大于0,则不能释放锁,重置有效期然后返回
  redis.call('expire', key, releaseTime);
  return nil;
else
  -- 等于0,则说明可以直接删除,释放锁
  redis.call('del', key)
  return nil;
end;
⑥、启动了类
@EnableScheduling
@ComponentScan(value = {"com.stone.lock"})
@SpringBootApplication
@EnableConfigurationProperties
public class StoneRedisLockTestApplication {
    public static void main(String[] args) {
        SpringApplication.run(StoneRedisLockTestApplication.class, args);
    }
}

四、总结

1、回顾

上面我们分别通过setnx key value命令set nx ex命令以及hset key命令(重入锁)简单实现了一下分布式锁,以及各种方式的优缺点分析。我们再次回顾一下分布式锁应该具备的哪些特征

  1. 多进程可见(必须):多进程可见,否则就无法实现分布式锁的效果
  2. 互斥(必须):同一时刻只能有一个进程获取到锁,执行任务后释放锁
  3. 可重入(可选):同一个任务再次获取该锁时不会被死锁
  4. 阻塞锁(可选):获取锁失败时,具备重试机制,尝试再次获取锁
  5. 性能好(可选):效率高,应对高并发场景
  6. 高可用(可选):避免锁服务宕机或处理好宕机的补救措施

2、目前已经实现特性

  1. 多进程可见(每个服务都向Redis去获取锁,且该锁是否存在对应每个进程都是可见的)
  2. 互斥(由Redis的特性(setnxhset)帮我们保证了锁的互斥性)
  3. 可重入(我们使用Redis的hash结构来记录线程唯一ID和重入次数来实现了可重入锁)

3、目前还未实现的特性以及实现方案

①、还未实现的特性

  1. 阻塞锁(当锁被一个客户端占有,其他客户端获取不到锁的时候,其他客户端不能一直疯狂的发起请求来获取锁,这样系统开销很高,而是在获取不到锁的时候进行适当的休眠或等待,当锁释放的时候再通知这些等待的客户端重新争抢锁)
  2. 性能好(需要基于Redis的高性能)
  3. 高可用(需要保证即使一台Redis服务器挂了,也不会影响客户端获取锁。不然的话当Redis挂了便会导致某些业务瘫痪,这是不可取的)

②、如何实现

I)、阻塞锁如何实现

方式一(不推荐)、获取锁失败后重试一定次数,适量休眠。

方式二(推荐)、基于Redis的pubsub发布订阅模式,获取锁失败时去订阅一个频道,自己阻塞。如果锁被释放了会发布一个通知,等收到了这个通知后再发起锁争抢。

II)、性能好如何实现

Redis本身性能就比较高,我们不用考虑太多。

III)、高可用如何实现

单机版Redis无法保证高可用,我们一般都会搭建Redis主从集群来保证Redis高可用。

但是主从模式对应分布式锁会有安全问题,如下:

  • 客户端A从master获取到锁
  • 在master将锁同步到slave之前,master宕机了
  • slave节点被晋升为master节点
  • 客户端B取得了同一个资源被客户端A已经获取到的锁,安全失效。

如何解决?

Redis作者给出的解决方案红锁(redlock),但是该方案仍然存在一定的安全问题,在网上的争议也比较大,redlock有什么问题呢?

参考:https://mp.weixin.qq.com/s/p0-tIRmpjdMqkG9hJYfrdw

五、Redis做分布式锁和zookeeper做分布式锁比较

1、redis作为分布式锁的优缺点

优点:实现简单,性能好,并发能力强,如果对并发能力有要求,推荐使用

缺点:可靠性有争议,极端情况会出现锁失效问题,如果对安全要求较高,不推荐使用(推荐使用zookeeper去实现)。

2、zookeeper实现分布式锁的优缺点

优点:实现起来更简单,安全问题最可靠

缺点:zookeeper保证强一致性,虽然说安全问题做的非常好,但是性能上比Redis略差一点。

六、参考文章

https://mp.weixin.qq.com/s/p0-tIRmpjdMqkG9hJYfrdw