基于Redis的分布式锁

145 阅读12分钟

当有多个客户端需要获取锁时,我们需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。

实现锁的思路:

使用一个变量来去表示锁, 变量值为 0 表示没有线程获取到锁 变量值为1表示有线程获取到锁了

一个线程调用加锁操作,其实就是检查锁变量值是否为 0。如果是 0,就把锁的变量值设置为 1,表示获取到锁,如果不是 0,就返回错误信息,表示加锁失败,已经有别的线程获取到锁了。而一个线程调用释放锁操作,其实就是将锁变量的值置为 0,以便其它线程可以来获取锁。

在实现分布式锁时需要保证 锁操作的原子性和 共享存储系统的高可用

基于单Redis节点实现分布式锁

Redis 可以使用一个键值对 lock_key:0 来保存锁变量,其中,键是 lock_key,也是锁变量的名称,锁变量的初始值是 0。

在图中,客户端 A 和 C 同时请求加锁。因为 Redis 使用单线程处理请求,所以,即使客户端 A 和 C 同时把加锁请求发给了 Redis,Redis 也会串行处理它们的请求。我们假设 Redis 先处理客户端 A 的请求,读取 lock_key 的值,发现 lock_key 为 0,所以,Redis 就把 lock_key 的 value 置为 1,表示已经加锁了。紧接着,Redis 处理客户端C 的请求,此时,Redis 会发现 lock_key 的值已经为 1 了,所以就返回加锁失败的信息。

释放锁

当客户端A 持有锁时,锁变量 lock_key 的值为 1。客户端 A 执行释放锁操作后,Redis 将lock_key 的值置为 0,表明已经没有客户端持有锁了。

加锁过程原子性

因为加锁包含了三个操作(读取锁变量、判断锁变量值以及把锁变量值设置为 1),而这

三个操作在执行时需要保证原子性。那怎么保证原子性呢?

要想保证操作的原子性,有两种通用的方法

  • 使用 Redis 的单命令
  • 使用 Lua 脚本。

单命令

SETNX 命令,它用于设置键值对的值。具体来说,就是这个命令在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。

对于释放锁操作来说,我们可以在执行完业务逻辑后,使用 DEL 命令删除锁变量。不过,你不用担心锁变量被删除后,其他客户端无法请求加锁了。因为 SETNX 命令在执行时,如果要设置的键值对(也就是锁变量)不存在,SETNX 命令会先创建键值对,然后设置它的值。所以,释放锁之后,再有客户端请求加锁时,SETNX 命令会创建保存锁变量的键值对,并设置锁变量的值,完成加锁。

总结来说,我们就可以用 SETNX 和 DEL 命令组合来实现加锁和释放锁操作

// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock
key

单命令的隐患

风险一: 死锁 由于发生异常无法释放锁 可以加超时时间 自动释放 由于setnx和设置超时时间不能进行原子操作,推荐使用SET set是原子的 也可以锁续期 Redisson中的看门狗

风险二: 释放了别人的锁, 需要区分这个锁属不属于自己的 可以使用set命令来给锁加上唯一标识.来标定每个客户端 只有当唯一标识一致时才可释放,具体命令如下:

// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000

unique_value 是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX

10000 则表示 lock_key 会在 10s 后过期,以免客户端在这期间发生异常而无法释放锁。

基于多Redis节点的分布式锁

多节点主要是为了提升Redis的可用性,由于分布式锁是基于redis实现的,当redis宕机势必影响,业务的正常执行

在主从模式或者哨兵模式下,正常情况下,如果加锁成功了,那么master节点会异步复制给对应的slave节点。但是如果在这个过程中发生master节点宕机,主备切换,slave节点从变为了 master节点,而锁还没从旧master节点同步过来,这就发生了锁丢失。

当我们要实现高可靠的分布式锁时,就不能只依赖单个的命令操作了,我们需要按照一定的步骤和规则进行加解锁操作,否则,就可能会出现锁无法工作的情况。“一定的步骤和规则”是指啥呢?其实就是分布式锁的算法。

RedLock

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。

RedLock算法执行步骤:

1.客户端获取当前时间

2.客户端按顺序依次向 N 个 Redis 实例执行加锁操作。

使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。当然,如果某个 Redis 实例发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给加锁操作设置一个超时时间。

如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。

比如:TTL为5s,设置获取锁最多用1s,所以如果一秒内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁

3.一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

客户端只有在满足下面的这两个条件时,才能认为是加锁成功。

条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;

条件二:客户端获取锁的总耗时没有超过锁的有效时间。

在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

比如:超时时间是5s,获取所有锁用了2s,则真正锁有效时间为3s 如果设置1s的超时时间就会出问题了

当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有 Redis 节点发起释放锁的操作。

在 Redlock 算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的Lua 脚本就可以了。这样一来,只要 N 个 Redis 实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。

RedLock存在的问题及解决方法

由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。前面我们说的主从架构下存在的安全性问题,在RedLock中已经不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的,具体的影响程度跟Redis持久化配置有关:

(1)如果redis没有持久化功能,在clientA获取锁成功后,所有redis重启,clientB能够再次获取到锁,这样违法了锁的排他互斥性;

(2)如果启动AOF永久化存储,事情会好些, 举例:当我们重启redis后,由于redis过期机制是按照unix时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务;但是由于AOF同步到磁盘的方式默认是每秒一次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效;但如果同步磁盘方式使用Always(每一个写命令都同步到硬盘)造成性能急剧下降;所以在锁完全有效性和性能方面要有所取舍;

(3)为了有效解决既保证锁完全有效性 和 性能高效问题:antirez又提出了“延迟重启”的概念,redis同步到磁盘方式保持默认的每秒1次,在redis崩溃单机后(无论是一个还是所有),先不立即重启它,而是等待TTL时间后再重启,这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响,缺点是在TTL时间内服务相当于暂停状态;

Redisson中的Redlock实现

在Java的redisson包已经实现了对RedLock的封装,主要是通过 redisClient 与 lua 脚本实现的,之所以用 lua 脚本,是为了实现加解锁校验与执行的事务性。

为了能够让作为中心节点的存储节点获取锁的持有者,从而避免锁被非持有者误解锁,每个发起请求的 client 节点都必须具有全局唯一的 id。通常我们是使用 UUID 来作为这个唯一 id,redisson 也是这样实现的,在此基础上,redisson 还加入了 threadid 避免了多个线程反复获取 UUID 的性能损耗

protected final UUID id = UUID.randomUUID();
String getLockName(long threadId) {
	return id + ":" + threadId;
}

加锁

redisson 加锁的核心代码非常容易理解,通过传入 TTL 与唯一 id,实现一段时间的加锁请求。下面是可重入锁的实现逻辑:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) 
{
	internalLockLeaseTime = unit.toMillis(leaseTime);
 
	// 获取锁时向5个redis实例发送的命令
	return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
			  // 校验分布式锁的KEY是否已存在,如果不存在,那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1),并通过pexpire设置失效时间(也是锁的租约时间)
			  "if (redis.call('exists', KEYS[1]) == 0) then " +
				  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
				  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
				  "return nil; " +
			  "end; " +
			  // 如果分布式锁的KEY已存在,则校验唯一 id,如果唯一 id 匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间
			  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
				  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
				  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
				  "return nil; " +
			  "end; " +
			  // 获取分布式锁的KEY的失效时间毫秒数
			  "return redis.call('pttl', KEYS[1]);",
			  // KEYS[1] 对应分布式锁的 key;ARGV[1] 对应 TTL;ARGV[2] 对应唯一 id
				Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

释放锁

protected RFuture<Boolean> unlockInnerAsync(long threadId) 
{
	// 向5个redis实例都执行如下命令
	return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
			// 如果分布式锁 KEY 不存在,那么向 channel 发布一条消息
			"if (redis.call('exists', KEYS[1]) == 0) then " +
				"redis.call('publish', KEYS[2], ARGV[1]); " +
				"return 1; " +
			"end;" +
			// 如果分布式锁存在,但是唯一 id 不匹配,表示锁已经被占用
			"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
				"return nil;" +
			"end; " +
			// 如果就是当前线程占有分布式锁,那么将重入次数减 1
			"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
			// 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,不删除
			"if (counter > 0) then " +
				"redis.call('pexpire', KEYS[1], ARGV[2]); " +
				"return 0; " +
			"else " +
				// 重入次数减1后的值如果为0,则删除锁,并发布解锁消息
				"redis.call('del', KEYS[1]); " +
				"redis.call('publish', KEYS[2], ARGV[1]); " +
				"return 1; "+
			"end; " +
			"return nil;",
			// KEYS[1] 表示锁的 key,KEYS[2] 表示 channel name,ARGV[1] 表示解锁消息,ARGV[2] 表示 TTL,ARGV[3] 表示唯一 id
			Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}

Redisson中Redlock的使用

Config config = new Config();
config.useSentinelServers()
        .addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
		.setMasterName("masterName")
		.setPassword("password").setDatabase(0);
 
RedissonClient redissonClient = Redisson.create(config);
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
 
try {
    // 尝试加锁,最多等待500ms,上锁以后10s自动解锁
	boolean isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
	if (isLock) {
		//获取锁成功,执行对应的业务逻辑
	}
} catch (Exception e) {
    e.printStackTrace();
} finally {
	redLock.unlock();
}

redisson 包的实现中,通过 lua 脚本校验了解锁时的 client 身份,所以我们无需再在 finally 中去判断是否加锁成功,也无需做额外的身份校验,可以说已经达到开箱即用的程度了。

基于RedLock实现的分布式锁也存在 client 获取锁之后,在 超时时间内没有完成业务逻辑的处理,而此时锁会被自动释放

Redisson中的看门狗机制

原理

redisson在获取锁之后,会维护一个后台线程,当锁即将过期还没有释放时,不断的延长锁key的生存时间

加锁

线程去获取锁,获取成功:执行lua脚本,保存数据到redis数据库。

线程去获取锁,获取失败:一直通过while循环尝试获取锁,获取成功后,执行lua脚本,保存数据到redi数据库。

自动续期

看门狗机制对整体性能有一定影响,默认是不开启的,如果使用了看门狗并且又设置了超时时间,自动续期失效

看门狗机制默认超时时间是30s,会在超时时间的1/3处检查业务是否执行完成,若没完成,则会续期一次,将锁重置为30s,保证解锁前锁不会自动失效,默认超时时间可以通过lockWactchdogTimeout 参数来修改

那万一业务的机器宕机了呢?如果宕机了,那看门狗线程就执行不了了,就续不了期,那自然30秒之后锁就解开了

Redisson文档地址github.com/redisson/re…