话题引入
大家好,我是小龙。
之前在《吃透Redis系列》专栏发表了第一篇文章《Redis基础篇(万丈高楼平地起):核心底层数据结构》简单介绍了Redis,以及它的内部组织形式、核心数据结构与大致使用场景。还没看到得同学可以回过头看看。
接下来,我将继续带大家深入理解,本文将介绍Redis高频使用的一个场景——「利用Redis实习分布式锁」。
想必大家都知道,在遇到并发问题时,我们通常会使用锁来解决并发问题。
这是,有同学可能说:“这个我会,不就用synchronized、Lock这些实现吗?”
对,你说的不错。但是你只说对了一半,在「传统单机部署」的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。
但是在「分布式系统」中,由于分布式系统「多线程」、「多进程」并且「分布在不同机器」上,这将使原单机并发控制锁策略失效,为了解决这个问题就需要一种「跨JVM的互斥机制」来控制共享资源的访问,这就得靠分布式锁啦。
看透锁本质
在我看来:所有的锁本身都可以用一个变量来表示。
比如:在「单机上运行」的多线程程序来说。取一个变量,变量为0时,表示没有线程获取锁;变量为1时,表示已经有线程获取锁。
加锁:线程调用加锁操作,检查变量是否为0,如果为0,表示没线程获取锁,将变量设置为1,表示获取锁;如果不是0,表示其他线程已经暂用锁,获取锁失败。
解锁:同理。
而分布式环境下,同样可以以变量形式理解分布式锁。
但是,和线程在单机上操作锁不同的是,在分布式场景下,「锁变量需要由一个共享存储系统来维护」,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,「加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值」。
「可见,满足分布式锁的要求」:
- 「锁操作原子性」:分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些「锁操作的原子性」;
- 「锁的可靠性」:共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,我们需要考虑保证「共享存储系统的可靠性」,进而保证「锁的可靠性」。
上面我们提到了可以使用一个锁变量来表示锁,其实你也可以理解为「占位」。只不过分布式锁需要把这个坑位拿出来放于「共享」的地方,每个都从「共享处来检查坑位」。
占位一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占位。先来先占, 用完了,再调用 del 指令释放茅坑。
- //加锁
- > setnx lock_key 1
- OK
- //业务逻辑
- >(其他操作)
- //释放锁
- > del lock_key
但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会「陷入死锁」,锁永远得不到释放。
于是我们在拿到锁之后,再给锁加上一个过期时间,这样即使中间出现异常也可以保证指定时间之后锁会自动释放。
- //加锁
- > setnx lock_key 1
- OK
- > expire lock_key 5
- //业务逻辑
- >(其他操作)
- //释放锁
- > del lock_key
但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。你也许会想到使用事务什么的执行,但是这里不行,因为如果 setnx 没抢到锁,expire 是不应该执行的。
Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和expire 指令可以一起执行,彻底解决了分布式锁的乱象。
- set key value [EX seconds | PX milliseconds] [NX]
除了上述基本常规的问题,还有这些「你可能没考虑到的问题」:
超时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁间的业务逻辑执行时间太长,以至于超出了锁的超时限制,就会出现问题(也就是锁过期了,你的业务逻辑还没执行完)。
因为这时候锁过期了,第二个客户端B重新持有了这把锁,但是紧接着客户端A执行完了业务逻辑,就把锁给释放了,客户端C就会在客户端B逻辑执行完之间拿到了锁。为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。
为了应对这个问题,我们需要能区分来自不同客户端的锁操作,具体咋做呢 ? 针对于这个问题,我们可以想办法把命令略加点小技巧。可以在锁变量的值上想想办法。
在使用SETNX命令进行加锁的方法中,我们通过把锁变量值设置为1或0,表示是否加锁成功。1和0只有两种状态,无法表示究竟是哪个客户端进行的锁操作。
所以,我们在加锁操作时,可以「让每个客户端给锁变量设置一个唯一值」,这里的唯一值就可以用来标识当前操作的客户端。
在释放锁操作时,客户端需要判断,当前「锁变量的值是否和自己的唯一标识相等」,只有在相等的情况下,才能释放锁。这样一来,就不会出现误释放锁的问题了。
于是,我们的命令可以这样写:
- //加锁,unique_value作为客户端唯—性的标识
- SET lock_key unique_value NX PX 5000
其中,unique_value 是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 5000则表示 lock_key会在5s后过期,以免客户端在这期间发生异常而无法释放锁。
因为在加锁操作中,每个客户端都使用了一个唯一标识,所以在「释放锁操作」时,我们需要「判断锁变量的值」,是否等于执行释放锁操作的客户端的唯一标识,如下所示,可以使用Lua脚本来保证原子性:
- //释放锁比较unique_value是否相等,避免误释放
- if redis.call("get" ,KEYS[1])== ARGV[1] then
- return redis.call("del" , KEYS[1])
- else
- return 0
- end
可重入性
可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。
Redis 分布式锁如果要支持可重入,可以对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数。
此处就不过多介绍,大抵不会问,有兴趣可以自己上网查阅看书。
课外补充
上述内容,是个基于单个Redis节点实现分布式锁。
当我们要实现「高可靠的分布式锁」时,就不能只依赖单个的命令操作了,我们需要按照一定的步骤和规则进行加解锁操作,否则,就可能会出现锁无法工作的情况。“一定的步骤和规则”是指啥呢?其实就是分布式锁的算法。
这里简单介绍Redlock算法的执行步骤。Redlock算法的实现需要有N个独立的Redis实例。接下来,我们可以分成3步来完成加锁操作。
1、客户端获取当前时间
2、客户端按照顺序在每个Master实例中尝试获得锁。在获得锁的过程中,为每一个锁操作设置一个快速失败时间(如果想要获得一个10秒的锁,那么每一个锁操作的失败时间设为5-50ms)。
这样可以避免客户端与一个已经故障的Master通信占用太长时间,通过快速失败的方式尽快的与集群中的其他节点完成锁操作。
3、客户端计算出与master获得锁操作过程中消耗的时间,「当且仅当Client获得锁消耗的时间小于锁的存活时间,并且在一半以上的master节点中获得锁」。才认为client成功的获得了锁。
4、如果已经获得了锁,「Client执行任务的时间窗口是锁的存活时间减去获得锁消耗的时间。」
5、如果Client获得锁的数量不足一半以上,或获得锁的时间超时,那么认为获得锁失败。客户端「需要尝试在所有的master节点中释放锁, 即使在第二步中没有成功获得该Master节点中的锁,仍要进行释放操作。」