文章目录
分布式锁
一,为什么需要分布式锁
在单台服务器系统中,我们在解决并发问题时常用本地锁(synchronized,lock)去处理,但是在集群部署下,本地锁只能在单实例下起作用,服务和服务之前不能保证并发线程安全,所以,针对分布式环境下的并发问题就需要用到分布式锁去解决。
分布式锁特性:
- **互斥性:**分布式锁和本地锁都具有互斥性,只是分布式锁的互斥性要求是分布式环境下不同节点的不同线程的互斥
- **可重入性:**同一个节点的同一个线程获取到锁后可以再次获取锁
- **锁超时:**防止死锁
- **高效,高可用:**加锁和解锁需要高效率,高可用需要保证锁不会失效
- 支持阻塞和非阻塞,支持公平锁和非公平锁
常用的分布式锁实现:
- zookeeper实现分布式锁
- Redis实现分布式锁
- consul实现分布式锁
二,分布式锁实现
2.1 Redis实现分布式锁
加锁解锁原理
- 加锁底层利用redis的setnx key value 命令,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
- 解锁:Del key,通过删除键值对释放锁,以便其他线程可以通过setnx命令来获取锁
- 锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。
伪代码:
if(setnx(key,value) == 1){
expire(key,3000);
try{
//业务
}finally{
del(key);
}
}
实现
基于Redisson实现分布式锁,Redisson支持我们像操作本地锁一样去加锁和解锁
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>2.10.0</version>
</dependency>
基本编程模型:
public class TestRedisson {
public static void main(String[] args) {
// 创建redis客户端实例,默认配置是连接本地redis
RedissonClient redissonClient = Redisson.create();
// 获取锁
RLock lock = redissonClient.getLock("Redisson-test-key");
// 加锁
try {
if (lock.tryLock(1, TimeUnit.MINUTES)) {
// 处理业务逻辑
}
}catch (Exception ex){
ex.printStackTrace();
}finally {
// 解锁
lock.unlock();
}
}
}
深入探索Redisson加锁解锁原理
@Override
public boolean tryLock() {
return get(tryLockAsync());
}
protected <V> V get(RFuture<V> future) {
return commandExecutor.get(future);
}
@Override
public RFuture<Boolean> tryLockAsync() {
return tryLockAsync(Thread.currentThread().getId());
}
@Override
public RFuture<Boolean> tryLockAsync(long threadId) {
return tryAcquireOnceAsync(-1, null, threadId);
}
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
// 内部是通过执行lua脚本完成加锁,利用lua脚本执行的原子性
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// 可以看到内部并没有使用setnx命令去完成加锁,而是使用hash,k:key,v:锁重入次数
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
释放锁也是通过lua脚本实现:
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +//判断锁重入
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +//释放锁会发消息,通知还在阻塞等待的节点
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
不足
SETNX 和 EXPIRE 非原子性
如果忘记设置锁超时时间,在某节点通过setnx获取锁后,除非该节点主动del key,否则,将一直持有锁,其他节点将无法通过setnx完成加锁,造成死锁
- 解决方法:
- 记得设置锁超时时间
- 处理完业务逻辑后,记得主动释放锁
锁误消除
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
-
解决方法:
- 对key进行唯一处理,key一般是业务名,但是如果这样设置的话可能会出现锁误消除的问题,可以在业务名后+UUID(也可以是当前线程ID),标识当前线程,这样其他线程就无法误解锁
超时解锁导致并发
如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
- 将过期时间设置的足够长,确保加锁线程的业务逻辑能够正常执行完成
2.2 zookeeper实现分布式锁
加锁解锁原理
zk实现分布式锁主要通过临时节点和watch机制去实现
- 每一个业务资源占用一个普通节点,当需要占用这些业务资源时(即需要获取锁)则在该节点下新增临时节点,代表加锁成功
- watch机制监听业务资源的普通节点,如果监听到该节点下的临时节点被删除了,就是有线程释放锁了,其他等待线程可以重新竞争锁
实现
/**
* 获取锁
* @return
* @throws InterruptedException
*/
public boolean lock() throws InterruptedException {
String path = null;
ensureRootPath();
watchNode(nodeString,Thread.currentThread());
while (true) {
try {
path = zk.create(nodeString, "".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
} catch (KeeperException e) {
System.out.println(Thread.currentThread().getName() + " getting Lock but can not get");
try {
Thread.sleep(5000);
}catch (InterruptedException ex){
System.out.println("thread is notify");
}
}
if (!Strings.nullToEmpty(path).trim().isEmpty()) {
System.out.println(Thread.currentThread().getName() + " get Lock...");
return true;
}
}
}
/**
* 释放锁
*/
public void unlock(){
try {
zk.delete(nodeString,-1);
System.out.println("Thread.currentThread().getName() + release Lock...");
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
}
}
优缺点
优点:
- 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点:
- 性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器上。还需要对 ZK的原理有所了解。(其实就是ZK不支持高可用)