Redis分布式锁!

使用分布式锁的目的,是保证同一时间只有一个客户端可以对共享资源进行操作。

使用SETNX实现:

SETNX key value,只在键Key不存在的情况下,将键Key的值设置为value。

若键key存在,则SETNX不做任何动作。

1
2
3
4
5
6
7
8
boolean result = jedis.setnx("lock-key",true)== 1L;
if (result) {
try {
// do something
} finally {
jedis.del("lock-key");
}
}

某个线程在获取锁之后由于某些异常因素(比如宕机)而不能正常的执行解锁操作,这个锁就永远释放不掉了。

  • 可以为这个锁加上一个超时时间。

SET key value EX seconds 的效果等同于执行 SETEX key seconds value

SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value

1
2
3
4
5
6
7
8
String result = jedis.set("lock-key",true, 5);
if ("OK".equals(result)) {
try {
// do something
} finally {
jedis.del("lock-key");
}
}

img

某线程A获取了锁设置了过期时间为10s,在执行业务逻辑时耗费了15s。

  • 此时线程A获取的锁被Redis的过期机制自动释放了。

在线程A获取锁并经过10s之后,改锁可能已经被其它线程获取到了。

  • 当线程A执行完业务逻辑准备解锁(DEL key)的时候,有可能删除掉的是其它线程已经获取到的锁。

所以在解锁时判断锁是否是自己的,可以在设置key的时候将value设置为一个唯一值uniqueValue

当解锁时,也就是删除key的时候先判断一下key对应的value是否等于先前设置的值,如果相等才能删除key。

1
2
3
4
5
6
7
8
9
10
11
12
String velue= String.valueOf(System.currentTimeMillis())
String result = jedis.set("lock-key",velue, 5);
if ("OK".equals(result)) {
try {
// do something
} finally {
//非原子操作
if(jedis.get("lock-key")==value){
jedis.del("lock-key");
}
}
}

GETDEL是两个分开的操作,在GET执行之后且在DEL执行之前的间隙是可能会发生异常的。

  • 只要保证解锁的代码是原子性的就能解决问题了。

由于Lua脚本的原子性,在Redis执行该脚本的过程中,其他客户端的命令都需要等待该Lua脚本执行完才能执行。

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

确保过期时间大于业务执行时间:

为了防止多个线程同时执行业务代码,需要确保过期时间大于业务执行时间。

  • 增加一个boolean类型的属性isOpenExpirationRenewal,用来标识是否开启定时刷新过期时间。

  • 增加一个scheduleExpirationRenewal方法用于开启刷新过期时间的线程。

加锁代码在获取锁成功后将isOpenExpirationRenewal置为true。

  • 并且调用scheduleExpirationRenewal方法,开启刷新过期时间的线程。

解锁代码增加一行代码,将isOpenExpirationRenewal属性置为false,停止刷新过期时间的线程轮询。

Redisson实现:

WatchdDog机制

获取锁成功就会开启一个定时任务,定时任务会定期检查去续期。

该定时调度每次调用的时间差是internalLockLeaseTime/3,也就10秒。

默认情况下,加锁的时间是30秒。

  • 如果加锁的业务没有执行完,那么到 30-10 = 20秒的时候,就会进行一次续期,把锁重置成30秒。

image

RedLock

在集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。

  • 原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。

然后从节点变成了主节点,这个新的节点内部没有这个锁。

  • 所以当另一个客户端过来请求加锁时,立即就批准了。

这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。

Redlock算法就是为了解决这个问题:

加锁时,它会向过半节点发送 set 指令,只要过半节点 set 成功,那就认为加锁成功。

释放锁时,需要向所有节点发送 del 指令。

不过 Redlock 算法还需要考虑出错重试、时钟漂移等很多细节问题。

  • 同时因为 Redlock 需要向多个节点进行读写,意味着相比单实例 Redis 性能会下降一些。

image

假设当前集群有 5 个节点,运行 Redlock 算法的客户端依次执行下面各个步骤,来完成获取锁的操作:

客户端记录当前系统时间,以毫秒为单位。

依次尝试从 5 个 Redis 实例中,使用相同的 key 获取锁。

当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时时间,超时时间应该小于锁的失效时间,避免因为网络故障出现的问题。

  • 客户端使用当前时间减去开始获取锁时间就得到了获取锁使用的时间。

当且仅当从半数以上的 Redis 节点获取到锁,并且当使用的时间小于锁失效时间时,锁才算获取成功。

如果获取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间,减少超时的几率。

如果获取锁失败,客户端应该在所有的 Redis 实例上进行解锁,即使是上一步操作请求失败的节点。

  • 防止因为服务端响应消息丢失,但是实际数据添加成功导致的不一致。

也就是说,假设锁30秒过期,三个节点加锁花了31秒,自然是加锁失败了。

在 Redis 官方推荐的 Java 客户端 Redisson 中,内置了对 RedLock 的实现。

RedLock问题:

RedLock 只是保证了锁的高可用性,并没有保证锁的正确性。

RedLock 是一个严重依赖系统时钟的分布式系统。