- 为什么需要分布式锁?
在编写多线程程序时,为了避免同时操作进程中的共享变量,一般会使用一把“互斥锁”,确保该进程中共享变量的正确性,这把“互斥锁”就是与“分布式锁”相对应的“单机锁”。又或者是在同一台机器上的不同进程,想要同时操作一个共享资源(比如修改同一个文件),可以使用操作系统提供的“文件锁”或“信号量”来做互斥。
以上介绍的两种互斥操作,都仅限于线程、进程处于同一台机器上。如果是不同机器上的不同进程,要同时操作一个共享资源(比如修改数据库中的数据),如何做到互斥呢?
至此,“分布式锁”应运而生。
想要实现分布式锁,需要借住一个外部系统来管理锁。这个外部系统必须具备互斥的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败或等待。
外部系统可以是 MySQL、Redis、Zookeeper、Etcd。本片文章介绍的是 Redis。高并发业务场景下,Redis 做分布式锁可以有更高的性能。
- 如何基于 Redis 实现分布式锁?
在 Redis 中,SETNX 命令可以实现互斥。SETNX 命令表示 SET if Not eXists,如果 key 不存在,才会设置 key 的值,如果 key 存在,则 SETNX 什么也不做。
开启两个客户端进程先后执行这个命令,互斥效果如下:
客户端 1 申请加锁,加锁成功:
127.0.0.1:6379> SETNX lockKey uniqueValue
(integer) 1 // 客户端 1,加锁成功
客户端 2 申请加锁,加锁失败:
127.0.0.1:6379> SETNX lockKey uniqueValue
(integer) 0 // 客户端 2,加锁失败
其中,lockKey 表示加锁的锁名,uniqueValue 表示能够唯一标识锁的随机字符串。至于为什么需要 uniqueValue 这样的唯一字符串后面在介绍 Redis 分布式锁存在的第三个问题——释放被人的锁时会介绍到。这样一来,加锁成功的客户端 1 就可以去操作共享资源。例如:修改 MySQL 某一行数据,或调用某个 API 执行业务逻辑。操作完成后,需要及时释放锁,让后面的客户端进程也可以操作共享资源。
直接通过 DEL 命令删除对应的 key 就可以释放锁:
127.0.0.1:6379> DEL lockKey
(integer) 1 // 释放锁成功
上述加锁过程逻辑非常简单。但是,存在一个很大的问题,有【死锁】的可能:
- 加锁成功后,程序处理业务逻辑发生异常,没及时释放锁;
- 加锁成功后,进程挂了,没机会释放锁。
以上述加锁为例,此时的客户端 1 就会一直占用这把锁,客户端 2 就一直无法拿到这把锁,出现“锁饥饿”现象。
- Redis 分布式锁存在的第一个问题:死锁
对于第 1 种情况,程序在处理业务逻辑时发生异常,没及时释放锁。解决方案是对这段业务代码加上异常处理,保证无论业务逻辑是否异常,都可以把锁释放掉。在 GoLang 的 defer 语句和 Java 的 finally 语句种手动释放锁:
Go:defer redis.del(key)
Java:try … catch … finally:redis.del(key)
对于第 2 种情况,进程挂了,没机会释放锁。解决方案是对锁设置一个过期时间。语句如下:
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
上述语句的 EX 3 参数表示过期时间设置为 3 秒(EX 以秒为单位),3 秒后该锁自动释放。PX 30000 参数表示过期时间设置为 30 秒(PX 以毫秒为单位)。
加锁、设置过期时间本来是两条命令,不能保证原子操作,就仍然会存在【死锁】问题。所以在 Redis 2.6.12 之后,Redis 官方扩展了 SET 命令参数,把 NX/EX 集成到 SET 语句中,执行一条命令即可。
解决了【死锁】问题后,其实还存在其它的问题。
试想这样一种场景:
- 客户端 1 加锁成功,开始操作共享资源;
- 客户端 1 操作共享资源的时间,超过了锁的过期时间,锁被提前释放;
- 客户端 2 加锁成功,开始操作共享资源;
- 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)。
存在两个问题:一个是锁过期,客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有;另一个是释放别人的锁,客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁。
- Redis 分布式锁存在的第二个问题:锁过期
锁过期问题的本质就是客户端拿到锁之后,执行的业务逻辑所需的预估时间不准确导致的。如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效;如果锁的超时时间设置过长,又会影响到性能。
解决方案:加锁时,先设置一个过期时间,然后额外开启一个线程异步定时的检测锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就会自动对锁进行续期,重新设置过期时间。
- 如何实现锁的续期?
在 Redis 官网文档中可以找到各种变成语言的解决方案,比如 Java 中的 Redisson,Go 中的 Redsync,Redis 官方文档地址:Distributed Locks with Redis | Docs

本片文章着重介绍一下 Redisson 的实现原理,解读一下源码。
Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它提供了一个专门用来监控和续期锁的 Watch Dog(看门狗)机制,不断延长锁的过期时间,直到操作共享资源的线程执行完成。流程图如下:

下面介绍的源码版本是 redission-3.17.6 。
在 redisson/src/main/java/org/redisson/config/Config.java 文件中有个名为 getLockWatchdogTimeout() 的方法,看门狗名字由此而来。这个方法返回的是看门狗给锁续期的时间,默认值为 30 秒,源码如下:
// Redisson 配置类
public class Config {
// ...... 其它配置参数在此省略
// 默认续期 30 秒
private long lockWatchdogTimeout = 30 * 1000;
public Config() {}
public Config(Config oldConf) {
// ...... 其它初始化操作在此省略
// 初始化看门狗续约时间
setLockWatchdogTimeout(oldConfig.getLockWatchdogTimeout());
}
public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {
this.lockWatchdogTimeout = lockWatchdogTimeout;
return this;
}
public long getLockWatchdogTimeout() {
return lockWatchdogTimeout;
}
}
在 redisson/src/main/java/org/redisson/RedissonBaseLock.java 文件中有个名为 renewExpiration() 方法,包含了看门狗的主要逻辑。源码如下:
private void renewExpiration() {
// EXPIRATION_RENEWAL_MAP 是一个静态的 ConcurrentHashMap,全局存储所有锁的续期信息。
// key 是 String 类型,保存锁的唯一标识;value 是 ExpirationEntry 对象。
// getEntryName() 方法可以获取当前锁的唯一标识。
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask()) {
@Override
public void run(Timeout timeout) throws Exception {
// 根据锁名获取对应锁的信息
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
// 获取锁的线程 Id
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// 异步实现锁的续约
CompletionStage<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
// e 返回的是续约异常信息,可能是网络异常或者 Redis 服务器不可用导致的。
// 从 EXPIRATION_RENEWAL_MAP 中移除该锁的相关信息,不在续约,看门狗停止工作。
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
// res 返回的是正常续约对应的续约结果。
// true 表示续约成功;false 表示续约失败(锁已被删除或者锁不属于当前线程)
if (res) {
// 续约成功,递归调用,继续下一次续约
renewExpiration();
} else {
// 续约失败,取消续约
cancelExpirationRenewal(null);
}
});
}
// 延迟 internalLockLeaseTime / 3 (30 / 3,默认 10 s)再调用续约逻辑
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
默认情况下,每过 10 秒看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前会判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。
异步续期 renewExpirationAsync() 方法源码如下:
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"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));
}
在 renewExpirationAsync 方法中是调用 Lua 脚本实现的续约,可以保证续约的原子性。
接下来对 Lua 脚本进行完整解析:
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then -- 检查锁是否存在且属于当前线程
redis.call('pexpire', KEYS[1], ARGV[1]); -- 续期锁的过期时间
return 1; -- 返回续期成功
end;
return 0; -- 返回续期失败
命令KEYS[1] 对应锁名,ARGV[1] 标识锁的过期时间,ARGV[2] 表示锁的唯一标识。
通过 hexists 命令判断该锁是否为当前线程所持有;
通过 pexpire 命令将锁的过期时间重置为 ARGV[1] ,默认为 30 秒。
Java 中通过 Redisson 实现分布式可重入锁 RLock :
// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lockKey");
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁
- 如何实现可重入锁?
可重入锁是指在一个线程中可以多次获取同一把锁,比如一个线程在执行在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,无需重新获得锁。Java 中的 synchronized 和 ReentrantLock 都属于可重入锁。
在 RedissonBaseLock 类中的 ExpirationEntry 类中有个 addThreadId 方法,源码如下:
private final Map<Long, Integer> threadIds = new LinkedHashMap<>();
public synchronized void addThreadId(long threadId) {
threadIds.compute(threadId, (t, counter) -> {
counter = Optional.ofNullable(counter).orElse(0);
counter++;
return counter;
});
}
其中 threadIds 表示为每个锁关联一个可重入计数器。当可重入计数器大于 0 时,锁被认为被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。
addThreadId 方法通过 synchronized 保证线程安全,防止多个线程同时修改 threadIdsMap,确保重入
后面继续介绍下 Go 中的 Redsync,解读下源码:
待补充~~~
到此,回顾一下,上述内容介绍了 Redis 做分布式锁存在的第二个问题——锁过期。除此之外,Redis 还有第三个问题——释放别人的锁问题。
- Redis 分布式锁存在的第三个问题:锁被别人释放
重新审视一下问题现象:客户端 1 释放了客户端 2 持有的锁。问题本质是客户端释放锁时是无判断的,它并没有检查这把锁是否还归自己持有,所以会发生释放别人锁的风险。
前面在分析 renewExpiration() 方法时有提到对 res 续约结果做判断,当 res == false,说明该锁可能并不属于当前线程。除此之外,前面在介绍 SETNX 命令时,提到为什么设置的 lockKey 要配置一个唯一标识的 uniqueValue 随机字段串。目的就是让客户端加锁时,设置一个只有自己知道的唯一标识,后续用来判断锁是否还属于自己。
一般这个唯一标识 uniqueValue 采用 id +”:” + lockKey 组成,id 为自己的线程 Id,当然也可以一个 UUID
- Redis 分布式锁小结
至此,对前面所写进行小结,基于 Redis 实现的分布式锁,完整的过程如下:
- 加锁:SET lockKey uniqueValue EX expireTime NX
- 操作共享资源:异步开启定时器,对锁进行续约,直到业务逻辑操作共享资源结束
- 释放锁:基于 Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁
每个步骤存在的问题以及相应的解决措施:
- 死锁:原子命令给锁设置过期时间;
- 过期时间不好评估,导致锁提前释放:看门狗机制,异步定时续约;
- 锁被别人释放:锁写入唯一标识 uniqueValue,释放锁前先检查标识,再释放。
- Redis 如何解决集群情况下分布式锁的可靠性?
上面提到的 Redis 做分布式锁存在的三个问题是在单个 Redis 实例中可能出现的问题,在实际工程中一般会采用主从复制+哨兵模式部署。当主节点宕机后,哨兵节点可以实现故障转移,通过选举把从节点升级为主节点,保证高可用。
但是在 Redis 的集群模式下分布式锁会出现问题,试想这样的场景:
- 客户端 1 在 Redis 主节点上执行 SET 命令,加锁成功;
- 但是,Redis 主节点异常宕机,SET 命令还未同步到 Redis 从节点上(主从复制是异步的);
- Redis 从节点晋升为主节点时并不知道原主节点的锁,所以这把锁就丢失了,新 Redis 主节点还可以重复拿到该把锁。
Redis 的作者 antirez 提出了 Redlock 方案,Redlock 方案给出的配置如下:
- 不再需要部署从库和哨兵实例,只部署主库;
- 主库要部署多个,官方推荐至少 5 个实例。
Redlock 是直接部署至少 5 个彼此孤立的 Redis 实例,直接操作 Redis 节点,而不是部署 Redis 集群,非常的简单粗暴,但是最直观的缺点就是太重了,资源消耗极大。
Redlock 整体的流程如下:
- 客户端先获取【当前时间戳 T1】;
- 客户端依次向这 5 个 Redis 实例使用相同的 lockKey 和 uniqueValue 获取锁,即 SET 命令。值得注意的是,当向 Redis 申请锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该远小于锁的失效时间。假如锁的默认过期时间为 10 秒,那么超时时间应该在 5-50 毫秒之间。以保证某个 Redis 实例加锁失败时就立即向下一个 Redis 实例申请加锁;
- 如果客户端从 >= 3 个(半数)以上 Redis 实例加锁成功,则再次获取【当前时间戳 T2】。如果 T2 – T1 < 锁的过期时间,就认为客户端加锁成功,否则认为加锁失败;
- 加锁成功,可以操作共享资源,执行自己的业务逻辑(修改数据库等);
- 加锁失败,客户端应该在所有的 Redis 实例上进行解锁。
Redlock 的思想是只要大多数的节点加锁成功,就可以保证集群模式下的 Redlock 正常工作。
但是 Redlock 性能太差,服务器内部发生时钟变迁时会有很多安全性隐患。《数据密集型应用系统设计》作者 martin 曾公开质疑 antirez 的 Redlock 方案,两位大佬的争论,可以看一下红锁之争这篇文章,写的很清楚。
参考阅读:

No responses yet