如何优雅地用Redis实现分布式锁


什么是分布式锁



在学习Java多线程编程的时候,锁是一个很重要也很基础的概念,锁可以看做是多线程情况下访问共享资源的一种线程同步机制。这是对于单进程应用而言的,即所有线程都在同一个JVM进程里的时候,使用Java语言提供的锁机制可以起到对共享资源进行同步的作用。如果分布式环境下多个不同线程需要对共享资源进行同步,那么用Java的锁机制就无法实现了,这个时候就必须借助分布式锁来解决分布式环境下共享资源的同步问题。分布式锁有很多种解决方案,今天我们要讲的是怎么使用缓存数据库Redis来实现分布式锁。

Redis分布式锁方案一



使用Redis实现分布式锁最简单的方案是在获取锁之前先查询一下以该锁为key对应的value存不存在,如果存在,则说明该锁被其他客户端获取了,否则的话就尝试获取锁,获取锁的方法很简单,只要以该锁为key,设置一个随机的值就行了。比如,我们有一批任务需要由多个分布式线程处理,每个任务都有一个taskId,为了保证每个任务只被执行一次,在工作线程执行任务之前,先获取该任务的锁,锁的key可以为taskId。因此,获取锁的过程可以用如下伪代码实现:

function boolean getLock(taskId){ if(existsKey(taskId)){ return false; }else{
setKey(taskId); return true; } }

上述就是最简单的获取锁的方案了,但是大家可以想想这个方案有什么问题呢?有没有什么潜在的坑?在分析这种方案的优缺点之前,先说一下获取锁后我们一般是怎么使用锁,并且又是如何释放锁的,以Java语言为例,我们一般获取锁后会将释放锁的代码放在finally块中,这样做的好处是即使在使用锁的过程中出现异常,也能顺利将锁释放掉。用伪代码描述如下:

boolean lock=false; try{ lcok=getLock(taskId);   //获取锁 if(lock){ doSomething();   //业务逻辑 } }finally{
if(lock){
releaseLock(taskId);   //释放锁
} }

其中,getLock方法的伪代码上文已经给出,releaseLock方法是释放锁的方法,在该方案中,只是简单地删除掉key,就不给出伪代码了。

上述使用锁的代码咋一看是没有什么问题的,学过Java的人都知道,在try...finally...代码块中,即使try代码块中抛出异常,最终也会执行finally代码块,然而这样就能保证锁一定会被释放吗?考虑这样一种情况:代码执行到doSomething()方法的时候,服务器宕机了,这个时候finally代码块就没法被执行了,因此在这种情况下,该锁不会被正常释放,在上述案例中,可能会导致任务漏算。因此,这种方案的第一个问题是会出现锁无法正常释放的风险,解决这个问题的方法也很简单,Redis设置key的时候可以指定一个过期时间,只要获取锁的时候设置一个合理的过期时间,那么即使服务器宕机了,也能保证锁被正确释放。

该方案的另外一个问题是,获取到的锁不一定是排他锁,也就是说同一把锁同一时间可能被不同客户端获取到。仔细分析一下getLock方法,该方法并不是原子性的,当一个客户端检查到某个锁不存在,并在执行setKey方法之前,别的客户端可能也会检查到该锁不存在,并也会执行setKey方法,这样一来,同一把锁就有可能被不同的客户端获取到了。

既然这种方案有以上缺点,那么该如何改进呢?且听我慢慢道来。

Redis分布式锁方案二



上一小节的方案有2个缺点,一个是获取的锁可能无法释放,另一个是同一把锁在同一时间可能被不同线程获取到。通过查看Redis文档,可以找到Redis提供了一个只有在某个key不存在的情况下才会设置key的值的原子命令,该命令也能设置key值过期时间,因此使用该命令,不存在上述方案出现的问题,该命令为:

SET my_key my_value NX PX milliseconds

其中,NX表示只有当键key不存在的时候才会设置key的值,PX表示设置键key的过期时间,单位是毫秒。

如此一来,获取锁的过程可以用如下伪代码描述:

function boolean getLock(taskId,timeout){ return setKeyOnlyIfNotExists(taskId,timeout); }

其中,setKeyOnlyIfNotExists方法表示的是原子命令SET my_key my_value NX PX milliseconds。

如此一来,获取锁的代码应该就没什么问题了,但是这种方案还是会有其他问题。大家再仔细研究下释放锁的代码。因为现在我们设置key的时候也设置了过期时间,所以原来的释放锁的代码现在看来就有问题了。考虑这样一种情况:客户端A获取锁的时候设置了key的过期时间为2秒,然后客户端A在获取到锁之后,业务逻辑方法doSomething执行了3秒(大于2秒),当执行完业务逻辑方法的时候,客户端A获取的锁已经被Redis过期机制自动释放了,因此客户端A在获取锁经过2秒之后,该锁可能已经被其他客户端获取到了。当客户端A执行完doSomething方法之后接下来就是执行releaseLock方法释放锁了,由于前面说了,该锁可能已经被其他客户端获取到了,因此这个时候释放锁就有可能释放的是其他客户端获取到的锁。

Redis分布式锁方案三



既然方案二可能会出现释放了别的客户端申请的锁的问题,那么该如何进行改进呢?有一个很简单的方法是,我们设置key的时候,将value设置为一个随机值r,当释放锁,也就是删除key的时候,不是直接删除,而是先判断该key对应的value是否等于先前设置的随机值,只有当两者相等的时候才删除该key,由于每个客户端产生的随机值是不一样的,这样一来就不会误释放别的客户端申请的锁了。新的释放锁的方案用伪代码描述如下:

function void releaseLock(taskId,random_value){ if(getKey(taskId)==random_value){ deleteKey(taskId); } }

其中,getKey方法就是Redis的查询key值的方法,deleteKey就是Redis的删除key值的方法,在此不给出伪代码了。

那么这种方案就没有问题了吗?很遗憾地说,这种方案也是有问题的。原因在于上述释放锁的操作不是原子性的,不是原子性操作意味着当一个客户端执行完getKey方法并在执行deleteKey方法之前,也就是在这2个方法执行之间,其他客户端是可以执行其他命令的。考虑这样一种情况,在客户端A执行完getKey方法,并且该key对应的值也等于先前的随机值的时候,接下来客户端A将会执行deleteKey方法。假设由于网络或其他原因,客户端A执行getKey方法之后过了1秒钟才执行deleteKey方法,那么在这1秒钟里,该key有可能也会因为过期而被Redis清除了,这样一来另一个客户端,姑且称之为客户端B,就有可能在这期间获取到锁,然后接下来客户端A就执行到deleteKey方法了,如此一来就又出现误释放别的客户端申请的锁的问题了。

Redis分布式锁方案四



既然方案三的问题是因为释放锁的方法不是原子操作导致的,那么我们只要保证释放锁的代码是原子性的就能解决该问题了。很遗憾的是,查阅Redis开发文档,并没有发现相关的原子操作。不过幸运的是,在Redis中执行原子操作不止有通过官方提供的命令的方式,还有另外一种方式,就是Lua脚本。因此,方案三中的释放锁的代码可以用以下Lua脚本来实现:

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

其中ARGV[1]表示设置key时指定的随机值。

由于Lua脚本的原子性,在Redis执行该脚本的过程中,其他客户端的命令都需要等待该Lua脚本执行完才能执行,所以不会出现方案三所说的问题。至此,使用Redis实现分布式锁的方案就相对完善了。

总结



上述分布式锁的实现方案中,都是针对单节点Redis而言的,然而在生产环境中,我们使用的通常是Redis集群,并且每个主节点还会有从节点。由于Redis的主从复制是异步的,因此上述方案在Redis集群的环境下也是有问题的。关于在Redis集群中如何优雅地实现分布式锁,后续再写文章详述。

        京ICP备15003959号         原文链接