为何要使用分布式锁
为了保证一个方法在高并发状况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的状况下,可使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。可是,随着业务发展的须要,原单体单机部署的系统被演化成分布式系统后,因为分布式系统多线程、多进程而且分布在不一样机器上,这将使原单机部署状况下的并发控制锁策略失效,为了解决这个问题就须要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。
分布式锁的三种实现方式
在分析分布式锁的三种实现方式以前,先了解一下分布式锁应该具有哪些条件。
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具有可重入特性;
- 具有锁失效机制,防止死锁;
- 具有阻塞锁特性,即没有获取到锁将继续等待获取锁;
- 具有非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
基于数据库的实现方式
在数据库中建立一个表,表中包含方法名等字段,并在方法名字段上建立惟一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
这种实现方式很简单,可是对于分布式锁应该具有的条件来讲,它有一些问题须要解决及优化。redis
- 由于是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,因此,数据库须要双机部署、数据同步、主备切换;
- 不具有可重入的特性,由于同一个线程在释放锁以前,行数据一直存在,没法再次成功插入数据,因此,须要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
- 没有锁失效机制,由于有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,因此,须要在表中新增一列,用于记录失效时间,而且须要有定时任务清除这些失效的数据;
- 不具有阻塞锁特性,获取不到锁直接返回失败,因此须要优化获取逻辑,循环屡次去获取。
优势:借助数据库,方案简单。
缺点:在实际实施的过程当中会遇到各类不一样的问题,为了解决这些问题,实现方式将会愈来愈复杂;依赖数据库须要必定的资源开销,性能问题须要考虑。
基于Redis的实现方式
在Redis2.6.12版本以前,使用setnx命令设置key-value、使用expire命令设置key的过时时间获取分布式锁,使用del命令释放分布式锁,可是这种实现有以下一些问题:
- setnx命令设置完key-value后,还没来得及使用expire命令设置过时时间,当前线程挂掉了,会致使当前线程设置的key一直有效,后续线程没法正常经过setnx获取锁,形成死锁;
- 在分布式环境下,线程A经过这种实现方式获取到了锁,可是在获取到锁以后,执行被阻塞了,致使该锁失效,此时线程B获取到该锁,以后线程A恢复执行,执行完成后释放该锁,直接使用del命令,将会把线程B的锁也释放掉,而此时线程B还没执行完,将会致使不可预知的问题;
- 为了实现高可用,将会选择主从复制机制,可是主从复制机制是异步的,会出现数据不一样步的问题,可能致使多个机器的多个线程获取到同一个锁。
针对上面这些问题,有以下一些解决方案:
- 第一个问题是由于两个命令是分开执行而且不具有原子特性,若是能将这两个命令合二为一就能够解决问题了。在Redis2.6.12版本中实现了这个功能,Redis为set命令增长了一系列选项,能够经过SET resource_name my_random_value NX PX max-lock-time来获取分布式锁,这个命令仅在不存在key(resource_name)的时候才能被执行成功(NX选项),而且这个key有一个max-lock-time秒的自动失效时间(PX属性)。这个key的值是“my_random_value”,它是一个随机值,这个值在全部的机器中必须是惟一的,用于安全释放锁。
- 为了解决第二个问题,用到了“my_random_value”,释放锁的时候,只有key存在而且存储的“my_random_value”值和指定的值同样才执行del命令,此过程能够经过如下Lua脚本实现:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end复制代码
- 第三个问题是由于采用了主从复制致使的,解决方案是不采用主从复制,使用RedLock算法,这里引用网上一段关于RedLock算法的描述。
在Redis的分布式环境中,假设有5个Redis master,这些节点彻底互相独立,不存在主从复制或者其余集群协调机制。为了取到锁,客户端应该执行如下操做:
- 获取当前Unix时间,以毫秒为单位;
- 依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络链接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样能够避免服务器端Redis已经挂掉的状况下,客户端还在死死地等待响应结果。若是服务器端没有在规定时间内响应,客户端应该尽快尝试另一个Redis实例;
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就获得获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,而且使用的时间小于锁失效时间时,锁才算获取成功。
- 若是取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果);
- 若是由于某些缘由,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在全部的Redis实例上进行解锁(即使某些Redis实例根本就没有加锁成功)。
经过上面的解决方案能够实现一个高效、高可用的分布式锁,这里推荐一个成熟、开源的分布式锁实现,即Redisson。
优势:高性能,借助Redis实现比较方便。
缺点:线程获取锁后,若是处理时间过长会致使锁超时失效,因此,经过锁超时机制不是十分可靠。
基于ZooKeeper的实现方式
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个惟一文件名。基于ZooKeeper实现分布式锁的步骤以下:
- 建立一个目录mylock;
- 线程A想获取锁就在mylock目录下建立临时顺序节点;
- 获取mylock目录下全部的子节点,而后获取比本身小的兄弟节点,若是不存在,则说明当前线程顺序号最小,得到锁;
- 线程B获取全部节点,判断本身不是最小节点,设置监听比本身次小的节点;
- 线程A处理完,删除本身的节点,线程B监听到变动事件,判断本身是否是最小的节点,若是是则得到锁。
这里推荐一个apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。
优势:具有高可用、可重入、阻塞锁特性,可解决失效死锁问题。
缺点:由于须要频繁的建立和删除节点,性能上不如Redis方式。
总结
上面的三种实现方式,没有在全部场合都是完美的,因此,应根据不一样的应用场景选择最适合的实现方式。