分布式锁的实现方式
分布式锁实现方式
分布式锁一般可以通过Redis和Zookeeper两种中间件来实现。
1. Redis
1.1 原生Redis指令
SETNX指令(set if not exists)
【加锁方法】
SET key value NX EX timeout
【解锁方法】
如果场景简单无需value对比,那么可以直接del指令,否则需要通过一段lua脚本进行解锁
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
问题一:死锁问题,当客户端获取到锁后,客户端崩溃等问题导致失效时间没有设置成功就会导致一直持有锁
解决:保证SETNX命令和EXPIRE命令以原子的方式执行, 可以通过lua(包含SETNX + EXPIRE两条指令)或者SETNX支持失效时间
问题二:锁被其他线程释放
解决:锁必须要有一个拥有者的标记(可以通过value设置为uuid或者线程号等方式),并且也需要保证释放锁的原子性操作。或者使用不同的key
问题三:支持可重入
解决: 在加锁的时候记录加锁次数,在释放锁的时候减少加锁次数,这个就需要记录的value值使用hash数据结构了。使用lua脚本。
【加锁方法】
//锁不存在
if (redis.call('exists', key) == 0)
then
redis.call('hset', key, uuid, 1);
redis.call('expire', key, time);
return 1;
end;
//锁存在,判断是否是自己的锁
if (redis.call('hexists', key, uuid) == 1)
then
redis.call('hincrby', key, uuid, 1);
redis.call('expire', key, uuid);
return 1;
end;
//否则返回加锁失败
return 0;
【解锁方法】
//判断是否是自己的锁
if (redis.call('hexists', key,uuid) == 0)
then
return 0;
end;
//锁是自己的,则加锁次数-1
local counter = redis.call('hincrby', key, uuid, -1);
if (counter > 0)
then
//剩余加锁次数大于0,则不能释放锁,重新设置过期时间
redis.call('expire', key, uuid);
return 1;
else
//等于0,释放锁
redis.call('del', key);
return 1;
end;
问题四:自动续期,由于设置的过期时间太短或者业务执行时间太长导致锁过期,
解决: 在加锁成功时,开启定时任务,自动刷新Redis加锁key的超时时间。原生的方式越来越复杂了, 建议使用Redisson一劳永逸。
1.2 Redisson
通过引入Redisson实现分布式锁。Redisson源码中加锁/释放锁操作都是用Lua脚本完成的,还支持自动续期等功能封装的非常完善,开箱即用。
后期有时间单独写一篇源码解析
2. Zookeeper
2.1 原生ZooKeeper
大致思路是,首先创建一个持久节点/locks,请求进来时首先在/locks创建临时有序节点,然后判断当前创建得节点是不是/locks路径下面最小的节点,如果是,那么获取锁,不是则阻塞线程,同时设置监听器,监听前一个节点。获取到锁并处理完成业务逻辑后释放锁,删除掉当前节点。后一个节点就会收到通知,唤起线程,重复上面的判断。
注:只监听前一个节点主要是为了避免羊群效应。否则一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力。
//加锁路径
String lockPath;
//阻塞
CountDownLatch latch = new CountDownLatch(1);
//创建锁节点的路径
Sting LOCK_ROOT_PATH = "/locks"
//创建锁
public void createLock(){
lockPath = zkClient.create(LOCK_ROOT_PATH+"/lock_", CreateMode.EPHEMERAL_SEQUENTIAL);
}
//获取锁
public boolean acquireLock(){
//获取加锁路径下所有的锁节点
List<String> allLocks = zkClient.getChildren("/locks");
//按节点顺序大小排序
Collections.sort(allLocks);
//判断是否是第一个节点
int index = allLocks.indexOf(lockPath.substring(LOCK_ROOT_PATH.length() + 1));
//如果是第一个节点,则加锁成功
if (index == 0) {
// 获得锁成功
return true;
} else {
//不是序号最小的节点,则监听前一个节点
String preLockPath = allLocks.get(index - 1);
//创建监听器
Stat status = zkClient.exists(LOCK_ROOT_PATH + "/" + preLockPath, watchedEvent -> {
//监听到前一个节点释放锁,唤醒当前线程
latch.countDown();
});
// 前一个节点不存在了,则重新获取锁
if (status == null) {
return acquireLock();
} else {
// 阻塞当前线程,直到前一个节点释放锁
latch.await();
// 获得锁成功
return true;
}
}
}
2.2 Curator
引入curator包
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.3.0</version>
</dependency>
业务代码使用
InterProcessMutex lock = new InterProcessMutex(client, "/locks");
// 获取锁
// 获取锁有两种方式,一种是使用acquire()方法,该方法会一直阻塞直到获取到锁
lock.acquire();
// 另一种方式是使用tryAcquire()方法,该方法会尝试获取锁,如果获取成功则返回true,否则返回false
if (lock.tryAcquire()) {
// 获取锁成功
}
// 释放锁
// 使用release()方法释放锁:
lock.release();
ZooKeeper分布式锁虽然能有效的解决分布式锁问题,但是性能并不高。因为每次在加锁解锁的过程中,都要通过创建、销毁瞬时节点来实现。而ZooKeeper中创建和删除节点只能通过Leader服务器来执行,然后Leader服务器还需要将数据同不到所有的Follower机器上,这样频繁的网络通信,性能的短板是非常突出的。
总结
- 在高性能、高并发的场景下,由于ZooKeeper分布式锁性能不高不建议使用,推荐使用Redis分布式锁。
- 在并发量不高的场景,由于ZooKeeper的高可用特性,推荐使用ZooKeeper分布式锁。