分布式锁是控制分布式系统或者不一样系统之间共同访问共享资源的一种锁实现,若是不一样的系统或同一个系统的不一样主机之间共享了某个资源,每每须要互斥来防止彼此干扰来保证一致性。java
1.互斥性:任意时刻,只能用一个客户端获取锁,不能同时有两个客户端获取到锁。 2.安全性:锁只能被持有改锁的客户端删除,不能由其余客户端删除。 3.死锁:获取锁的客户端由于某些缘由(如down机等)而未能释放锁,其余客户端再也没法获取到该锁。 4.容错:当部分节点(redis节点等)down机时,客户端仍可以获取锁和释放锁。node
(1)建立一个job,初始化: id(自增主键),jobName(惟一键),status(0)redis
(2)客户端1执行turnOnJob(jobName)请求锁,算法
(3)当前status=0,得到锁成功,将status置为1数据库
(4)客户端1进行业务处理缓存
(5)客户端2执行turnOnJob(jobName)请求锁安全
(6)当前status=1,得到锁失败服务器
(7)客户端2捕得到到锁失败,进入重试队列,由重试做业来进行下次锁的获取已及业务逻辑的处理。网络
(1)互斥性:利用数据库的惟一性索引,使得锁不会由两个客户端在同一时刻得到锁。多线程
(2)安全性:数据库插入成功后,会将status置为1,并只会在业务执行完以后才会将status置为0。因此其余客户端是没法删除客户端所持有的锁的。
(3)死锁:因为一些异常状况(重启)使得锁没有释放,这样会致使死锁,经过设置过时时间机制,来避免这种异常死锁。经过一个Job,定时的清除已通过期的锁,过时条件:Math.floor(|Now-CreatedTime|)>ExpiredTime
(4)容错:利用数据库主历来进行容错的处理。主节点发生故障时,从节点切换成主节点,从而不影响分布式锁服务的运行。
(1)轻量级:能够快速实现分布式锁的功能,快速上线。
(1)因为须要不断的读写数据库,系统开销比较大,依赖数据库的性能,对数据库有必定程度的影响。对于小并发的场景下知足要求,可是在大并发或者在分布式集群下可能会有性能问题。
(2)跟数据库的某些特性耦合的太紧,首先是不一样的数据库会有不一样的特性,其次数据库的链接等都是紧俏的资源,没有针对分布式锁这种场景作特殊定制和优化。
分布式系统在某些场景下,必须对客户端请求肯定顺序、分清主次,由于有些资源在同一时间只容许被一个进程/线程处理,这时就须要分布式锁。利用分布式锁能保证数据一致性,避免出现负库存、撞单等错误。 虽然不少数据库都提供了锁的功能,可是DB锁的效率极低,并且在高并发下把压力都堆到DB上显然是极其危险的行为,在应用层实现分布式锁能够有效的减轻DB的压力,从而提升系统的稳定性和可用性。 zk主要是用来提供分布式一致性的,因此很天然的想到利用zk来实现分布式锁。由于分布式锁是应用在高并发场景下的(低并发场景也须要锁,但不须要分布式),分布式+高并发天然地要引入集群,锁就存在于集群内的各个节点上,分布式锁在逻辑上只有一把,但物理上存在多把,必须保证各节点上同一把锁的状态一致,这正是分布式一致性算法要作的事。
zk实现分布式锁是利用对节点的操做来进行的,锁操做主要涉及加锁和解锁,对应到zk里,加锁就是建立节点(临时节点),解锁就是删除节点,全部客户端都在同一父节点下进行加锁和解锁操做。具体实现思路有如下两种: 1、单节点锁 以某一固定节点为锁,全部客户端争抢这个节点,抢到即为加锁成功。 (1)某个client加锁时,先尝试建立这个节点,若是建立成功,说明该节点不存在,即当前无锁,此client得到锁; (2)若是建立失败,说明该节点已存在,即有其它client已得到锁,此client阻塞(等待节点删除的通知)或循环重试; (3)client得到锁并执行完业务逻辑后,删除该节点(即解锁),zk通知其它client(唤醒阻塞或跳出重试循环)。 2、多节点锁 在单节点锁中,全部client操做同一节点,当持有锁的client释放琐时,其它全部client都从阻塞中唤醒,将以竞争的方式来争抢锁,谁先得到锁取决于各client的网络情况和zk集群节点的cpu调度等不可控因素,和client的先来后到彻底无关。若是但愿各client能按前后顺序(至少在网络差别不大的状况下)来得到锁,就须要多节点锁来实现,即每一个客户端建立本身的专属节点(全部节点在同一父节点下),在知足特定条件时,本身的节点会成为锁。 zk有三种节点:永久节点、临时节点和顺序节点,能够组合成四种节点类型(永久、临时、永久顺序和临时顺序),为了实现先进先出的功能(先加锁的先得到锁),选择临时顺序节点来充当锁的角色。临时节点的特色是当会话失效时节点会被zk自动清除,顺序节点的特色是zk会为节点加上一个递增的序号做为后缀,序号按节点建立时间的前后顺序递增。基本原理以下: (1)某个client尝试加锁时,直接建立一个顺序节点; (2)load出父节点下的全部子节点(getChildren),判断刚才的节点序号是否最小,若是最小则表示当前client是第一个尝试加锁的,它将得到锁,若是不是最小则阻塞; (3)得到锁并执行完业务逻辑后,删除本身建立的节点,zk通知其它client; (4)从阻塞中唤醒后,执行2中的操做
分布式锁中的死锁,不是指多线程中的无线回路等待,而是指出现一个“幽灵锁”,这把锁在被建立后就和加锁者失去联系,加锁者也不知道本身建立过这把锁,固然也就没法对其进行解锁,进而致使其它client没法得到锁。有两种典型场景: (1)client发送建立节点的请求后因异常(如网络故障)和zk断开链接,zk服务端接收到请求并成功建立节点,这个节点就成为一个“幽灵节点”,它不与任何client关联,没法释放(能够设置失效时间,经过定时任务来清理,但显然增长了系统的复杂度)。 (2)client建立节点成功后,在作业务逻辑的过程当中出现异常,和zk断开链接,未能执行解锁操做。 在单节点锁和多节点锁中,应对死锁的方案有所不一样,但核心思想都是重试+校验。重试就是client在异常断开后发起重试,再次尝试加锁,直到成功为止(无限重试),校验就是在建立节点前先检查当前节点(若是存在)是不是本身以前建立的。 具体来讲,对于单节点锁,能够把client的私有信息(如ip)写入节点关联的字符串,每次加锁前先比较当前节点字符串中的信息是否和本身匹配,若是匹配就表示当前节点是本身以前建立的,直接视为加锁成功。对于多节点锁,每一个client持有一个惟一ID(好比java的uuid),将此ID做为节点名称的前缀,也就是用于判断节点归属的依据,每次建立节点前先load出父节点下的全部子节点,遍历子节点列表判断每一个子节点名称中是否包含此id,若是包含说明这个子节点就是本身以前建立的,而后才判断序号是否最小。
3.1.4 代码实现
1、单节点锁实现
/** * 单节点分布式锁 */ public class SingleNodeLock { private String clientId; private String nodeName; private ZKClient zkClient; //锁节点全路径名称 private static final String LOCK_NAME = "/zk/lock/publiclock"; public SingleNodeLock() throws Exception { clientId = UUID.randomUUID().toString().replaceAll("-", "") + "-"; zkClient = new ZKClient(); } /** * 加锁 * @return */ public boolean lock() { //先尝试获取现有锁节点数据 String lockInfo = zkClient.getData(LOCK_NAME); if ("nonode".equals(lockInfo)) { System.out.println("当前无锁," + clientId + "尝试加锁"); //节点不存在(即无锁),当前客户端能够尝试加锁(即建立节点) String lockNode = zkClient.Create(LOCK_NAME, clientId, CreateMode.PERSISTENT); if (LOCK_NAME.equals(lockNode)) { //建立节点成功即加锁成功 setNodeName(lockNode); return true; } /*即便开始判断锁节点不存在,当前客户端也不必定能成功建立节点,在多线程下可能被其它线程抢先建立*/ return false; } else { System.out.println("当前有锁,client标记:" + lockInfo); //锁存在且属于当前客户端 return clientId.equals(lockInfo) ? true : false; } } /** * 解锁 */ public void unlock() { System.out.println(clientId + "解锁"); zkClient.delete(nodeName); } //5个客户端线程争锁 public static void main(String[] args) { for (int i = 0; i < 5; i++) { Thread t = new Thread(new Runnable() { public void run() { try { SingleNodeLock snl = new SingleNodeLock(); while(true) { if (snl.lock()) { System.out.println(snl.getClientId() + "得到锁,3s后解锁"); Thread.sleep(3000); snl.unlock(); snl.getZkClient().close(); break; } System.out.println(snl.getClientId() + "未得到锁,等待2s"); Thread.sleep(2000); } } catch (Exception e) { e.printStackTrace(); } } }); t.start(); } } }
Redis命令参考 SETEX key seconds value setex:将值value关联到key,而且设置key的生存时间为 seconds(以秒为单位)。 若是 key 已经存在, SETEX 命令将覆写旧值。 这个命令相似于如下两个命令: SET key value EXPIRE key seconds # 设置生存时间
SETNX SETNX key value 将 key 的值设为 value ,当且仅当 key 不存在。 若给定的 key 已经存在,则 SETNX 不作任何动做。 SETNX 是『SET if Not eXists』(若是不存在,则 SET)的简写。
PSETEX PSETEX key milliseconds value 这个命令和 SETEX 命令类似,但它以毫秒为单位设置 key 的生存时间,而不是像 SETEX 命令那样,以秒为单位。 可用版本: >= 2.6.0 时间复杂度: O(1) 返回值: 设置成功时返回 OK 。
SET SET key value [EX seconds] [PX milliseconds] [NX|XX] 将字符串值 value 关联到 key 。 若是 key 已经持有其余值, SET 就覆写旧值,无视类型。 对于某个本来带有生存时间(TTL)的键来讲, 当 SET 命令成功在这个键上执行时, 这个键原有的 TTL 将被清除。 可选参数 从 Redis 2.6.12 版本开始, SET 命令的行为能够经过一系列参数来修改: EX second :设置键的过时时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。 PX millisecond :设置键的过时时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。 NX :只在键不存在时,才对键进行设置操做。 SET key value NX 效果等同于 SETNX key value 。 XX :只在键已经存在时,才对键进行设置操做。 由于 SET 命令能够经过参数来实现和 SETNX 、 SETEX 和 PSETEX 三个命令的效果,因此未来的 Redis 版本可能会废弃并最终移除 SETNX 、 SETEX 和 PSETEX 这三个命令。
redis命令:SET resource_name my_value KEYX EXPTIME 30000
KEYX:若是缓存中不存在key值,则在缓存中设置该key值。
EXPTIME:过时时间,key值将在过时时间事后失效。
my_value:该key设置的对应的值。这个值必须在全部的客户端和全部的 lock request 之间惟一。这个惟一值能够用来保证删除锁时的安全性:删除锁时只有key值相等,而且key值对应的value相等的客户端,才能够删除锁。
key插入失败,会抛出异常,客户端可以捕获,判断是否插入成功。
(1)客户端1向redis申请锁(key=1, value=1)
(2)redis中无锁的记录,客户端1插入成功,得到锁成功
(3)客户端1进行业务逻辑处理
(4)客户端2向redis申请锁(key=1, value=2)
(5)redis中已经有此锁记录,客户端2插入失败,得到锁
(6)客户端2捕获得到锁失败的异常,进入重试队列,由重试做业来进行下次所的获取以及逻辑得处理
(7)客户端1释放锁(key=1, value=1)
(8)客户端2从新得到锁成功,并进行业务逻辑处理
(1)互斥性:redis的命令能够保证只有一个客户端能够写入成功,其余客户端会写入失败。
(2)安全性:设置key的value时,经过构造全局惟一的 my_random_value,在删除锁时key和value都相等时才能够进行删除,以此来保证本客户端的锁不会被其余客户端删除。
(3)死锁:因为一些异常状况(好比:服务器重启)使得锁没有释放,这样会致使死锁,经过设置redis的key过时时间,来避免这种异常死锁。
(4)容错:没解决
(1)redis基于内存设置KV,性能好,可靠性高,对大并大能够有很好的支持,单机QPS能够达到5w。
(1)单点故障,当单机redis出现故障时,没法作到故障切换,会致使整个分布式的服务不可用。
(2)须要人为监控单机redis的机器状态,有必定的维护成本。
使用与单机redis环境。
同3.1.1
(1)互斥性:没解决
(2)安全性:设置key的value时,经过构造全局惟一的my_value,在删除锁时key和value否相等时才能够进行删除,以此来保证客户端的锁不会被其它客户端删除。
(3)死锁:因为一些异常状况(好比:服务器重启)使得锁没有释放,这样会致使死锁,经过设置redis的key过时时间来避免这种异常的死锁。
(4)容错:当redis主节点down机时,redis从节点晋升为主节点,继续提供分布式锁服务。
同3.3.1相比,redis主从模式提供了故障切换机制,能够保证分布式服务的正常提供。
(1)互斥性没法保证。redis主从复制是异步复制,当客户端1在redis主节点设置锁成功后,当尚未同步到从节点时,主节点down机,从节点升级为主节点提供分布式锁服务,客户端2再次申请得到取锁服务,而刚刚升级完主节点的机器由于没有key值,客户端2会申请锁成功,而此时客户端1的业务逻辑并无处理完成,在这种状况下,客户端1和客户端2就同时拥有了分布式锁,互斥性的条件没法知足。
主从备份机制:主机和从机的数据是一致的,在主机运行时,备份机时闲置的。
3.3.2.1 方案流程
(1)客户端1获取系统当前时间 current_time(ms)
(2)客户端1轮流用相同的key在N个redis节点上请求锁。客户端1在每一个master节点请求锁时,会有一个比锁的过时时间相比小不少的超时时间。好比锁的过时时间是10s,那每一个节点锁请求的超时时间多是5-50ms的范围,这样能够防止客户端在某个down掉的master节点上阻塞过长时间,若是一个master节点不可用,应尽快尝试下一个master节点。
(3)客户端1计算在第二步中获取锁所花费的时间cost_time_get_lock,只有当客户端在大多数master节点上(N/2+1)成功获取了锁,并且锁花费的时间不超过锁的过时时间,那么这个锁就认为是分配成功了。
(4)若是所获取成功了,那么锁的自动释放时间为 锁的过时时间- 得到锁所花费的时间(expire_time - cost_time_get_lock)
(5)客户端2获取锁失败,多是由于成功获取的锁不超过 N/2+1,多是由于获取锁的时间超过了锁过时时间,客户端都必须在每一个master节点上释放锁,包括那些客户端2认为没有成功获取到的锁。
(1)互斥性:只有在大多数节点都获取锁成功时,才认为客户端获取锁成功,没有获取锁成功的客户端释放全部已经持有的锁。
(2)安全性:设置key的value时,经过构造全局惟一的my_value,在删除锁时key和value都相等时才能够进行删除,以此来保证本客户端的锁不被其余客户端删除。
(3)死锁:因为一些异常状况(好比:服务器重启)使得锁没有释放,这样会致使死锁,经过设置redis的key过时时间来避免这种异常死锁。
(4)容错:当redis主节点down机时,其余redis节点继续组成集群,提供分布式锁服务。
基本上解决了分布式锁应该解决的问题。
(1)算法复杂,开发成本高,维护代价大。