情景如下:
我们有一批任务需要由多个分布式线程处理,每个任务都有一个taskId,为了保证每个任务只被执行一次,在工作线程执行任务之前,先获取该任务的锁,锁的key可以为taskId
方式1:set(key,value)方式
原理:在获取锁之前先查询一下以该锁为key对应的value存不存在,如果存在,则说明该锁被其他客户端获取了,否则的话就尝试获取锁,获取锁的方法很简单,只要以该锁为key,设置一个随机的值就行了
代码:
缺陷:该函数并不是原子性的,当一个线程执行existKey()时,检测到某个锁不存在,并在执行setKey()之前,其他线程可能也执行了existKey(),同样检测到该锁不存在,也会紧接着执行setKey方法,这样一来,同一把锁就有可能被不同的线程获取到了
方式2:setnx(key,value,timeout)方式 ,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果
原理:如果 setnx() 返回1,说明该线程获得锁,SETNX将键 key 的值设置为value
如果 setnx() 返回0,说明其他线程已经获得了锁,可以在一个循环中不断地尝试 setnx()操作,以获得锁
代码:
缺陷:客户端A获取锁的时候设置了key的过期时间为2秒,然后客户端A在获取到锁之后,业务逻辑方法doSomething执行了3秒(大于2秒),当执行完业务逻辑方法的时候,客户端A获取的锁已经被Redis过期机制自动释放了,因此客户端A在获取锁经过2秒之后,该锁可能已经被其他客户端获取到了。当客户端A执行完doSomething方法之后接下来就是执行releaseLock方法释放锁了,由于前面说了,该锁可能已经被其他客户端获取到了,因此这个时候释放锁就有可能释放的是其他客户端获取到的锁
方式3:setnx(key,value,timeout)方式,value为一个随机值
原理:既然方式二可能会出现释放了别的客户端申请到的锁的问题,那么该如何进行改进呢?有一个很简单的方法是,我们设置key的时候,将value设置为一个随机值r,当释放锁,也就是删除key的时候,不是直接删除,而是先判断该key对应的value是否等于先前设置的随机值r,只有当两者相等的时候才删除该key,由于每个客户端产生的随机值是不一样的,这样一来就不会误释放别的客户端申请的锁了
代码:
缺陷:releaseLock()函数不是原子性的,不是原子性操作意味着当一个客户端A执行完getKey()并在执行deleteKey()之前,也就是在这2个函数执行之间,其他客户端是可以执行其他命令的。考虑这样一种情况,在客户端A执行完getKey(),并且该key对应的值也等于先前的随机值的时候,接下来客户端A将会执行deleteKey()。假设由于网络或其他原因,客户端A执行getKey()之后过了1秒钟才执行deleteKey(),那么在这1秒钟里,该key有可能也会因为过期而被Redis清除了,这样一来另一个客户端,姑且称之为客户端B,就有可能在这期间获取到锁,然后接下来客户端A就执行到deleteKey()了,如此一来就又出现误释放别的客户端申请的锁的问题了
方式4:setnx(key,value,timeout)方式,value为一个随机值,删除时执行lua代码,来保证原子性
原理:既然方式三的问题是因为释放锁的方法不是原子操作导致的,那么我们只要保证释放锁的代码是原子性的就能解决该问题了。有另外一种方式,就是Lua脚本。由于Lua脚本的原子性,在Redis执行该脚本的过程中,其他客户端的命令都需要等待该Lua脚本执行完才能执行,所以不会出现方案三所说的问题。至此,使用Redis实现分布式锁的方案就相对完善了
代码:go语言版本
结论:
上述分布式锁的实现方案中,都是针对单节点Redis而言的,然而在实际的生产环境中,我们使用的通常是Redis集群,并且每个主节点还会有从节点。由于Redis的主从复制是异步的,因此上述方案在Redis集群的环境下也是有问题的。比如主节点刚设置了一个key用做锁,还没同步到从节点,此时主节点崩溃了,稍后从节点升级为主节点,自然没有这个key,那么其他客户端请求时就又请求到了锁,造成混乱。
关于在Redis集群中如何优雅地实现分布式锁,后续再写文章详述