- redis实现分布式锁
利用
setnx
命令,这个命令是原子性操作,只有在key不存在的情况下才能设置成功。
- zookeeper分布式锁
利用
zookeeper
的顺序临时节点特性来实现分布式锁和等待队列。ZK在最初设计的时候就是为了实现分布式锁的,不过目前我们大多把它作为注册中心来用。
- 基于DB的锁
for update
悲观锁可以用来作为分布式锁,但是实际的生产过程中采用这种方法的非常少,性能比较低。
- Memcached分布式锁
有一个命令
add
是原子性的操作,和redis
中的setnx
类似,只有key不存在才设置成功。
1. Redis实现分布式锁
其实Redis实现分布式锁的流程很简单,只需要使用setnx(key,1)
返回1代表成功获取到锁,0代表线程抢夺锁失败,同时呢,加锁和解锁是对应的,当得到锁的那个线程执行完任务之后就需要释放锁,此时执行del(key)
即可,这个时候其他线程就可以继续执行setnx(key,1)
来争夺锁。但是想要优化到完美也是不容易的。我们需要考虑很多东西,比如锁超时:当刚刚那个获得到锁的线程在执行任务之后因为某些原因挂了,这个时候锁并没有被释放,这种情况我们一般会设置一个超时时间,使用expire(key , time)
这个指令。所以一般我们会简单的写了一个分布式锁的代码如下:
注:cacheService是自己写的一个操作redis的服务接口,将redis命令通过服务的方式提供出来。
private void test(){
//争夺锁
if(cacheService.setnx(key,value) == 1){
//设置过期时间expireSecond
cacheService.expire(key,expireSecond);
try{
//执行任务代码
}finally {
//释放锁
cacheService.del(key);
}
}
}
其实阅读上面的代码看似没什么问题的,实现了分布式锁。但是它存在一些弊端:
-
setnx
的操作和exipre
这两步并不是原子的,极端情况下,当线程执行setnx
成功获得了锁,还没执行exipre
就挂了,那么这个锁就会无法被其他线程得到了。这个问题在redis早期的版本中是会存在的。但是2.6.12
版本提供了一个可选的参数,在setnx
的时候多加了一个过期时间,这样来保证原子性(在底层实现的原子性)。
cacheService.setnx( key, expireSecond, value)
-
del
这个操作也是会有问题的,假如一个线程成功得到了锁,并且设置超时时间成功,这个时候因为许多原因在执行任务的时候耗时超过了设定的过期时间,这个时候锁就会被自动过期释放掉,其它线程会立马得到锁,这个时候最开始获得锁的线程执行del
指令可能会把刚刚得到锁的线程的锁给释放掉。这种情况可以在del
释放锁之前判断当先锁是不是自己的锁,可以在加锁的时候把当前线程id作为value,并且删除之前验证key对应的value是不是自己的线程id。如下代码:
//得到线程id
String threadId = Thread.currentThread().getId();
//获取锁
cacheService.setnx(key,expireSecond,threadId);
//释放锁
if(threadId.equals(cacheService.get((key)))){
del(key);
}
ps:如果继续深入会发现,释放锁又不是原子的课,如果追求完美的话可以使用Lua
脚本实现。这样以来就利用了Lua
脚本实现了原子性。
//得到线程id
String threadId = Thread.currentThread().getId();
//lua脚本
String lua= "if reids.call('get',KEYS[1]) == ARGV[1] then return redis.call(`del`,KEYS[1]) else return 0 end";
//单例执行
cacheService.eval(lua,Collections.singletonList(key),Collections.singletonList(threadId));
同时回到上面步骤,我们说过,当得到锁的线程没有执行完锁就过期了,此时会有另外的线程去竞争锁,这又违背了“同一时刻只有一个线程持有锁”的原则了。针对这种情况,我们可以继续进行一波优化,让获取锁的线程开启一个守护线程,用来给快要过期的锁"增加过期时间",比如我们设置了10s的过期时间,但是9s了还没执行完,这个时候守护线程执行expire
再让锁的过期时间变成10s,这个线程可以从第9s开始,每10s执行一次。当然,当执行完毕会释放锁,这个时候显示关闭守护线程即可。
2. 基于zk的分布式锁
zk的分布式锁是使用zk的临时顺序节点实现的,简单介绍下zk的节点:zk的数据存储结构就像一棵树,这些树由界定啊组成,这种节点叫做znode
,它分为四种类型:持久节点、持久节点顺序节点、临时节点、临时顺序节点。基本如下图所示
2.1 持久节点
是zk的默认的节点类型,创建节点的客户端即使与zk断接了链接这些节点依然存在。
2.2 持久节点顺序节点
所谓顺序节点,就是在创建节点时,zk根据创建的时间顺序给节点的名称一个编号,如上图中的青草,它的子节点可能有多个,分别命名青草1、青草2这种
2.3 临时节点
和持久节点相反,当创建节点的客户端与zk断开连接时临时节点也就没了
2.4 临时顺序节点
结合前面的介绍,临时顺序节点就是在创建节点时,zk根据创建的时间顺序给该节点名称进行一系列的编号,当创建节点的客户端与zk断开连接后,临时节点也会被删除。
基于zk分布式锁的原理
前面简单介绍了一下zk的节点类型,那么我们知道zk是基于临时顺序节点的特性实现分布式锁的,那么它是如何利用这个特性实现的呢?
获取锁
首先,在zk当中创建一个持久节点ParentLock
。当第一个客户端想要获得锁时,需要在ParentLock
这个节点下面创建一个临时顺序节点Lock1
,如下图所示。如果判断这个临时顺序节点为最前面的,代表获得成功,此时如果有客户端ClientB再来获取锁,则在ParentLock
下再创建一个临时顺序节点LockB
并排序,此时发现LockB
并非最小的,于是它会向它前面的节点LockA
注册Watcher
,来监听LockA
是否存在,这意味着没抢到锁,进入等待状态。同理ClientC
会向LockB
注册Watcher
然后进入等待状态。如果你学过AQS
,那么这种情况是不是很像一个AQS中的等待队列?
释放锁
- 当获取锁的线程
ClientA
的任务完成时,会显示调用删除节点Lock1
的指令。 - 若获得锁的
ClientA
在执行任务的过程中挂掉了,则会断开与zk服务端的连接,根据临时顺序节点的特性,LockA
也会随之被自动删除。此时因为ClientB
一直在监听着LockA
的存在状态,当LockA
节点被删除,ClientB
会立刻收到通知,此时ClientB
查询ParentLock
下面的所有节点确认LockB
是否为目前最前的节点,如果是最小,则ClientB
成功获得了锁。这样以来,是不是觉得zk的分布式锁比redis更简单?那么他们之间的比较我们来看一下
小结
redis的分布式锁依赖于setnx
和del
命令实现的,性能比较高,而zk的分布式锁是基于封装好了的框架比较容易实现,并且有等待锁的队列,大大提升抢锁的效率(公平锁),但是zk因为是基于一系列的节点,因为添加和删除节点性能比较低,而redis实现较为复杂,需要考虑超时、原子、误删等情况并且没有等待锁的队列m只能在客户端自旋来获得锁(有点像没有等待队列的非公平锁)。并且二者所持有的锁都是可重入锁。
3. 基于InnoDB引擎下的mysql数据库锁
悲观锁的思路
:在查询语句后面加上for update
,数据库会再查询过程中给数据库增加排它锁,当某条记录被加上排它锁后,其它线程就无法在该行记录上增加排它锁。这是一种悲观锁的思想,我认为一定会出现线程安全问题,所以我在获得锁的时候一定不要让其它线程也来干扰我。但是这种一单连接多了,DB连接池很可能被撑爆。另外spring自动提交事务的时候释放锁。
for update是在数据库上锁的,是排它锁,当一个事务的操作未完成的时候,其他事务可以读取但是不能写入或者更新。当这个事务回滚了或者提交了才会释放锁。
乐观锁的思路
:利用主键唯一的特性,如果多个请求同时提交的话,数据库会保证只有一个操作可以成功,那么我们认为操作成功的线程就得到了该方法的锁,当方法执行完毕删除记录就可以释放锁。他会出现几个问题:
- 这把锁强依赖DB,DB是一个单点,一旦DB挂掉,会导致业务系统不可用(DB可设置主从)
- 这把锁没有过期时间,一旦释放的时候出问题其他线程无法再次获得锁(定时任务,清理超时数据)
- 这把锁是非堵塞的,因为数据的
insert
操作,一旦失败就会报错。没有获得所的线程不会进入队列,想要再次获得所就需要再次触发获得锁的操作。(while(insert == true))- 这把锁是非重入的,同一个现场在没有释放锁之前无法再次得到这把锁(数据已经存在了)(记录线程信息即可,下次来的时候判断下)
- 这把锁是公平锁,素有线程都可以去
insert
(建立中间表存储等待线程)
6。 Mysql数据库采用主键冲突防止重复,大并发情况下可能锁表(在城程序中生产主键)。
RedLock的核心思想是同时使用多个Redis Master来冗余,且这些节点都是完全的独立的,也不需要对这些节点之间的数据进行同步。
假设有N个Redis节点,N大于2且是奇数,看下RedLock的实现步骤:
- 获取当前的时间
- 使用获取单个节点的锁的方法
setnx(key,value,expire)
,依次获取这N个节点的Redis锁。 - 如果获取到的锁的数量大于
N/2 + 1
,且获取的时间小于锁的有效时间,就任务获取到了一个有效的锁。锁自动释放时间就是expire 减去获取锁消耗的时间 - 如果获取锁的数量小于
N/2 + 1
,或者在锁的expire
内没有获取足够的锁,就认为获取锁释放,这个时候需要让所有节点发送释放锁的消息。
注意点:
如果Master节点故障后,恢复的时间间隔应大于锁的有效时间。用一个例子解释:
- 某集群有A1,A2,A3三个Redis节点
- 客户端C1获取到了A1,A2节点的锁
- A2宕机,内存数据丢失了
- A2节点恢复
- C2重新获取锁,得到A2,A3节点的锁
- 此时 C1、C2都获取到锁了,拿到了执行权限(当故障恢复的很快的情况下,如果A2恢复的很慢,自然而然锁会被释放的,还可以开启Redis持久化,如果对性能要求不高的话)
RedLock安全性分析
RedLock中为了防止死锁,每一把锁都是具备一个有效期的,这个过期时间很难去衡量它的大小,牛人Martin
给了一张图:
- 如果Client1在持有锁的时候,发生了一次很长时间的FGC,超过了锁的过期时间,Client持有锁到期就被释放了
- Client2也来获取了,因为Client1过期释放了,所以Client2能够直接获取到锁,此时Client2完成自己的操作提交了自己的操作结果和数据。
- 此时Client1从FGC中苏醒过来了,又一次提交数据。
这样就除了大问题了,锁的正确性被破坏了,而且FGC或者其它的网络堵塞等可能发生在任何时候,不能通过提交之前查询锁的持有来判断。
问题的提出总要解决,既然他说了这种方法不好,所以他有给了一个解决的方案(大牛精神):
fencing-tokens.png
如上图,他给出的方案,首先,每次获取锁也顺便会得到一个递增的token,如图
- Client1获取到了锁,并且也得到了一个token值为33,此时Client发生了FGC,并且在这段FGC的时间内锁的过期时间到了
- Client2直接获取到锁,并且得到token的值为34
- Client2提交了数据,此时token的值为34
- Client1提交数据的时候,需要判断token的大小,如果当前持有的token 小于现在的token就不能提交数据
注意看下步骤4,是不是乐观锁的思想,假设你是某值,如果是就更新,如果不是,就拒绝。
同时RedLock
是一个严重依赖时钟的分布式系统,分布式的节点系统时钟可能不统一的,比如在A1、A2、A3节点的时钟因为某种原因不统一了,也会造成锁的提前过期释放。举个例子,依然是有3个Redis节点A1、A2、A3
- C1获得A1、A2的锁,此时它得到了分布式锁
- 由于A1和A2的系统时钟不一致,导致A2提前释放
- C2获取A2、A3成功,也得到了锁
- 这个时候是不是有问题了,同时有C1、C2持有锁
总结一下Martin
感觉RedLock
不爽的地方
- 对于提升效率的场景下,
RedLock
太重了 - 对于正确性要求极高的场景下,RedLock并不能保证正确性
- 分布式锁具有自动释放的功能,锁的互斥性,只在过期时间之内有效,锁过期释放以后就会造成多个Client持有锁。
- RedLock是建立在一个实际系统无法保证的模型上。这个例子就是系统假设时间是同步且可信的。
网友评论