分布式锁的设计问题

分布式锁设计时需要考虑的方向

  • 死锁问题:占有锁的客户端挂掉或者网络不可达
  • 脑裂问题:集群同步时数据不一致,导致旧进程占据锁时,新进程从 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 作者的也专门写了一篇文章进行了反驳

具体过程为:

  1. 客户端获取当前时间
  2. 客户端按顺序依次向 N 个 Redis 实例执行加锁操作。SET+NX+EX/PX+ID。这里需要设置 Redis 请求加锁的超时时间,几十毫秒。超时就下一个。
  3. 一旦客户端完成所有 Redis 实例加锁操作,计算总耗时。

客户端只有满足下面两个条件才认为加锁成功:

  1. 超过半数的 Redis 实例上获得锁;
  2. 总耗时没有超过锁有效时间。

若满足,需要计算锁的有效时间,如果有效时间来不及进行操作,则可以先释放锁。若没有满足,也发送释放锁。

基于 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> 类型)中获取当前线程对应的 lockDatalockData 包含锁的信息和加锁的次数,是实现可重入锁的关键。

第一次获取锁的时候,lockDatanull。获取锁成功之后,会将当前线程和对应的 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;
}

LockDataInterProcessMutex中的一个静态内部类。

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)机制 来避免并发冲突。

最后,再分享几篇我觉得写的还不错的文章:

参考链接