分布式锁的设计问题
分布式锁设计时需要考虑的方向
- 死锁问题:占有锁的客户端挂掉或者网络不可达
- 脑裂问题:集群同步时数据不一致,导致旧进程占据锁时,新进程从 follower 节点也获取了锁
- 高可用问题:申请和释放锁时的高可用问题
- 锁超时释放问题:锁能够被动释放,此时超时时间的设定需要考虑好
- 可重入问题:获取锁之后可以重复再获取锁
- 公平与非公平问题:先来先用锁 or 抢占式使用锁。
- 复杂、性能、可靠性
关于锁的超时时间
- 锁的超时时间 > 任务执行时间 + 程序 GC 时间 + 网络传输时间
- 可以考虑通过续约的方式更新超时时间。具体为,先设置一个超时时间,然后启动一个守护线程,判断锁的情况,若锁快要失效时,再次续约加锁,当主线程执行完成后,销毁续约锁。但是这种方式相对复杂。具体参考 Redisson 的实现。
分布式锁的羊群效应
- Zookeeper 分布式锁竞争过程中,大量的进程都想要获得锁去使用共享资源。每个进程都有自己的“Watcher”来通知节点消息,都会获取整个子节点列表,使得信息冗余,资源浪费。当共享资源被解锁后,Zookeeper 会通知所有监听的进程,这些进程都会尝试争取锁,但最终只有一个进程获得锁,使得其他进程产生了大量的不必要的请求,造成了巨大的通信开销,很有可能导致网络阻塞、系统性能下降。
- 解决办法是顺序节点:1️⃣ 在与该方法对应的持久节点的目录下,为每个进程创建一个临时顺序节点;2️⃣ 每个进程获取所有临时节点列表,对比自己的编号是否最小,若最小,则获得锁;3️⃣ 若本进程对应的临时节点编号不是最小的,则注册 Watcher,监听自己的上一个临时顺序节点,当监听到该节点释放锁后,获取锁。
分布式锁的实现方式
不同存储的实现方式
- 基于关系数据库实现
- 方法:锁数据库中的某一行数据,如 ID = GoodsLock 行。
- 优点:简单。
- 缺点:数据库单点故障问题 + 客户端故障导致锁无法释放 + 性能差。
- 基于缓存实现
- 方法:setnx + 超时时间 + lua
- 优点:相较于 DB 性能更好 + 缓存跨集群部署,避免单点故障 + 支持超时机制。
- 缺点:主备异步复制下,主库 crash,数据没复制到备库 + 超时时间不易设置 + 网络分区导致脑裂
- 基于 Redission 实现
- 方法:基于 RedLock 算法来实现
- 优点:锁的类型多 + 超时时间优雅续约
- 缺点:Redis 集群脑裂导致同时获取锁
- 基于 ZooKeeper 实现
- 方法:Zookeeper 的临时节点,在 client 链接 session 断开后,临时节点被删除。同时采用顺序节点的思路,参考前文中提到的羊群效应解决办法。
- 优点:通过集群避免单点故障 + 无死锁问题。
- 缺点:实现复杂 + 频繁增删节点 + Watch 机制花销 + 存在同时获取锁
- 基于 Etcd 实现
- 缺点:存在同时获取锁 +
RedLock 同时获取锁的例子:如果集群中有 5 个节点 abcde。如果发生网络分区,abc 在一个分区,de 在一个分区,客户端 A 向 abc 申请锁成功。在 c 节点 master 异步同步 slave 的时候,master 宕机了,slave 接替,此时 c 的 slave 又和 de 在一个分区里。这时候如果客户端 B 来申请锁,也就可以成功了。
Zookeeper 同时获取锁的例子:获取锁的应用发生 GC,Zookeeper 连接断开,锁释放,另一个应用抢到锁。第一个应用 GC 恢复后继续执行,导致两个应用同时获得锁。(GC 断开连接是 JVM GC 时 STW 导致)
RedLock 算法
官方链接:RedLock 算法
- 基于多个独立的 Redis Master 节点的一种实现。client 依次向各个节点申请锁,若能从多数个节点中申请锁成功并满足一些条件限制,那么 client 就能获取锁成功。
- 基于多个独立的 Redis Master 节点工作,只要一半以上节点存活就能正常工作,同时不依赖 Redis 主备异步复制,具有良好的安全性、高可用性。然而它的实现依赖于系统时间,当发生时钟跳变的时候,也会出现安全性问题。
- 补充:分布式存储专家 Martin 对 RedLock 的分析文章,Redis 作者的也专门写了一篇文章进行了反驳
具体过程为:
- 客户端获取当前时间
- 客户端按顺序依次向 N 个 Redis 实例执行加锁操作。SET+NX+EX/PX+ID。这里需要设置 Redis 请求加锁的超时时间,几十毫秒。超时就下一个。
- 一旦客户端完成所有 Redis 实例加锁操作,计算总耗时。
客户端只有满足下面两个条件才认为加锁成功:
- 超过半数的 Redis 实例上获得锁;
- 总耗时没有超过锁有效时间。
若满足,需要计算锁的有效时间,如果有效时间来不及进行操作,则可以先释放锁。若没有满足,也发送释放锁。
基于 Redis 实现分布式锁
实现代码
lua 脚本实现分布式锁
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end基于 Redisson 实现分布式锁
Redisson 中实现了 Redis 分布式锁,且支持单点模式和集群模式。在集群模式下,Redisson 使用了 Redlock 算法,避免在 Master 节点崩溃切换到另外一个 Master 时,多个应用同时获得锁。
实现代码
1.首先引入 jar 包:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.8.2</version>
</dependency>2.实现 Redisson 的配置文件:
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useClusterServers()
.setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
.addNodeAddress("redis://127.0.0.1:7000).setPassword("1")
.addNodeAddress("redis://127.0.0.1:7001").setPassword("1")
.addNodeAddress("redis://127.0.0.1:7002")
.setPassword("1");
return Redisson.create(config);
}
3.获取锁操作:
long waitTimeout = 10;
long leaseTime = 1;
RLock lock1 = redissonClient1.getLock("lock1");
RLock lock2 = redissonClient2.getLock("lock2");
RLock lock3 = redissonClient3.getLock("lock3");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功,且设置总超时时间以及单个节点超时时间
redLock.trylock(waitTimeout,leaseTime,TimeUnit.SECONDS);
...
redLock.unlock();Redisson 优雅续期
看门狗名字的由来于 getLockWatchdogTimeout() 方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30 秒(redisson-3.17.6)。
//默认 30秒,支持修改
private long lockWatchdogTimeout = 30 * 1000;
public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {
this.lockWatchdogTimeout = lockWatchdogTimeout;
return this;
}
public long getLockWatchdogTimeout() {
return lockWatchdogTimeout;
}renewExpiration() 方法包含了看门狗的主要逻辑:
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);
}默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。
Watch Dog 通过调用 renewExpirationAsync() 方法实现锁的异步续期:
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));
}Redisson 多种锁类型
内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、红锁(RedLock)、读写锁(ReadWriteLock)。
基于 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();Curator 可重入锁
这里以 Curator 的 InterProcessMutex 对可重入锁的实现来介绍(源码地址:InterProcessMutex.java)。
当我们调用 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 就可以了。
基于 Etcd 实现分布式锁
21 分布式锁:为什么基于etcd实现分布式锁比Redis锁更安全?
总结
- 如果对性能要求比较高的话,建议使用 Redis 实现分布式锁。推荐优先选择 Redisson 提供的现成分布式锁,而不是自己实现。实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式实现分布式锁。
- 如果对可靠性要求比较高,建议使用 ZooKeeper 实现分布式锁,推荐基于 Curator 框架来实现。不过,现在很多项目都不会用到 ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper 的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。
需要注意的是,无论选择哪种方式实现分布式锁,包括 Redis、ZooKeeper 或 Etcd,都无法保证 100% 的安全性,特别是在遇到进程垃圾回收(GC)、网络延迟等异常情况下。
为了进一步提高系统的可靠性,建议引入一个兜底机制。例如,可以通过 版本号(Fencing Token)机制 来避免并发冲突。
最后,再分享几篇我觉得写的还不错的文章: