
相信大部分同学都使用过 Redisson 来操作 Redis,尤其是用它来实现分布式锁,但是有些小伙伴可能对 Redisson 实现分布式锁的原理不是很清楚,只知道怎么用,如何用,但是不清楚为什么要这么用,这篇文章就 Redisson 实现分布式锁讲透,一篇文章让你彻彻底底了解其核心原理。
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid),及基于Redis 实现的分布式工具集合。它不仅提供了一系列的分布式的Java常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock)、联锁(MultiLock)、红锁(RedLock)、读写锁(ReadWriteLock)等,还提供了许多分布式服务。
Redisson 的宗旨是促进使用者对 Redis 的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上,为每个试图再造分布式轮子的程序员带来了大部分分布式问题的解决办法。
功能特性:
Object Bucket,Bitset,AtomicLong,Bloom Filter 和 HyperLogLog 等。 org.redisson redisson 3.27.2 RedissonClient。RedissonClient有多种模式,主要的模式有: 程序化配置方法:
// 默认连接地址 127.0.0.1:6379 RedissonClient redisson = Redisson.create(); Config config = new Config(); config.useSingleServer().setAddress("myredisserver:6379"); RedissonClient redisson = Redisson.create(config); 配置参数:
SingleServerConfig singleConfig = config.useSingleServer(); 具体的参数配置:github.com/redisson/re…
程序化配置哨兵模式的方法如下:
Config config = new Config(); config.useSentinelServers() .setMasterName("mymaster") //可以用"rediss://"来启用SSL连接 .addSentinelAddress("127.0.0.1:26389", "127.0.0.1:26379") .addSentinelAddress("127.0.0.1:26319"); RedissonClient redisson = Redisson.create(config); 具体的参数配置见:github.com/redisson/re…
程序化配置主从模式的用法:
Config config = new Config(); config.useMasterSlaveServers() //可以用"rediss://"来启用SSL连接 .setMasterAddress("redis://127.0.0.1:6379") .addSlaveAddress("redis://127.0.0.1:6389", "redis://127.0.0.1:6332", "redis://127.0.0.1:6419") .addSlaveAddress("redis://127.0.0.1:6399"); RedissonClient redisson = Redisson.create(config); 具体的参数配置见:github.com/redisson/re…
程序化配置主从模式的用法:
Config config = new Config(); config.useClusterServers() .setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒 // 可以用"rediss://"来启用SSL连接 .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001") .addNodeAddress("redis://127.0.0.1:7002"); RedissonClient redisson = Redisson.create(config); 集群模式除了适用于 Redis 集群环境,也适用于任何云计算服务商提供的集群模式,例如 AWS ElastiCache 集群版、Azure Redis Cache 和阿里云(Aliyun)的云数据库 Redis 版。
redisson-spring-boot-starter 依赖 org.redisson redisson-spring-boot-starter 3.23.5 spring: data: redis: # 数据库 database: 0 # 主机 host: localhost # 端口 port: 6379 # 密码 password:123456 # 读超时 timeout: 5s # 连接超时 connect-timeout: 5s @Configuration public class RedissonConfig { @Autowired private RedisProperties redisProperties; @Bean public RedissonClient redissonClient() { Config config = new Config(); String redisUrl = String.format("redis://%s:%s", redisProperties.getHost() + "", redisProperties.getPort() + ""); config.useSingleServer().setAddress(redisUrl).setPassword(redisProperties.getPassword()); config.useSingleServer().setDatabase(3); return Redisson.create(config); } } 基于 Redis 的 Redisson 分布式可重入锁 RLock,它实现了 java.util.concurrent.locks.Lock。同时还支持自动过期解锁。使用最多的是下面三类方法:
lock.lock()lock.lock(10, TimeUnit.SECONDS):10 秒后自动释放锁,无需手动调用 unlock() 解锁。lock.tryLock(5, 10, TimeUnit.SECONDS):尝试加锁,最多等待 5 秒,加锁成功后,10 秒后自动释放锁。下面用示例验证它的可重入逻辑:
public class RedissonLockTest { RedissonClient redisson = Redisson.create(); RLock lock = redisson.getLock("reentrantLockTest"); @Test public void reentrantLock01Test() throws InterruptedException { boolean isLock = lock.tryLock(); if (isLock) { System.out.println(Thread.currentThread().getName() + " -- 获取锁成功..."); // 整理等待 30 秒是为了查看数据 TimeUnit.SECONDS.sleep(30); // 调用 reentrantLock02Test 第二次获取锁 reentrantLock02Test(); } } public void reentrantLock02Test() { boolean isLock = lock.tryLock(); if (isLock) { System.out.println(Thread.currentThread().getName() + " -- 获取锁成功..."); } } } 执行程序,当控制台第一次打印 “获取锁成功” 后,查看 Redis 数据:

第二次打印 “获取锁成功”:

Redisson 分布式锁采用了 Redis 的 hash 数据结构存储,key 为我们指定的值,field 属性为线程标识,value 为锁次数。当线程第一次获取时,此时 Redis 中没有这个 key,获取锁成功,创建锁数据并设置锁次数为 1。接下来如果线程再次获取锁,则先对比线程标识是否为同一个线程,如果是则重入,锁次数 + 1。
释放锁也需要同样对比线程标识,然后将所次数 -1 ,当锁的次数为 0 时,表示锁已完全释放。
Redisson 支持公平锁和非公平锁,上面的重入锁就是非公平锁。公平锁与 JUC 中的公平锁一致,遵循先到先得的原则。
Redisson 提供了 getFairLock() 来创建公平锁:
RLock fairLock = redisson.getFairLock("myFairLock"); 获取公平锁后,调用 lock() 即可获取锁:
fairLock.lock(); 公平锁一般适用于对锁的公平性要求较高的场景,例如任务调度、消息处理等。
联锁(RedissonMultiLock)是指同时对多个资源进行加锁操作,只有所有资源都加锁成功的时候,联锁才会成功。
Redisson 中的联锁是将多个 RLock 对象关联为一个联锁对象,实现加锁和解锁功能。每个 RLock 对象实例可以来自于不同的 Redisson 实例。
RLock lock1 = redissonClient.getFairLock("testLock1"); RLock lock2 = redissonClient.getFairLock("testLock2"); RLock lock3 = redissonClient.getFairLock("testLock3"); RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2, lock3); try { // 同时加锁:testLock1 testLock2 testLock3 // 所有的锁都上锁成功才算成功。 boolean tryLock = multiLock.tryLock(1, TimeUnit.SECONDS); if (tryLock) { // do something() } } catch (InterruptedException e) { throw new RuntimeException(e); } 与 Java 一样,Redisson 也提供了读写锁。读写锁是 Redisson 中的高级分布式锁,它分为读锁和写锁两种锁:
使用 Redisson 的 getReadWriteLock() 创建读写锁对象:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); 调用 readLock() 或者 writeLock() 获取读写锁:
// 获取读锁 RLock readLock = readWriteLock.readLock(); // 获取写锁 RLock writeLock = readWriteLock.writeLock(); Redlock 是 Redis 作者对分布式锁提出的一种加锁算法,其核心是:假设 Redis 集群中有 N 个 Redis 节点,只有当客户端成功在 N/2+1 个实例中成功加锁成功,才算成功持有分布式锁。
RLock lock1 = redissonClient.getLock("testLock1"); RLock lock2 = redissonClient.getLock("testLock2"); RLock lock3 = redissonClient.getLock("testLock3"); RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3); redLock.lock(); 如果任务的执行时间比锁的超时时间还长,这种情况会导致锁过早被释放了,从而会让其他线程在当前线程的任务完成之前获取到锁,这就会引发线程安全问题。为了解决这个问题,一般有如下几种解决方案:
最常见有效的方案是实现一个锁续租机制。也就是在任务执行期间,会定期更新锁的过期时间。确保锁在整个任务执行期间保持有效。Redisson 提供了 watch dog 机制(看门狗),该机制具备锁自动续期功能,用于避免分布式锁在业务处理过程中因执行时间过长而被提前释放。watch dog会自动检测用户线程是否还活着,如果活着,它会在锁快要自动释放之前自动续期,直到用户线程完成工作。
预估一个任务的最长执行时间,然后将所的超时时间设置更长一点,已覆盖这个时间范围。但是这种方案有几个缺陷:绝大部分任务的执行时间都会比预估的最长超时时间短,如果某个线程中途崩溃了,导致锁无法正常释放,这就会降低系统的并发性。
再获取锁后,检查任务的执行状态,如果仍然有任务在运行,则在那里等待。
我们可以将一个长时间执行的任务拆分为多个独立的较短的小任务,每个步骤都有自己独立的分布式锁,这样就可以减少锁定资源的时间,同时确保每个阶段都能在适当的时间内完成。
这里详细介绍 Redisson 的看门狗机制。
Redisson 的 watch dog 的核心思想是在 Redisson 客户端获取到锁后,会自动启动一个监控任务,该任务会定期检查锁的状态,并在需要时自动延长锁的过期时间。其核心机制有如下几点:
watch dog 会每隔一段时间(默认是锁有效期的 1/3,即 10 秒)自动将锁的有效期重新设置为最初的有效期(默认 30 秒),直到锁被释放。这个操作是通过一个后台线程完成的,它确保了即使客户端处理逻辑较长也不会因为锁自动过期而导致锁被提前释放。watch dog 会停止续期,锁会在最后一次续期后的有效期内自动释放掉。watch dog 每 10 秒续期一次,每次续期 30 秒。下面看看 Redisson 的 watch dog 源码。
源码路径如下:lock() —> tryAcquire() —> tryAcquireAsync():
private RFuture tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { RFuture ttlRemainingFuture; // leaseTime > 0:表示指定了锁定时间,则直接加锁 if (leaseTime > 0) { ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } else { // 没有指定锁定时间,默认加锁时间为 internalLockLeaseTime ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG); } CompletionStage s = handleNoSync(threadId, ttlRemainingFuture); ttlRemainingFuture = new CompletableFutureWrapper<>(s); CompletionStage f = ttlRemainingFuture.thenApply(ttlRemaining -> { // lock acquired if (ttlRemaining == null) { if (leaseTime > 0) { // leaseTime > 0 ,不使用自动续期 internalLockLeaseTime = unit.toMillis(leaseTime); } else { // 自动续期 scheduleExpirationRenewal(threadId); } } return ttlRemaining; }); return new CompletableFutureWrapper<>(f); } leaseTime > 0:说明我们调用加锁方法时指定的锁过期时间,这个时候是不会开启 watch dog 机制,直接设置过期时间即可。
如果没有指定过期时间,则使用 internalLockLeaseTime 为过期时间,该值通过 getServiceManager().getCfg().getLockWatchdogTimeout() 获取 lockWatchdogTimeout 的值,默认为 30 秒:
private long lockWatchdogTimeout = 30 * 1000; 当然也可以调用 setLockWatchdogTimeout() 设置 watch dog 默认时间。
只有当 leaseTime == -1 时才会调用 scheduleExpirationRenewal() 开启自动续期进程:
protected void scheduleExpirationRenewal(long threadId) { ExpirationEntry entry = new ExpirationEntry(); ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry); if (oldEntry != null) { oldEntry.addThreadId(threadId); } else { entry.addThreadId(threadId); try { renewExpiration(); } finally { if (Thread.currentThread().isInterrupted()) { cancelExpirationRenewal(threadId, null); } } } } scheduleExpirationRenewal() 首先会将该续期任务添加到 EXPIRATION_RENEWAL_MAP 集合中,EXPIRATION_RENEWAL_MAP 是 Redisson 用来管理锁续期任务的集合,其作用是跟踪当前正在被自动续期的锁。
在 scheduleExpirationRenewal() 中调用 renewExpiration()开启自动续期定时任务:
private void renewExpiration() { ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ee == null) { return; } Timeout task = getServiceManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ent == null) { return; } Long threadId = ent.getFirstThreadId(); if (threadId == null) { return; } CompletionStage future = renewExpirationAsync(threadId); future.whenComplete((res, e) -> { if (e != null) { log.error("Can't update lock {} expiration", getRawName(), e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } if (res) { // reschedule itself renewExpiration(); } else { cancelExpirationRenewal(null, null); } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); } 从 renewExpiration() 可以看出,Redisson 是使用了一个 TimerTask 定时任务去执行续期任务的,delay 为 internalLockLeaseTime / 3。在该定时任务中调用 renewExpirationAsync() 完成续期:
protected CompletionStage renewExpirationAsync(long threadId) { return evalWriteSyncedAsync(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)); } 这里是使用 lua 脚本调用 pexpire 命令来进行续期。
然而,在 TimerTask 里面它并不是无脑地调用 renewExpirationAsync() 来续期的,这里会有两个判断:
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ent == null) { return; } Long threadId = ent.getFirstThreadId(); if (threadId == null) { return; } ent == null 表示该自动续期任务已经被释放了,当调用 unlock() 时,Redisson 会 remove 掉这个任务:
protected void cancelExpirationRenewal(Long threadId, Boolean unlockResult) { ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (task == null) { return; } if (threadId != null) { task.removeThreadId(threadId); } if (threadId == null || task.hasNoThreads()) { Timeout timeout = task.getTimeout(); if (timeout != null) { timeout.cancel(); } EXPIRATION_RENEWAL_MAP.remove(getEntryName()); } } 虽然 Redisson 的看门狗机制能够解决锁自动续期的问题,但是它是单机的,单机就存在两个问题:
Redis 官网也提到了这些问题:

那怎么解决呢?Redis 作者提出 RedLock 解决方案。
RedLock 是 Redis 作者提出的一个多节点分布式锁算法,它主要是解决单节点 Redis 分布式锁可能存在的单点故障问题。其核心思想是:不在单个 Redis 实例上进行加锁,而是在多个互相独立的 Redis 节点加锁,只有在大多数节点上解锁成功,锁才算获取成功。其核心原理如下:
RedLock 不再是在单个 Redis 节点加锁,而是在多个互相独立的 Redis 节点加锁(通常是基数个,避免脑裂),这些节点彼此直接不是主从关系,也不是集群。N/2+1),则认为获取锁成功。如下:

RedLock 获取锁过程如下(假如有 5 个 Redis 节点):
≥ 3 个 Redis 节点获取锁成功,客户端再次获取当前时间戳 T2,如果 T2 - T1 < 锁的过期时间,则获取 RedLock 成功。Redisson 提供了 RedLock 的实现,直接用 RedissonRedLock 即可:
@Test public void redissonRedLockTest() { Config config1 = new Config(); config1.useSingleServer().setAddress("redis://127.0.0.1:6379"); RedissonClient redissonClient1 = Redisson.create(config1); Config config2 = new Config(); config2.useSingleServer().setAddress("redis://127.0.0.2:6380"); RedissonClient redissonClient2 = Redisson.create(config2); Config config3 = new Config(); config3.useSingleServer().setAddress("redis://127.0.0.3:6381"); RedissonClient redissonClient3 = Redisson.create(config3); RLock rLock1 = redissonClient1.getLock("lock1"); RLock rLock2 = redissonClient2.getLock("lock2"); RLock rLock3 = redissonClient3.getLock("lock3"); RedissonRedLock redLock = new RedissonRedLock(rLock1, rLock2, rLock3); boolean lockResult = redLock.tryLock(); if (lockResult) { try{ //.... } finally { redLock.unlock(); } } } 到这里了,是不是小伙伴们认为 RedLock 就万无一失了?其实不然。Redis 作者 Antirez 提出 RedLock 方案后,立刻就遭到英国剑桥大学、业界著名的分布式系统专家 Martin 的质疑!他认为 Antirez 提出的 RedLock 算法模型有问题,写了一篇文章列出 RedLock 的算法问题,并提出了自己的看法。而 Antirez 也不甘示弱,也写了一篇文章来反驳。
两位大神的原文:
下面的内容是对这两篇文章的解读。
在 Martin 大神的文章中主要是阐述了 4 点:
Martin 表示我们使用 Redis 来实现分布式锁的主要目的是两点。
Martin 认为,如果是为了效率,单机版的 Redis 效率更高,即使发生偶尔的宕机也不会产生很严重的问题。使用 RedLock 太重了,没有必要。Martin 认为 RedLock 根本无法达到安全的效果,会存在锁失效的情况。所以,无论是效率还是正确性,Martin 认为 RedLock 都达不到。
Martin 表示,一个分布式系统,存在着各种异常情况,这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC。
N:Network Delay,网络延迟P:Process Pause,进程暂停C:Clock Drift,时钟漂移Martin 使用了一个进程暂停的例子来说明,具体过程如下:
A、B、C、D、EA、B、C、D、E如下图:

需要注意的是,不仅仅只是 GC 导致的暂停,任何可以造成系统停顿的因素都会导致这种情况产生,比如 I/O 、网络阻塞等等。
Martin 指出一个优秀的分布式系统应该基于异步模型,简单概括就是不对时间做任何假设,不能使用时间来作为安全保障。因为在分布式系统中会有程序暂停、数据包延迟、系统时间错误。而一个好的分布式系统不会因为这些因素影响锁的安全性,只可能影响到它的活性(liveness property)。也就是说在极端情况下优秀的分布式锁顶多是不能在有限的时间内给出结果,但不能给出一个错误的结果,这样的算法是真实存在的如Raft、Zab 和 Paxos等等。
但是,RedLock 严重依赖依赖系统时钟,因为在 RedLock 的实现中,它是依赖锁的过期时间的,如果多个 Redis 实例的时钟不一致,则会导致如下这种情况:
A、B、C、D、EA、B、C 三个节点的锁,获得分布式锁A、D、E,获得分布式锁而机器发生时钟漂移的概率还是有的,比如:
针对 RedLock 的缺陷,Martin 提出了自己的解决方案:fecing token。
Martin 的解决方案是为锁资源增加一个递增的 token 用来保证分布式锁的安全性:

token。如在上图 Client1 除了获取锁外,还获得了一个值为 33 的 token 。Client1 因为 STW 暂停导致锁被释放了,Client2 获取锁后使用 token = 34 去操作共享资源Martin 认为 fecing token 方案无论是碰到分布式中 NPC 的那种情况,都能够保证分布锁的安全性,因为它是建立在异步模型的。
针对 Martin 的质疑,Antirez 做出来以下几点反驳。
针对 Martin 提出的时钟错误问题,Antirez 反驳道:
严格上来说,RedLock 是建立在可信的时钟模型上的,在现实情况下确实是会存在一些时钟错误的情况,但是我们可以通过一些运维手段或者工程机制最大限度保证时钟可信。
针对线程暂停的问题,我们再次回顾 RedLock 获取锁的过程:
≥ 3 个 Redis 节点获取锁成功,客户端再次获取当前时间戳 T2,如果 T2 - T1 < 锁的过期时间,则获取 RedLock 成功。在这个步骤中,RedLock 会两次获取时间戳。如果线程暂停是发生在获取 T 时间戳前,那么是可以通过 T2 - T1 < 锁的过期时间 检测出来的。如果超出了锁的过期时间,则会被认为获取锁失败,所以这种情况是可以避免的。
如果线程暂停是发生客户端 1 获取分布锁成功后,导致其他线程能够获取分布式锁产生锁冲突。那这就不是 RedLock 所负责的范畴了,RedLock 只提供的正确的分布式锁,而且这种情况其他的分布式锁服务(如Zookeeper)也是无法避免的。
Martin 提供的fecting token 方案需要共享资源具备拒绝旧 token 的能力,试想下,如果共享资源就具备这种互斥能力,那还需要分布式锁干嘛?
由于 RedLock 存在争议,Redis 官方已经标记 RedLock 算法为 “discouraged”:

更新记录如下:

所以在实际生产环境下还是尽量不要使用 RedLock 。对于大多数的场景而言,使用 Redisson 的普通锁就可以了,如果项目对分布式锁的安全性要求很高,推荐使用基于 Raft 或 Paxos 算法的 etcd 或 ZooKeeper,他们在设计时充分考虑了分布式环境下的一致性和可靠性问题,提供了比 RedLock 更为健壮的解决方案。