锁在并发编程领域很为常见,当有资源竞争时就会有锁,在单进程的程序里,由于编程语言一般均提供了相应的原语,只需要简单的一个函数调用或声明,及可实现线程的互斥访问和操作。比如:

 lock(obj){
   doSomthing();
 }

但是,如果程序需要在不同的进程间互斥访问或操作共享资源,那语言级别的的锁定原语就无能为力。比如:我们有两个客户端对同一个文件进行修改,相互的修改需要互斥,不能相互覆盖也不能脏读,显然我们只在进程内加锁是无法实现多个客户端之间的协同和互斥的。对于此类场景,分布式锁就派上用场了。

简单来说,分布式锁就是需要一个资源中心,客户端向该资源中心请求某一资源的锁定,如果请求成功,则锁定该资源,其他客户端不能继续锁定该资源,直到该资源被释放为止。

说到资源中心,我们最容易想到的就是数据库了,数据库是天然收敛的,具有中心化特点,同时他有可以提供数据行级别的原子锁定操作,比较适合作为分布式锁的中心。可以创建一个如下的表:

CREATE TABLE lock_resource_t{
	id int(11) NOT NULL AUTO_INCREMENT
	resource_name varchar(200) NOT NULL,
 	insert_time timestamp  NULL,
 	lock_token varchar(32) NOT NULL,
 	PRIMARY KEY (id),
 	UNIQUE KEY uidx_resouce_name (resouce_name) USING BTREE
}ENGINE=InnoDB;

为resource_name创建了唯一索引,用数据库的唯一约束来进行资源互斥。可以定义以下的操作:

//锁定操作
bool tryLock(string resouceName){ 
	String lockToken=uuid();
	return exex_sql(insert into lock_resource_t(resouce_name,lock_token) 
	values(resouceName,lockToken);commit;);
}

//释放锁,lockToken用于保证只有当前锁定者才可以释放自己所锁定的资源
bool releaseLock(string resouceName,string lockToken){
	return exex_sql(delete from lock_resource_t where 
	resouce_name = resouceName and lock_token=lockToken);commit;;
}

上面的简单代码就基本模拟了一个分布式锁的核心功能:资源互斥。但是要作为一个可用的分布式锁这还远远不够。 注意到上面的锁定和释放,均只调用了一次,当有资源互斥的时候,它们会直接返回失败,如果失败了,那客户端只能不断重试,对于此,我们可以将重试的逻辑加到lock和release的API中去,这样就可以精简客户端的调用:

//锁定操作,并自动重试
bool tryLock(string resouceName,int tryTimes){ 
	String lockToken=uuid();
	while(--tryTimes >= 0)){
		if(exex_sql(insert into lock_resource_t(resouce_name,lock_token) 
		values(resouceName,lockToken);commit;)){
			return true;
		}else{
			thread.sleep(200);
		}
	}
	return false;
}

bool releaseLock(string resouceName,string lockToken,int tryTimes){
	while(--tryTimes >= 0)){
		if(exex_sql(delete from lock_resource_t where 
			resouce_name = resouceName and lock_token=lockToken;commit;)){
			return true;
		}else{
			thread.sleep(200);
		}
	}
	return false;
}

以上实现,在客户端均能正常调用的情况下没有问题,但是当客户端出现异常时,可能导致锁无法被释放。比如某客户端锁定某资源后,还没有来得及释放该资源的锁就崩溃了,将导致该资源一直被锁定,其他客户端均无法访问该资源。对于此类问题,一般引入锁定的超时时间来解决,即在调用锁定失败之后,判断原来锁的时间与当前时间差是不是超过了锁定周期,如果超过,及释放掉原来的锁,重新获取锁成功。代码如:

//锁定操作,并自动重试
bool tryLock(string resouceName){ 
	String lockToken=uuid();
	int timeout=30000;
	//先删除已过期的锁,再执行锁定,但必须保证删除和锁定的原子性
	return exec(
		delete from lock_resource_t where resouce_name = resouceName and insert_time<now()-timeout;
		insert into lock_resource_t(resouce_name,lock_token, insert_time) values(resouceName,lockToken,now());
		commit;
		);
}

上述实现中,由于考虑到各个客户端可能存在时钟差异,我们直接使用数据库时钟来获取时间。 受限于数据库的性能,基于数据库的分布式锁方案,当并发访问较高时,吞吐量一般,延时也较大, 可以使用redis来替代数据库。由于redis可以为某个key设置超时,所以我们只需要在插入该key时设置超时时间即可,而不用客户端再来处理超时:

SET resource_name lock_token NX PX 60000

以上指令将设置resource_name的值,仅当其不存在时生效(NX选项), 且设置其生存期为60000毫秒(PX选项)。并且上述指令是原子性的,所以只要上述命令能执行成功,即代表客户端可以获取到锁,否则认为失败。但是由于Redis没有提供能判断值的相等性后执行删除的原子命令,所以需要借助Lua脚本(Redis的单线程特性,执行Lua脚本命令也是单线程执行)

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

现在有了互斥、重试以及超时管理,我们基本解决了客户端获取锁的问题,既保证了在任何时候只有一个客户端可以获得锁,也保证了锁在没有正常释放的情况下,资源也能继续被使用。但是还有一个问题需要解决,我们前面均只考虑了只存在一个中心的情况,无论是数据库还是redis,如果只有一个中心节点,那就存在单点问题。分布式锁作为系统的中心,如果其不可用(除了可用性问题,采用Redis的方案,如果单节点出现failover,可能导致数据丢失,锁的状态也可能丢失,引起锁的不安全性问题),将导致所有客户端均不能正常使用。容易想到的解决方案是,建立数据库集群或者redis集群来解决。但是集群真的能锁的不安全问题吗?它会不会带来新的问题?

举例来说,采取主从架构来部署Redis集群,每个Redis的主节点挂载一个从节点作为备份,当主节点不可用时,切换到从节点,可以明显提升Redis的可用性。但是这里忽略了一个问题,由于Redis的主从间数据复制时异步的,所以当主节点崩溃的时候,数据可能还未完全同步从节点,当从节点接管服务后,有可能丧失了锁的安全性。鉴于此情况,Redis的作者提出了基于N(N通常为奇数)个Redis节点的Redlock的算法(https://redis.io/topics/distlock ),算法概述如下:

1、客户端获取本地机器时间;

2、客户端循环遍历N个Redis节点,执行以下命令以尝试获取锁:

SET resource_name lock_token NX PX 60000

3、由于需要向多个Redis节点获取锁,为了保证某个Redis节点不可用时算法可以继续运行,获取锁的操作过程中通常还需要设置一个超时时间,并且这个超时时间应该比锁的有效持有时间短很多。当客户端向某个Redis获取锁失败时,立即尝试从下一个节点获取锁。

4、获取锁完成后用当前时间减去第一步记录的时间,得到获取锁过程的总时长,如果超过半数的Redis节点最终获取成功锁,并且获取锁的总时长小于锁的有效持有时间,则客户端成功获取锁,否则获取锁失败。

5、如果客户端最终判断获取锁失败,则立即向每个Redis节点释放锁,需要注意的是,无论这个节点有没有获取成功,均需要释放,因为如果是超时引起的获取失败节点,其最终有没有锁成功的状态是未知的。

以上基于通过超时来自动释放锁的方式,存在一个局限是要求客户端的实际操作必须在锁定超时时间内完成,如果客户端的处理时间超过超时时间,则状态会变成不安全,超时时间的选择是一个比较困难的点。Redlock要求获取锁的时间应远小于超时时间,以给客户端的操作预留足够的处理时间,所以该如何选择锁定成功的有效时间呢?又是一个比较困难的问题。除了这个问题,Redlock还是依然存在几种可能导致不安全锁的情况。

先看第一种情况,假设有1、2、3三个Redis节点,分别发生以下锁获取的事件:

  1. 客户端A通过成功从1、2节点获取锁,节点3未成功获取,最终判定为获取锁成功。
  2. 此时节点2发生重启,导致客户端A在该节点上的锁定状态丢失。
  3. 节点2重启之后,客户端B又通过从节点2、3成功获取相同资源的锁,即对于相同资源,两个客户端都同时获取到了锁,导致了不安全的情况。

以上问题,默认情况下可以借助于Redis的AOF持久化机制来尽量保证数据状态不丢失,同时设置Redis每次修改操作都执行fsync,但会明显降低Redis的写入性能(不同的操作系统,fsync也不一定能完全保证不丢失数据)。

第二种情况,同样假设有1、2、3三个Redis节点,分别发生以下事件:

  1. 客户端A通过成功从1、2节点获取锁,最终判定为获取锁成功
  2. 节点2的机器发生机器时间前移(比如有人手工更新了机器时间或NAT同步错误),导致节点2上的锁快速过期
  3. 客户端B又通过从节点2、3成功获取相同资源的锁,即对于相同资源,两个客户端都同时获取到了锁,导致了不安全的情况。

以上问题,是由分布式锁的实现依赖了不可靠的时钟导致。

另外以上所有基于超时时间来让锁自动解决的方案,存在一种解决不了的情况,那就是当客户端本身发生延时无法判断锁的有效性,导致不安全的情况。比如,分别发生以下事件:

  1. 客户端A向分布式锁服务获取到了锁,并成功为锁设置超时时间
  2. 客户端A获取锁后,马上进入了暂停状态,比如发生Full GC
  3. 在客户端A暂停期间,锁过期,分布式锁的服务将锁分配给客户端B
  4. 客户端A从暂停的状态中恢复,继续执行,此时客户端A并不知道自身的锁已经过时,即发生不安全情况,锁同时被客户端A和客户端B持有。

接下来,我们来看看基于zookeeper的分布式锁实现,能不能解决上述问题呢?基于zookeeper来实现分布式锁的一般实现方式如下:

  1. 客户端尝试向zookeeper创建一个节点“/lock_token”,如果客户端创建节点成功,则获取锁成功,否则则认为失败;
  2. 客户端释放锁时,只需要将创建的节点删除,即可完成锁的释放
  3. 由于zookeeper有临时节点的功能,所以只要创建的节点是临时节点,当客户端崩溃后,由于会话消失,临时节点会被自动删除,锁也一定会释放

上述方案解决了上面说的锁自动释放问题,同时由于zookeeper的多节点持久化存储功能,所以也解决了由于数据状态丢失导致的不安全锁问题。但是回到上面说到的如果客户端获取后马上进入暂停状态,如Full GC,会不会发生不安全锁的问题?这主要要分析zookeeper时怎么判断会话丢失的。zookeeper的会话存活判断基于与客户端之间的心跳,心跳从客户端发往zookeeper,如果超过一定时间没有收到客户端的心跳即认为客户端已经不存活了,但是不幸的是,客户端进入Full GC的暂停状态时,或客户端与zookeeper间出现网络隔离,会话丢失,将同样会发生锁失效导致其他客户端同时获取锁的问题。所以,基于zookeeper的实现,也同样无法根本解决不安全锁的问题。

尽管基于zookeeper的实现,无法提供真正安全的分布式锁,但借助于zookeeper的一些功能特性,将使得分布式锁的实现变得简单。

一个是zookeeper的watch机制,客户端在没有获取成功锁时,不需要一直向zookeeper查询锁的状态,借助watch机制由zookeeper主动通知客户端来来唤醒,从而使客户端不用进入“自旋”状态。二是zookeeper可以建立顺序子节点,这一点非常有助于实现分布式锁的公平性,具体将在下一篇关于分布式锁的具体代码实现的文章里继续介绍。