非分布式环境下的可重入排他锁的实现很简单,可以使用的方法很多,如synchronized、ReentrantLock等,但是在分布式环境一下则需要思考一下,一般也可以选型为Redis 、MySQL、Zookeeper等。本文通过Redis实现一个分布式可重入排他锁。
通过Redis的原子特性实现一个分布式环境下的排他锁并不难,默认的SET NX可以很好的帮助我们解决并发下的锁key争抢问题。但是如果持有锁key的线程再一次通过该方式获取该锁则会失败,因为此锁key 已经存在了,即SET NX命令无法支持锁的重复获取,需要在代码层做控制,这也带来了实现的复杂性。
SET命令
对SET命令不是很了解的同学可以先看一下使用说明,以下摘自Redis命令参考:
从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改:
- EX seconds : 将键的过期时间设置为 seconds 秒。 执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value 。
- PX milliseconds : 将键的过期时间设置为 milliseconds 毫秒。 执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value 。
- NX : 只在键不存在时, 才对键进行设置操作。 执行 SET key value NX 的效果等同于执行 SETNX key value 。
- XX : 只在键已经存在时, 才对键进行设置操作。
需要注意一个问题就是上面的选项可以同时生效:
1
2
3
4
5
6
7
8
127.0.0.1:6379> set mykey haha ex 10 nx
OK
127.0.0.1:6379> ttl mykey
(integer) 6
127.0.0.1:6379> ttl mykey
(integer) 1
127.0.0.1:6379> ttl mykey
(integer) -2
还可以通过lua来实现
1
2
3
4
127.0.0.1:6379> eval "return redis.call('set', 'mykey', '2_90041', 'ex', '10', 'nx')" 0
OK
127.0.0.1:6379> ttl mykey
(integer) 6
锁超时
任何一个锁在被一个线程持有之后都最好再设置一个失效时间,防止业务代码由于异常原因,如宕机等,无法正常释放对应的锁key,造成其他线程无法获取到锁。 当然正常情况下,线程执行完业务之后需要及时unlock让其他线程获取到锁,提高程序的并发性能。
可重入锁
可重入锁是指一个锁在被一个线程持有后,在该线程未释放锁前的任何时间内,只要再次访问被该锁锁住的函数区都可以再次进入对应的锁区域。 可重入锁有一个可重入度的概念,即每次重新进入一次该锁的锁住的区域都会递增可重入度,每次退出一个该锁锁住的区域都会递减可重入度,最终释放全部锁后,可重入度为0。 一般情况下非分布式的可重入锁的实现都是基于JVM级别的,一旦lock成功,在没有unlock之前,进程终止,可重入锁也就随之消失了,不会有什么问题。 一个典型的JVM级别的可重入锁对象的实现如下:
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
public class SpinLock {
private AtomicReference<Thread> owner =new AtomicReference<>();
private int count =0; // 单线程操作锁无需使用volatile修饰符修饰
public void lock(){
Thread current = Thread.currentThread();
if(current==owner.get()) {
count++;
return ;
}
while(!owner.compareAndSet(null, current)){
}
}
public void unlock (){
Thread current = Thread.currentThread();
if(current==owner.get()){
if(count!=0){
count--;
}else{
owner.compareAndSet(current, null);
}
}
}
}
通过AtomicReference
自带的compareAndSet
来实现对获取锁以及锁重入的控制。
如何用Redis实现分布式环境下可重入锁
所以,我们的基于Redis的分布式可重入锁的实现需要考虑:
- 排他性
这个Redis的SET NX已经帮我们解决了,具体的锁持有者身份标识问题需要业务解决,下面会说
- 锁超时性
这个需要实现代码在获取锁key成功之后同时设置一个失效时间,防止锁key可能存在的异常情况下长时间不被释放的问题
- 可重入性
即获取锁的线程在未释放(锁未过期)之前,仍然可以继续获取该锁,这个需要结合排他性里说到的锁持有者身份标识来解决,即A获取了锁,那么锁里一定要有A的信息,对上了就可以继续让A获取了 但这里不能考虑可重入度的问题,原因是分布式锁的存储和JVM没有关系,彼此是独立的,及时JVM 进程关闭,没有来得及释放锁,锁也不会凭空消失,只能用失效时间来控制。所以本质上,「分布式可重入锁」并不是传统意义上的可重入锁的实现。 需要注意的是前两点需要保证原子性,防止上锁成功和锁失效时间设置之间的gap过长导致失效时间设置时锁已释放
加锁实现
由于获取锁和可重入逻辑之间为非原子操作,我们通过lua来实现加锁操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-当key不存在的时候,设置value和失效时间,返回成功
-当key已经存在的时候,如果oldValue == value,新老值相同时,也会返回成功
-都不符合时返回失败
local function setnx_or_value_equals(key, value, ttl)
local old = redis.call('GET', key);
if old == nil or old == false then
redis.call('SET', key, value, 'px', ttl, 'nx') -- 设置value后设置了px失效时间并且要求
return 1 -- 返回成功
end
if value == old then
return 1 -- 对比新老value一致则认为是相同的线程,返回成功
end
-- 大部分请求只进行一次GET后即返回
return 0 -- 返回失败
end
上述锁实现可以令一个已经通过setnx_or_value_equals()获取了key锁的线程在该key未失效前可以多次获取对应的的key锁。
释放锁实现
同样,我们可以用lua来实现对应的释放锁的逻辑:
1
2
3
4
5
6
7
8
9
-- 若key存在,且值等于value,则删除,否则不做其他操作
-- 若成功删除,则返回1,否则返回0
local function delete_if_equals(key, value)
local old = redis.call('GET', key)
if old ~= nil and value == old then -- 通过value保证不同线程的value一定不同
return redis.call('DEL', key)
end
return 0
end
需要注意这里的key的value要特别进行设计,防止简单的时间戳的数值造成可能的冲突问题,导致其他线程通过value==old来窃取到锁。 一般在分布式环境中value可以参考机器、进程和线程,尽可能的多维度化value的值从而避免冲突,如:HOST_IP + Process_ID + Thread_Id 下面给出Java的调用代码:
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
/**
* 尝试加锁操作
* @param lockName 锁名称
* @param lockMaxMs 最大锁住时间,防止程序异常退出而无法释放锁。 该值若设置过小,可能会导致锁占有期间因锁超时而被释放,从而导致资源占用冲突
* @return 若加锁成功,返回true,否则返回false
*/
public boolean tryLock(String lockName, long lockMaxMs) {
long start = System.currentTimeMillis();
String lockValue = lockValue();
return rc.setIfNotExist(lockName, lockValue, lockMaxMs);
}
/**
* 释放已经获取的锁
* @param lockName 锁名称
*/
public void unlock(String lockName) {
long start = System.currentTimeMillis();
String lockValue = lockValue();
rc.deleteIfEquals(lockName, lockValue);
}
private String lockValue() {
return HOST_NAME + "-" + PID + "-" + Thread.currentThread().getId();
}
这样我们就实现了基于Redis的分布式可重入的排他锁,利用了Redis单线程的特性以及lua的扩展来实现,整体比较简单轻量,可用于生产环境。
在使用的时候需要注意,一旦调用了unlock()就会释放锁,所以在使用多个方法调用lock()时,只能在最外层的方法执行完毕时调用一次unlock()。
References
- http://ifeve.com/java_lock_see4/
本文首次发布于 LiuShuo’s Blog, 转载请保留原文链接.