分布式锁简述
在单机时代,虽然不存在分布式锁,但也会面临资源互斥的状况,只不过在单机的状况下,若是有多个线程要同时访问某个共享资源的时候,咱们能够采用线程间加锁的机制,即当某个线程获取到这个资源后,就须要对这个资源进行加锁,当使用完资源以后,再解锁,其它线程就能够接着使用了。例如,在JAVA中,甚至专门提供了一些处理锁机制的一些API(synchronize/Lock等)。node
可是到了分布式系统的时代,这种线程之间的锁机制,就没做用了,系统可能会有多份而且部署在不一样的机器上,这些资源已经不是在线程之间共享了,而是属于进程之间共享的资源。所以,为了解决这个问题,「分布式锁」就强势登场了。程序员
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,经常须要协调他们的动做。若是不一样的系统或是同一个系统的不一样主机之间共享了一个或一组资源,那么访问这些资源的时候,每每须要互斥来防止彼此干扰来保证一致性,在这种状况下,便须要使用到分布式锁。redis
在分布式系统中,经常须要协调他们的动做。若是不一样的系统或是同一个系统的不一样主机之间共享了一个或一组资源,那么访问这些资源的时候,每每须要互斥来防止彼此干扰来保证一致性,这个时候,便须要使用到分布式锁。数据库
目前相对主流的有三种,从实现的复杂度上来看,从上往下难度依次增长:并发
数据库分布式
redislua
zookeeperspa
基于数据库来作分布式锁的话,一般有两种作法:线程
- 基于数据库的乐观锁
- 基于数据库的悲观锁
乐观锁
乐观锁的特色先进行业务操做,不到万不得已不去拿锁。即“乐观”的认为拿锁多半是会成功的,所以在进行完业务操做须要实际更新数据的最后一步再去拿一下锁就好。code
乐观锁机制其实就是在数据库表中引入一个版本号(version)字段来实现的。当咱们要从数据库中读取数据的时候,同时把这个version字段也读出来,若是要对读出来的数据进行更新后写回数据库,则须要将version加1,同时将新的数据与新的version更新到数据表中,且必须在更新的时候同时检查目前数据库里version值是否是以前的那个version,若是是,则正常更新。若是不是,则更新失败,说明在这个过程当中有其它的进程去更新过数据了。
看图叙事。模拟实战场景。
如上图,故事男主人公(如下简称男主)打算去ATM机取3000元,故事女主人公(如下简称女主)则要在某宝买买买,买个包须要3000元,帐户的余额是5000元。若是没有采用锁的话,在两人同时取款和买买买,可能会出现合计消费了6000,致使帐户余额异常。因此须要用到锁的机制,当男主女主甚至更多小主同时消费时,除了读取到6000的帐户余额外,还须要读取到当前的版本号version=1,等先行消费成功的主人公(不管谁先消费)去出发修改帐户余额的同时,会触发version=version+1,即version=2。那么其余人使用未更新的version(1)去更新帐户余额时就会发现版本号不对,就会致使本次更新失败,就得从新去读取最新帐户余额以及版本号。
乐观锁遵循的两点法则:
- 锁服务要有递增的版本号version
- 每次更新数据的时候都必须先判断版本号对不对,而后再写入新的版本号
悲观锁
悲观锁的特色是先获取锁,再进行业务操做,即“悲观”的认为获取锁是很是有可能失败的,所以要先确保获取锁成功再进行业务操做。
一般所说的“一锁二查三更新”即指的是使用悲观锁。一般来说在数据库上的悲观锁须要数据库自己提供支持,即经过经常使用的select ... for update
操做来实现悲观锁。当数据库执行select for update
时会获取被select
中的数据行的行锁,所以其余并发执行的select for update
若是试图选中同一行则会发生排斥(须要等待行锁被释放),所以达到锁的效果。select for update
获取的行锁会在当前事务结束时自动释放,所以必须在事务中使用。
基于数据库来作分布式锁的话,一般有两种作法:
- 基于数据库的乐观锁
- 基于数据库的悲观锁
乐观锁
乐观锁的特色先进行业务操做,不到万不得已不去拿锁。即“乐观”的认为拿锁多半是会成功的,所以在进行完业务操做须要实际更新数据的最后一步再去拿一下锁就好。
乐观锁机制其实就是在数据库表中引入一个版本号(version)字段来实现的。当咱们要从数据库中读取数据的时候,同时把这个version字段也读出来,若是要对读出来的数据进行更新后写回数据库,则须要将version加1,同时将新的数据与新的version更新到数据表中,且必须在更新的时候同时检查目前数据库里version值是否是以前的那个version,若是是,则正常更新。若是不是,则更新失败,说明在这个过程当中有其它的进程去更新过数据了。
看图叙事。模拟实战场景。
如上图,故事男主人公(如下简称男主)打算去ATM机取3000元,故事女主人公(如下简称女主)则要在某宝买买买,买个包须要3000元,帐户的余额是5000元。若是没有采用锁的话,在两人同时取款和买买买,可能会出现合计消费了6000,致使帐户余额异常。因此须要用到锁的机制,当男主女主甚至更多小主同时消费时,除了读取到6000的帐户余额外,还须要读取到当前的版本号version=1,等先行消费成功的主人公(不管谁先消费)去出发修改帐户余额的同时,会触发version=version+1,即version=2。那么其余人使用未更新的version(1)去更新帐户余额时就会发现版本号不对,就会致使本次更新失败,就得从新去读取最新帐户余额以及版本号。
乐观锁遵循的两点法则:
- 锁服务要有递增的版本号version
- 每次更新数据的时候都必须先判断版本号对不对,而后再写入新的版本号
悲观锁
悲观锁的特色是先获取锁,再进行业务操做,即“悲观”的认为获取锁是很是有可能失败的,所以要先确保获取锁成功再进行业务操做。
一般所说的“一锁二查三更新”即指的是使用悲观锁。一般来说在数据库上的悲观锁须要数据库自己提供支持,即经过经常使用的select ... for update
操做来实现悲观锁。当数据库执行select for update
时会获取被select
中的数据行的行锁,所以其余并发执行的select for update
若是试图选中同一行则会发生排斥(须要等待行锁被释放),所以达到锁的效果。select for update
获取的行锁会在当前事务结束时自动释放,所以必须在事务中使用。
如何用Redis实现分布式锁?
Redis分布式锁的基本流程并不难理解,但要想写得尽善尽美,也并非那么容易。在这里,咱们须要先了解分布式锁实现的三个核心要素:
1.加锁
最简单的方法是使用setnx命令。key是锁的惟一标识,按业务来决定命名。好比想要给一种商品的秒杀活动加锁,能够给key命名为 “lock_sale_商品ID” 。而value设置成什么呢?咱们能够姑且设置成1。加锁的伪代码以下:
setnx(key,1)
当一个线程执行setnx返回1,说明key本来不存在,该线程成功获得了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。
2.解锁
有加锁就得有解锁。当获得锁的线程执行完任务,须要释放锁,以便其余线程能够进入。释放锁的最简单方式是执行del指令,伪代码以下:
del(key)
释放锁以后,其余线程就能够继续执行setnx命令来得到锁。
3.锁超时
锁超时是什么意思呢?若是一个获得锁的线程在执行任务的过程当中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程再也别想进来。
因此,setnx的key必须设置一个超时时间,以保证即便没有被显式释放,这把锁也要在必定时间后自动释放。setnx不支持超时参数,因此须要额外的指令,伪代码以下:
expire(key, 30)
综合起来,咱们分布式锁实现的初版伪代码以下:
if(setnx(key,1) == 1){
expire(key,30)
try {
do something ......
} finally {
del(key)
}
}
好端端的代码,怎么就回家等通知了呢?
由于上面的伪代码中,存在着三个致命问题:
1. setnx和expire的非原子性
设想一个极端场景,当某线程执行setnx,成功获得了锁:
setnx刚执行成功,还将来得及执行expire指令,节点1 Duang的一声挂掉了。
这样一来,这把锁就没有设置过时时间,变得“长生不老”,别的线程再也没法得到锁了。
怎么解决呢?setnx指令自己是不支持传入超时时间的,幸亏Redis 2.6.12以上版本为set指令增长了可选参数,伪代码以下:
set(key,1,30,NX)
这样就能够取代setnx指令。
2. del 致使误删
又是一个极端场景,假如某线程成功获得了锁,而且设置的超时时间是30秒。
若是某些缘由致使线程B执行的很慢很慢,过了30秒都没执行完,这时候锁过时自动释放,线程B获得了锁。
随后,线程A执行完了任务,线程A接着执行del指令来释放锁。但这时候线程B还没执行完,线程A实际上删除的是线程B加的锁。
怎么避免这种状况呢?能够在del释放锁以前作一个判断,验证当前的锁是否是本身加的锁。
至于具体的实现,能够在加锁的时候把当前的线程ID当作value,并在删除以前验证key对应的value是否是本身线程的ID。
加锁:
String threadId = Thread.currentThread().getId()
set(key,threadId ,30,NX)
解锁:
if(threadId .equals(redisClient.get(key))){
del(key)
}
可是,这样作又隐含了一个新的问题,判断和释放锁是两个独立操做,不是原子性。
咱们都是追求极致的程序员,因此这一块要用Lua脚原本实现:
String luaScript = 'if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end';
redisClient.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));
这样一来,验证和删除过程就是原子操做了。
3. 出现并发的可能性
仍是刚才第二点所描述的场景,虽然咱们避免了线程A误删掉key的状况,可是同一时间有A,B两个线程在访问代码块,仍然是不完美的。
怎么办呢?咱们可让得到锁的线程开启一个守护线程,用来给快要过时的锁“续航”。
当过去了29秒,线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命”20秒。守护线程从第29秒开始执行,每20秒执行一次。
当线程A执行完任务,会显式关掉守护线程。
另外一种状况,若是节点1 突然断电,因为线程A和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。
其实基于ZooKeeper,就是使用它的临时有序节点来实现的分布式锁。
原理
当某客户端要进行逻辑的加锁时,就在zookeeper上的某个指定节点的目录下,去生成一个惟一的临时有序节点, 而后判断本身是不是这些有序节点中序号最小的一个,若是是,则算是获取了锁。若是不是,则说明没有获取到锁,那么就须要在序列中找到比本身小的那个节点,并对其调用exist()
方法,对其注册事件监听,当监听到这个节点被删除了,那就再去判断一次本身当初建立的节点是否变成了序列中最小的。若是是,则获取锁,若是不是,则重复上述步骤。
当释放锁的时候,只需将这个临时节点删除便可。
如上图,locker是一个持久节点,node_1/node_2/.../node_n
就是上面说的临时节点,由客户端client去建立的。
client_1/client_2/.../clien_n
都是想去获取锁的客户端。以client_1为例,它想去获取分布式锁,则须要跑到locker下面去建立临时节点(假如是node_1)建立完毕后,看一下本身的节点序号是不是locker下面最小的,若是是,则获取了锁。若是不是,则去找到比本身小的那个节点(假如是node_2),找到后,就监听node_2,直到node_2被删除,那么就开始再次判断本身的node_1是否是序列中最小的,若是是,则获取锁,若是还不是,则继续找一下一个节点。