目录
1、为什么要使用分布式锁
使用分布式锁的目的,无外乎就是保证同一时间只有一个客户端可以对共享资源进行操作。
根据锁的用途还可以细分为以下两类:
- 允许多个客户端操作共享资源
这种情况下,对共享资源的操作一定是幂等性操作,无论你操作多少次都不会出现不同结果。在这里使用锁,无外乎就是为了避免重复操作共享资源从而提高效率。 - 只允许一个客户端操作共享资源
这种情况下,对共享资源的操作一般是非幂等性操作。在这种情况下,如果出现多个客户端操作共享资源,就可能意味着数据不一致,数据丢失。比如在购物系统中,对于用户下单来说,对一件商品的购买同一时间只能一个人操作,比如说秒杀商品 下单减库存,否则多个用户同时下单同时减库存容易出现问题,比如说超卖。
2、分布式锁的技术有哪些
常见的有redis来实现分布式锁和zookpeer来实现分布式锁两种方式。
3、Redis分布式锁的实现方案
3.1、setnx实现方式
这是最普通的实现方式,就是在 redis 里使用 setnx 命令创建一个 key,执行下面这个命令就算加锁。
SET key random_value NX PX 30000
- random_value :value设置为随机值。
- NX:表示只有 key 不存在的时候才会设置成功。(如果此时 redis 中存在这个 key,那么设置失败,返回 nil)
- PX 30000:意思是 30s 后锁自动释放。别人创建的时候如果发现已经有了就不能加锁了。
释放锁就是删除key,为了保证删除key的原子性,一般使用lua脚本进行删除。删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。
if redis.call(“get”,KEYS[1]) == ARGV[1] then
return redis.call(“del”,KEYS[1])
else
return 0
end
将value设置随机值的原因就是假如A获取到了锁,由于执行逻辑时间长,超过锁过期时间,此时B获取到了锁,A执行完后会释放锁,如果不设置随机值,就会释放B获取到的锁,这是不对的。
缺点:单机下使用,容易会有单点故障,没有可用性,假如有主从复制,那么主挂掉,还没将锁复制到从节点,此时从节点切换为主节点,别人就可以 set key,从而拿到锁。就会有两个线程持有锁。
3.2、Redisson框架实现方式
Redis推荐使用redlock算法实现分布式锁,实现框架有Redisson,在集群下可以使用。人家还支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构。
实现原理:
(1)加锁机制
咱们来看上面那张图,现在某个客户端要加锁。如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。紧接着,就会发送一段lua脚本到redis上,为啥要用lua脚本呢?
因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。
hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:
myLock :{
”8743c9c0-0795-4907-87fd-6c719a6b4586:1 ”:1
}
上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。
接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。
好了,到此为止,ok,加锁完成了。
(2)锁互斥机制
那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?
首先会判断发现myLock这个锁key已经存在了。
接着再判断,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
所以,客户端2会进入一个while循环,不停的尝试加锁。
(3)watch dog自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?
这就用到了watch dog机制,只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
(4)可重入加锁机制
那如果客户端1都已经持有了这把锁了,客户端1再次加锁,就会执行可重入加锁的逻辑,他会用:
incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。
(5)释放锁机制
如果执行lock.unlock(),就可以释放分布式锁,每次释放锁都对myLock数据结构中的那个加锁次数减1。如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:“del myLock”命令,从redis里删除这个key。
然后客户端2就可以尝试完成加锁了。
这就是分布式锁Redisson框架的实现机制。
一般我们在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁。
缺点:
其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的redis slave实例。
但是这个过程中一旦发生redis master宕机,还没来得进行异步复制,就进行了主备切换,redis slave变为了redis master。
接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。
此时就会导致多个客户端对一个分布式锁完成了加锁。
这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。
所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
4、ZooKeeper分布式锁的实现方案
4.1、临时顺序节点加watch机制实现方式
zk一般使用curator去实现分布式锁——公平锁。
原理:向zk发起请求,在一个目录(/locks/pd_1_stock)下,创建一个临时节点,也是带有自己客户端的id。如果目录是空的,自己创建出来的节点就是第一个节点,那么加锁成功。如果成功执行则释放(节点删除)。如果宕机了,基于zk的心跳机制,那个临时节点也会被删除。第二个客户端请求锁时,也创建一个节点,如果不是第一节点,那么向上一节点加一个watcher监听器。如果上一节点被删除立马会感知到,然后在判断自己是不是第一节点,如果不是再监听上一级(公平实现)。完事后陷入等待,直到获取锁。
部分代码实现如下:
public boolean tryLock() {
//子节点
String nodeName=LOCK_NAME+"/zlu";
//創建子节点 默认匿名权限,临时顺序节点
try {
//Lock/zk_1
CURRENTNODE.set(zooKeeper.get().create(nodeName, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL));
//获取所有孩子节点排序
List<String> list = zooKeeper.get().getChildren(LOCK_NAME, false);
Collections.sort(list);
String minNodeName = list.get(0);
//如果当前节点是最小的节点,则反馈true 说明枷锁
if(minNodeName.equals(CURRENTNODE)){
return true;
}else{//否则 监听前一个节点
String currentSimpleNodeName = CURRENTNODE.get().substring(CURRENTNODE.get().lastIndexOf("/") + 1);
String preNodeName = list.get(list.indexOf(currentSimpleNodeName) - 1);
//用于阻塞 等到前一个节点删除事件完成后,才返回true
CountDownLatch count=new CountDownLatch(1);
zooKeeper.get().exists(LOCK_NAME+"/"+preNodeName, new Watcher() {
@Override
public void process(WatchedEvent event) {
//建庭前一个节点的删除事件
if(event.getType().equals(Event.EventType.NodeDeleted)){
count.countDown();
System.out.println(Thread.currentThread().getName()+"被唤醒");
}
}
});
System.out.println(Thread.currentThread().getName()+"被阻塞");
count.await();
return true;
}
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return false;
}
@Override
public void unlock() {
//-1表示忽略版本号 强制删除
try {
zooKeeper.get().delete(CURRENTNODE.get(),-1);
CURRENTNODE.set(null);
zooKeeper.get().close();
} catch (InterruptedException e) {
} catch (KeeperException e) {
e.printStackTrace();
}
}
缺点:Zk的分布式锁也会有一些缺点,极端情况下会出现脑裂问题,比如客户端A获取到了锁,但是由于网络原因,zk迟迟收不到A的心跳,则释放锁,此时,监听这个节点的客户端2加锁成功,两个客户端都认为自己持有锁,就会出现问题,脏数据等。
4.2、临时节点实现方式
如果使用临时节点+watch机制实现分布式锁——非公平锁,会是如下场景:
当几十个客户端争抢同一把基于zk的锁时,只有一个客户端能成功,并创建临时节点,然后别的客户端都回去监听这个节点,那么当释放锁时,zk会同时反向通知所有客户端又来重新争抢锁。
缺点:产生羊群效应,如果几十个客户端同时争抢一个锁,此时会导致任何一个客户端释放锁的时候,zk反向通知几十个客户端,几十个客户端又要发送请求到zk去尝试创建锁,所以大家会发现,几十个人要加锁,大家乱糟糟的,无序的。造成很多没必要的请求和网络开销,会加重网络的负载。
4.3、总结
综上所述,基于zk实现分布式锁的方案采用第一种curator实现的就好,通过监听上一级节点,降低了争抢次数,还实现了公平锁。
5、ZooKeeper分布式锁和redis分布式锁的对比
redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。
另外一点就是,如果是 redis 获取锁的那个客户端 出现 bug 挂了,那么只能等待超时时间之后才能释放锁;
而 zk 的话,因为创建的是临时 znode,只要客户端挂了,znode 就没了,此时就自动释放锁。
综上加锁的过程而言,zk比redis更加健壮。
但是zk做高并发的分布式锁比redis稍逊,因为zk不适合大规模集群部署
Zk的分布式锁的应用场景,主要高可靠,而不是太高并发的场景下。
在并发量很高,性能要求很高的场景下,推荐使用基于redis的分布式锁。
6、分布式锁的高并发优化方案
6.1、采用分段加锁方案
假如redis做分布式锁,单机抗几万并发没问题,如果并发量更大,可以使用如下方式:
假如有10万个并发请求,可以设置10台redis master,库存表中 库存字段分成10个 stock_o1,……stock_10,每个字段里面均匀存放库存量假如每个字段100个库存,一个请求过来,创建一个key product_商品id_stock_1到10的随机值 去请求redis机器,去锁定请求的库存字段,这样每台redis能够均匀的接受1万的请求
假如某个库存分段的库存量不够了,要买20个商品,锁定stock_05发现只有10个库存,不够 采用合并加锁的方式,再去锁定别的库存 发现stock_03有10个库存,加起来够了,就锁定这两个库存。
6.2、不用分布式锁方案
采用分段加锁太麻烦啦,我们可以采用使用nosql kv存储的方式,比如使用mongodb,redis(保证持久化),直接在redis中不停的扣减,当扣减到负数,超卖,就回滚刚才的扣减加回去,返回给用用通知,将扣减成功的请求 异步到mq中,库存系统拉取请求实现真正扣减库存。
6.3、总结
采用什么样的分布式锁需要根据具体业务具体场景进行分析。