基于Redis的分布式锁
SETNX
在Redis中,SETNX(SET if Not eXists)命令,如果Key不存在的话,才会设置Key的值;否则啥也不做。
该命令通过Lua脚本来实现,是为了保证解锁操作的原子性。因为Redis在执行Lua脚本时,可以以原子性的方式执行。
缺点
因为没有设置过期时间,导致锁无法释放
SETNX EX
解决了过期释放的问题
缺点
如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能
Redisson
Redisson是一个开源的Java语言客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
自带自动续期机制,其提供了一个专门用来监控和续期锁的Watch Dog(看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
看门狗给锁续期的过期时间,默认为30s
默认情况下,每过10s。看门狗就会执行续期操作,将锁定额超时时间设置为30s,在执行前会先判断是否执行续期操作,需要时才会执行续期,否则取消续期操作
// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lock");
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁
lock.unlock();
当未指定锁超时时间,才会使用到Watch Dog自动续期机制
// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
lock.lock(10, TimeUnit.SECONDS);
可重入锁
Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。
分布式场景下锁的可靠性
通过Redlock算法来解决
Redlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
即使部分 Redis 节点出现问题,只要保证 Redis 集群中有半数以上的 Redis 节点可用,分布式锁服务就是正常的。
Redlock 是直接操作 Redis 节点的,并不是通过 Redis 集群操作的,这样才可以避免 Redis 集群主从切换导致的锁丢失问题。
缺点
Redlock 实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患
实际项目中不建议使用 Redlock 算法,成本和收益不成正比
如果你必须要实现一个绝对可靠的分布式锁的话,可以基于 ZooKeeper 来做,只是性能会差一些。
基于ZooKeeper的分布式锁
Redis 实现分布式锁性能较高,ZooKeeper 实现分布式锁可靠性更高。实际项目中,我们应该根据业务的具体需求来选择
如何基于Zookeeper实现分布式锁
获取锁
- 首先我们要有一个持久节点/locks,客户端获取锁就是在locks下创建临时顺序节点
- 假设客户端 1 创建了/locks/lock1节点,创建成功之后,会判断 lock1是否是 /locks 下最小的子节点
- 如果 lock1是最小的子节点,则获取锁成功。否则,获取锁失败
- 如果获取锁失败,则说明有其他的客户端已经成功获取锁。客户端 1 并不会不停地循环去尝试加锁,而是在前一个节点比如/locks/lock0上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端1(避免无效自旋),这样客户端 1 就加锁成功了
释放锁
- 成功获取锁的客户端在执行完业务流程之后,会将对应的子节点删除
- 成功获取锁的客户端在出现故障之后,对应的子节点由于是临时顺序节点,也会被自动删除,避免了锁无法被释放
- 我们前面说的事件监听器其实监听的就是这个子节点删除事件,子节点删除就意味着锁被释放
实际项目中,推荐使用 Curator 来实现 ZooKeeper 分布式锁。Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 ZooKeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用
Curator主要实现了下面四种锁
- InterProcessMutex:分布式可重入排它锁
- InterProcessSemaphoreMutex:分布式不可重入排它锁
- InterProcessReadWriteLock:分布式读写锁
- InterProcessMultiLock:将多个锁作为单个实体管理的容器,获取锁的时候获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)
CuratorFramework client = ZKUtils.getClient();
client.start();
// 分布式可重入排它锁
InterProcessLock lock1 = new InterProcessMutex(client, lockPath1);
// 分布式不可重入排它锁
InterProcessLock lock2 = new InterProcessSemaphoreMutex(client, lockPath2);
// 将多个锁作为一个整体
InterProcessMultiLock lock = new InterProcessMultiLock(Arrays.asList(lock1, lock2));
if (!lock.acquire(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("不能获取多锁");
}
System.out.println("已获取多锁");
System.out.println("是否有第一个锁: " + lock1.isAcquiredInThisProcess());
System.out.println("是否有第二个锁: " + lock2.isAcquiredInThisProcess());
try {
// 资源操作
resource.use();
} finally {
System.out.println("释放多个锁");
lock.release();
}
System.out.println("是否有第一个锁: " + lock1.isAcquiredInThisProcess());
System.out.println("是否有第二个锁: " + lock2.isAcquiredInThisProcess());
client.close();
为什么要用临时顺序节点
每个数据节点在Zookeeper中被称为znode,它是Zookeeper中数据的最小单元。
我们通常是将znode氛围4大类
- 持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除
- 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点
- 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001、/node1/app0000000002
- 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性
可以看出,临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。
使用 Redis 实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而 ZooKeeper 直接利用临时节点的特性即可。
假设不使用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁,这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。
为什么要设置对前一个节点的监听
同一时间段内,可能会有很多客户端同时获取锁,但只有一个可以获取成功。如果获取锁失败,则说明有其他的客户端已经成功获取锁。获取锁失败的客户端并不会不停地循环去尝试加锁,而是在前一个节点注册一个事件监听器(防止循环加锁)
监听器的作用是:当前一个节点对应的客户端释放锁之后(也就是前一个节点被删除之后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java 中的 wait/notifyAll ),让它尝试去获取锁,然后就成功获取锁了
如何实现可重入锁
通过 Curator 的 InterProcessMutex 对可重入锁的实现来介绍
当我们调用 InterProcessMutex#acquire方法获取锁的时候,会调用InterProcessMutex#internalLock方法
// 获取可重入互斥锁,直到获取成功为止
@Override
public void acquire() throws Exception {
if (!internalLock(-1, null)) {
throw new IOException("Lost connection while trying to acquire lock: " + basePath);
}
}
internalLock 方法会先获取当前请求锁的线程,然后从 threadData( ConcurrentMap<Thread, LockData> 类型)中获取当前线程对应的 lockData 。 lockData 包含锁的信息和加锁的次数,是实现可重入锁的关键。
第一次获取锁的时候,lockData为 null。获取锁成功之后,会将当前线程和对应的 lockData 放到 threadData 中
private boolean internalLock(long time, TimeUnit unit) throws Exception {
// 获取当前请求锁的线程
Thread currentThread = Thread.currentThread();
// 拿对应的 lockData
LockData lockData = threadData.get(currentThread);
// 第一次获取锁的话,lockData 为 null
if (lockData != null) {
// 当前线程获取过一次锁之后
// 因为当前线程的锁存在, lockCount 自增后返回,实现锁重入.
lockData.lockCount.incrementAndGet();
return true;
}
// 尝试获取锁
String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
if (lockPath != null) {
LockData newLockData = new LockData(currentThread, lockPath);
// 获取锁成功之后,将当前线程和对应的 lockData 放到 threadData 中
threadData.put(currentThread, newLockData);
return true;
}
return false;
}
LockData是 InterProcessMutex中的一个静态内部类。
private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();
private static class LockData
{
// 当前持有锁的线程
final Thread owningThread;
// 锁对应的子节点
final String lockPath;
// 加锁的次数
final AtomicInteger lockCount = new AtomicInteger(1);
private LockData(Thread owningThread, String lockPath)
{
this.owningThread = owningThread;
this.lockPath = lockPath;
}
}
如果已经获取过一次锁,后面再来获取锁的话,直接就会在 if (lockData != null) 这里被拦下了,然后就会执行lockData.lockCount.incrementAndGet(); 将加锁次数加 1。
整个可重入锁的实现逻辑非常简单,直接在客户端判断当前线程有没有获取锁,有的话直接将加锁次数加 1 就可以了。
基于数据库的
- MySQL的主键
- Select *** for updete
总结
- 如果对性能要求比较高的话,建议使用 Redis 实现分布式锁(优先选择 Redisson 提供的现成的分布式锁,而不是自己实现)。
- 如果对可靠性要求比较高的话,建议使用 ZooKeeper 实现分布式锁(推荐基于 Curator 框架实现)。不过,现在很多项目都不会用到
ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper
的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。
参考
https://mp.weixin.qq.com/s/JzCHpIOiFVmBoAko58ZuGw