使用Redis实现分布式环境下的可重入的排他锁

Implementing a Distributed Reentrant Exclusive Lock Using Redis

Posted by S.L on April 13, 2019

非分布式环境下的可重入排他锁的实现很简单,可以使用的方法很多,如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 : 只在键已经存在时, 才对键进行设置操作。

需要注意一个问题就是上面的选项可以同时生效:

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来实现

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级别的可重入锁对象的实现如下:

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来实现加锁操作:

-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来实现对应的释放锁的逻辑:

-- 若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的调用代码:

    /**
     * 尝试加锁操作
     * @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/

本文首次发布于 S.L’s Blog, 作者 @stuartlau , 转载请保留原文链接.