分布式锁常规总结

1,473 阅读6分钟

我正在参加「掘金·启航计划」

什么是分布式锁?

首先回顾下什么是锁吧,锁是解决多个线程同时操作同一个资源而导致数据不一致的一种解决方式。通常人们使用的锁是单机锁,如synchronized关键字和ReentrantLock锁,都只是同一个进程内的锁。而现在不少服务都会布置不止一个实例,所以导致单机锁根本没用了。所以就引入了分布式锁的概念。

分布式锁锁作用跟单机锁完全一样, 只是它通常需要借助第三方服务来实现。主流的第三方服务有redismysql

下面来一一讲解这两种实现方式和差异,以及个人认为的最好方式。

redis实现分布式锁

redis通常支持较高并发,且redis提供了一个原子命令,所以适合作为分布式锁,:

SET key value NX PX 30000

这条命令表示设置一个string类型的key-value到redis,30秒后自动过期,且仅当key不存在时才会设置成功。这就挺符合我们分布式锁的要求了,由于redis是单线程,我们可以在客户端进行此操作,会保证只有一个实例设置成功,不就代表加锁成功了吗?部分代码如下:

// 仅当key不存在才会设置成功,通常是将key设置为需要操作的资源唯一id,
// 例如,我们需要秒杀商品,key就设置为商品id
// 而 value一般设置为随机数,来保证释放锁的时候是当前线程持有。我这里使用【hutool】工具生成了16位随机字符串
// 过期时间也需要设置,因为如果该线程出现异常,就会导致资源无法释放,造成其他线程永远拿不到锁了
String randomString = RandomUtil.randomString(16);
//此方法会返回一个结果来表示是否操作成功
Boolean result = redisTemplate.opsForValue().setIfAbsent("productId", randomString);
//加锁成功
if (Boolean.TRUE.equals(result)) {
    try {
        // 业务处理
    } catch (Exception ignored) {
        // 回滚事务
    } finally {
        // 判断该锁是否当前线程持有,是才会释放
        if (redisTemplate.opsForValue().get("productId").equals(randomString)) {
            //提交事务
            //释放锁
            redisTemplate.delete("productId");
        } else {
            //业务执行时间过长导致锁自动失效,此时需要释放资源,如回滚事物等
        } 
    }
}
//加锁失败
else {
    // 可以返回服务器繁忙,请稍后再试之类的友好提示
}

关于事务的一些建议,通常来说,倘若当前方法涉及到2个或以上修改数据的操作,需要使用事务

通常来说,对于一般并发,上述方案完全够用,但是,它依然有些许缺陷:

  1. 判断锁是否为当前线程持有和释放锁操作不是原子操作,倘若,刚刚判断完锁是当前线程持有,下一秒就过期了,此时又被其他线程持有,那么不就会释放其他线程持有的锁了吗?
  2. 提交事物的时候也会有类似问题

针对这个问题,我们可以使用lua脚本来进行释放锁的原子操作:

//释放锁的时候判断了锁是否当前线程持有
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

lua脚本能够保证我们判断value是否与我们预期相等,只有相等时才会释放资源。并且这是一个原子操作。

但是,这样看起来很美好,可以解决上述问题1,不过问题2依然无法得到解决,因为事务与redis操作不是原子操作。lua脚本很明显无法做到帮我们提交mysql事务,那么应该怎么办呢?

下面就推荐大名鼎鼎的redisson,它是一个redis客户端,主要支持一些分布式相关的工具,其中就有分布式锁。

说白了,上述两个问题,我们已经解决了其中一个了,采用了lua脚本解决,而另外一个问题,根本原因是因为自动过期时间设置多大问题。

那么,我们应该设置多长时间呢?

首先我们应该尽可能的设置一个保证在业务能够正常执行结束的范围。

但是,其实不管我们设置多少,理论上来说都不合适,因为你无法保证业务代码执行的具体时间。倘若设置小了,导致业务执行结束后锁过期,还要额外进行回滚操作,设置大了,可能导致其他线程阻塞时间过长。所以,这个时间怎么设置都不好使,总会有瑕疵。

redisson采用了看门狗设置,也就是会起一个守护线程,来监测这个线程是否释放锁,如果此线程一直在活动,且过期时间快要结束,看门狗机制就会自动续期。

所以,看门狗机制保证了,线程持有锁后,只要线程还在活跃,且锁未释放,锁会永不过期,有人看到这里可能会怀疑了,永不过期? ,那么岂不是跟没设置过期时间没啥两样,哈哈,并不是,我说的只是理论上永不过期,实际上我们的代码终究会执行结束(除非写了死循环)。

redisson释放锁的时候同样采用了lua脚本的方式判断是否当前锁持有。

最佳实践代码如下:

适合并发一般的情况:

RLock lock = redissonClient.getLock("productId");
//会一直阻塞去获取锁,直到成功,默认30秒过期,会自动续期
lock.lock();
try {
    //执行业务代码
    //正常执行,然后提交事务,这里锁会一直续期,所以不用担心锁会自动过期
} catch (Exception ignored) {
    //异常,回滚事务
} finally {
    //释放锁,需要判断一下是否当前线程持有
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

适合并发较高的情况:

RLock lock = redissonClient.getLock("productId");
//会一直阻塞去获取锁,直到成功,但5秒还未获取锁,会返回结果,锁默认30秒过期,会自动续期
// ture代表获取锁成功,否则失败
if (lock.tryLock(5, TimeUnit.SECONDS)) {
    try {
        //执行业务代码
        //正常执行,然后提交事务,这里锁会一直续期,所以不用担心锁会自动过期导致事务提交后才过期
    } catch (Exception ignored) {
        //异常,回滚事务
    } finally {
        //释放锁,需要判断一下是否当前线程持有
        //注意:这里如果不判断也是可以的,只不过会抛出异常
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
} else {
    // 可以返回服务器繁忙,请稍后再试之类的友好提示
}

关于redisson的看门狗失效情况:

// 没有Watch Dog ,10s后锁释放
lock.lock(10, TimeUnit.SECONDS);
// 没有Watch Dog ,10s后锁释放,尝试获取100s
lock.tryLock(100, 10, TimeUnit.SECONDS);

redisson默认锁过期时间为30s,只要设置了过期时间,看门狗机制就会失效

MYSQL实现分布式锁

mysql实现分布式锁的方式最为简单,我们可以利用mysql主键唯一的性质,将新增数据这一动作的成功与否作为获取锁的结果。对于实现自动过期。我们可以增加字段来实现,增加一个过期时间字段和创建时间字段。

释放锁就是删除数据即可,如果锁支持自动失效,需要在释放锁时添加相应条件以防止释放锁时刚好自动失效。