分布式锁的概念
当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
实现分布式锁的几种方式
- 数据库乐观锁;
- 基于Redis的分布式锁;
- 基于ZooKeeper的分布式锁。
分布式锁的核心
- 如何实现不同分布式场景下对资源的互斥操作?
- 锁释放机制异常导致锁不能被释放的问题?
- 如何实现可重入锁
基于redis实现分布式
一、加锁
主要需要是否存在锁,超时锁释放,不能被其他线程释放锁
所以进本命令可以使用 set key Id EX 30 NX ,原子操作来保证锁的正确使用
二、释放锁
事务完成之后,可以进行释放锁,主要步骤判断锁是否存在,这个锁是否是当前线程添加的,同时要求这个操作时原子性的,可以通过Lua脚本实现,调用redis.eval()方法,传入脚本,和Key,线程ID或者请求ID,通过Redis全局数组,Keys,Argv传递参数。
参考
参考
三、存在问题
- 在删除锁的时候出现误删除(可以通过lua脚本,判断是否是当前线程添加的锁,确认之后才能释放)
- 操作共享资源的时间超过设置锁的有效时间,如何实现对锁的续期操作
Redisson
- Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。
- Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
Redisson 中的分布式锁自带自动续期机制
redisson通过一个看门狗的机制实现对锁的续期操作,本质是通过lua脚本实现该逻辑
private void renewExpiration() {
//......
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//......
// 异步续期,基于 Lua 脚本
CompletionStage<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (e != null) {
// 无法续期
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
// 递归调用实现续期
renewExpiration();
} else {
// 取消续期
cancelExpirationRenewal(null);
}
});
}
// 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认)
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。
Redisson解决分布式锁核心问题
- 通过setNX实现资源的互斥
- 通过设置EX过期时间实现释放锁逻辑出错,可以实现锁被自动释放。但是会引入另一个问题,占用资源的时间比锁存活时间长,需要实现锁续期。Redisson通过看门狗的机制实现没10秒设置过期时间为30秒,实现续期。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。
- 实现可重入锁的机制,Redisson自带可重入锁类型API,也是通过判断当前线程是否是持有锁的线程,是的话count +1。
集成Redisson
依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.8</version> <!-- 请使用最新版本 -->
</dependency>
使用
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class DistributedLockService {
@Autowired
private RedissonClient redissonClient;
public void performTaskWithLock() {
RLock lock = redissonClient.getLock("myLock");
try {
// 尝试加锁,最多等待锁100秒,上锁后10秒自动解锁
boolean isLocked = lock.tryLock(100, 10, java.util.concurrent.TimeUnit.SECONDS);
if (isLocked) {
// 执行业务逻辑
System.out.println("Lock acquired, performing task...");
// 模拟业务逻辑
Thread.sleep(5000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 释放锁
lock.unlock();
System.out.println("Lock released.");
}
}
}
只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。
lock.lock();
手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
lock.lock(10, TimeUnit.SECONDS);
Redis 如何解决集群情况下分布式锁的可靠性?
Redis 集群下,上面介绍到的分布式锁的实现会存在一些问题。由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
红锁redlock可以解决该问题。
在使用Redis集群的时候,获取分布锁需要向集群中多个独立的的Redis节点申请锁,超过一半的节点成功,才算是加锁成功。
Redlock 是直接操作 Redis 节点的,并不是通过 Redis 集群操作的,这样才可以避免 Redis 集群主从切换导致的锁丢失问题。
但是Redlock 实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患。
Zookeeper实现分布式锁
Zookeeper的实现分布式锁的核心是znode和watch机制。
- 我们通常是将 znode 分为 4 大类:
- 持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。
- 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。
- 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001、/node1/app0000000002 。
- 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。
- Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。
Zookeeper解决分布式锁核心问题
- 通过在固定路径下注册临时顺序节点,然后判断该节点是不是路径下的第一个节点,是的话,表示获取锁成功,否则表示锁获取失败,会在前一个节点注册一个删除事件监听器watcher,开始等待锁。当前面的服务释放锁时,会将删除注册的节点,并唤醒注册watcher的服务。
- 由于注册的是临时节点,当session结束时,临时节点会被删除。
- Zookeeper的分布式锁也是需要通过代码逻辑判断是否是当前线程来实现的可重入锁。
通过使用 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();
总结
- 如果对性能要求比较高的话,建议使用 Redis 实现分布式锁。推荐优先选择 Redisson 提供的现成分布式锁,而不是自己实现。实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式实现分布式锁。
- 如果对可靠性要求比较高,建议使用 ZooKeeper 实现分布式锁,推荐基于 Curator 框架来实现。不过,现在很多项目都不会用到 ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper 的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。