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

 lock(obj){
   doSomthing();
 }

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

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

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

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

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

//锁定操作
bool tryLock(string fileName){ 
	String lockToken=uuid();
	return exex_sql(insert into lockfile_t(file_name,lock_token) 
	values(fileName,lockToken));
}

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

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

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

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

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

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

上面的代码,由于需要保证删除过期和重新锁定的原子性,实现起来可能并不简单,需要借助于数据库的事务等特性才能完成。对此,如果数据库能自动处理超时的话,那我们的客户端实现将变得容易。如我们可以使用redis来替代数据库,由于redis可以为某个key设置超时,所以我们只需要在插入该key时设置超时时间即可,而不用客户端再来处理超时:

SET key value NX PX 60000

以上指令将设置key的值,仅当其不存在时生效(NX选项), 且设置其生存期为60000毫秒(PX选项)。

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