0%

redis分布式锁的实现

1. 分布式锁

1.1 特点

  • 互斥性: 同一时刻只能有一个线程持有锁
  • 可重入性: 同一节点上的同一个线程如果获取了锁之后能够再次获取锁
  • 锁超时:和J.U.C中的锁一样支持锁超时,防止死锁
  • 高性能和高可用: 加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
  • 具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒

1.2 实现方式

我们一般实现分布式锁有以下几种方式:

  • 基于数据库
  • 基于Redis
  • 基于zookeeper

2. Redis实现方案

2.1 实现原理

想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示SET If Not Exists,即如果 key 不存在,才会设置它的值,否则什么也不做。

加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。

加锁解锁伪代码如下:

1
2
3
4
5
6
7
8
if (setnx(key, 1) == 1){
expire(key, 30)
try {
//TODO 业务逻辑
} finally {
del(key)
}
}

2.2 加锁

SETNX 和 EXPIRE (错误)

如果 SETNX 成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间变成死锁。

SET 原子命令(正确)

从Redis 2.6.12 版本开始,SET命令可以通过参数来实现和SETNX、SETEX、PSETEX 三个命令相同的效果。

1
SET key value NX EX seconds

加上NX、EX参数后,效果就相当于SETEX,这也是Redis获取锁写法里面最常见的。

2.3 释放锁

VALUE 唯一性

value必须要具有唯一性,我们可以用UUID来做,设置随机字符串保证唯一性,至于为什么要保证唯一性?假如value不是随机字符串,而是一个固定值,那么就可能存在下面的问题:

  • 1.客户端1获取锁成功
  • 2.客户端1在某个操作上阻塞了太长时间
  • 3.设置的key过期了,锁自动释放了
  • 4.客户端2获取到了对应同一个资源的锁
  • 5.客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题

所以通常来说,在释放锁时,我们需要对value进行验证

GET 后对比自己并 DEL

释放锁时不能直接用del key这种粗暴的方式,因为直接del key任何客户端都可以进行解锁了,所以解锁时,我们需要判断锁是否是自己的,基于value值来判断。

  • lua脚本实现

所以我们必须先确保当前释放锁的线程是持有者,没问题了再删除,这样一来,就变成两个步骤了,似乎又违背了原子性了,怎么办呢?我们可以用lua脚本(保证原子性)把两步操作做拼装。

1
2
3
4
5
public boolean releaseLock_with_lua(String key,String value) {
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) else return 0 end";
return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}

KEYS[1]是当前key的名称,ARGV[1]可以是当前线程的ID(或者其他不固定的值,能识别所属线程即可),这样就可以防止持有过期锁的线程,或者其他线程误删现有锁的情况出现。

  • watch 和事务实现乐观锁实现

这个锁实现在绝大部分情况下都能够正常运行,但它的release()方法包含了一个非常隐蔽的错误:在程序使用GET命令获取锁键的值以后,直到程序调用DEL命令删除锁键的这段时间里面,锁键的值有可能已经发生了变化,因此程序执行的DEL命令有可能会导致当前持有者的锁被错误地释放。

qq105303 qq190427
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from redis import WatchError

class IdentityLock:

def__init__(self, client, key):
self.client = client
self.key = key

def acquire(self, identity, timeout):
"""
尝试获取一个带有身份标识符和最大使用时限的锁,
成功时返回True,失败时返回False
"""
result = self.client.set(self.key, identity, ex=timeout, nx=True)
returnresult isnotNone

def release(self, input_identity):
"""
根据给定的标识符,尝试释放锁。
返回True表示释放成功;
返回False则表示给定的标识符与锁持有者的标识符并不相同,释放请求被拒绝
"""
# 开启流水线
pipe = self.client.pipeline()
try:
# 监视锁键
pipe.watch(self.key)
# 获取锁键存储的标识符
lock_identity = pipe.get(self.key)
if lock_identity isNone:
# 如果锁键的标识符为空,那么说明锁已经被释放
return True
elif input_identity == lock_identity:
# 如果给定的标识符与锁键存储的标识符相同,那么释放这个锁
# 为了确保DEL命令在执行时的安全性,我们需要使用事务去包裹它
pipe.multi()
pipe.delete(self.key)
pipe.execute()
return True
else:
# 如果给定的标识符与锁键存储的标识符并不相同
# 那么说明当前客户端不是锁的持有者
# 拒绝本次释放请求
return False
except WatchError:
# 抛出异常说明在DEL命令执行之前,已经有其他客户端修改了锁键
return False
finally:
# 取消对键的监视
pipe.unwatch()
# 因为redis-py在执行WATCH命令期间,会将流水线与单个连接进行绑定
# 所以在执行完WATCH命令之后,必须调用reset()方法将连接归还给连接池
pipe.reset()

2.3 实现问题总结

死锁

设置过期时间即可

锁被别人释放

锁写入唯一标识,释放锁先检查标识,再释放。

锁过期时间到了还没干完活

锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。

加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。

如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。

单点问题
  1. 客户端 1 在主库上执行 SET 命令,加锁成功
  2. 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
  3. 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。

3. Redlock 算法

3.1 前提

Redlock 的方案基于 2 个前提:

  1. 不再需要部署从库和哨兵实例,只部署主库
  2. 但主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。

3.2 流程

整体的流程是这样的,一共分为 5 步:

  1. 客户端先获取「当前时间戳T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

3.3 争论

参考 Martin 和 redis 作者的争论

红锁的问题在于:加锁和解锁的延迟较大。难以在集群版或者标准版(主从架构)的Redis实例中实现。尽量不用它,而且它的性能不如单机版 Redis,部署成本也高。

4. 头脑风暴

  • SET EX PX NX + 校验唯一随机值, 对比后再释放锁。

  • 锁过期了活没干完,就要涉及到续期。

  • JAVA开源框架:Redisson

    只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。

  • GO的开源库 https://github.com/go-redsync/redsync

5. 参考资料

给作者打赏,可以加首页微信,咨询作者相关问题!