死磕 Redis - 一文说透 Redisson 实现分布式锁,让你不再疑惑!!

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。

本文已收录到我的技术网站:www.skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经


大明哥相信大部分的 Java boy 都使用过 Redisson 来操作 Redis,尤其是用它来实现分布式锁,但是有些小伙伴可能对 Redisson 实现分布式锁的原理不是很清楚,只知道怎么用,如何用,但是不清楚为什么要这么用,这篇文章,大明哥就 Redisson 实现分布式锁讲透,一篇文章让你彻彻底底了解其核心原理。

Redisson 是什么

Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock)、联锁(MultiLock)、红锁(RedLock)、读写锁(ReadWriteLock)等,还提供了许多分布式服务。

Redisson 的宗旨是促进使用者对 Redis 的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上,为每个试图再造分布式轮子的程序员带来了大部分分布式问题的解决办法。

通俗点来讲,Redisson 就是在 Redis 基础上实现的分布式工具集合。

功能特性:

  • 支持 Redis 单节点(single)模式、哨兵(sentinel)模式、主从(Master/Slave)模式以及集群(Redis Cluster)模式
  • 程序接口调用方式采用异步执行和异步流执行两种方式。
  • 数据序列化,Redisson 的对象编码类是用于将对象进行序列化和反序列化,以实现对该对象在 Redis 里的读取和存储。
  • 单个集合数据分片,在集群模式下,Redisson 为单个 Redis 集合类型提供了自动分片的功能。
  • 提供多种分布式对象,如:Object BucketBitsetAtomicLongBloom FilterHyperLogLog 等。
  • 提供丰富的分布式集合,如:Map,Multimap,Set,SortedSet,List,Deque,Queue等。
  • 分布式锁和同步器的实现,可重入锁(Reentrant Lock),公平锁(Fair Lock),联锁(MultiLock),红锁(Red Lock),信号量(Semaphore),可过期性信号锁(PermitExpirableSemaphore)等。
  • 提供先进的分布式服务,如分布式远程服务(Remote Service),分布式实时对象(Live Object)服务,分布式执行服务(Executor Service),分布式调度任务服务(Schedule Service)和分布式映射归纳服务(MapReduce)。

Github 地址:github.com/redisson/re...

Redisson 使用

客户端模式

  • 引入依赖
XML 复制代码
<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson</artifactId>
  <version>3.27.2</version>
</dependency>
  • 获取 RedissonClientRedissonClient有多种模式,主要的模式有:
    • 单节点模式
    • 哨兵模式
    • 主从模式
    • 集群模式

单节点模式

程序化配置方法:

Java 复制代码
// 默认连接地址 127.0.0.1:6379
RedissonClient redisson = Redisson.create();

Config config = new Config();
config.useSingleServer().setAddress("myredisserver:6379");
RedissonClient redisson = Redisson.create(config);

配置参数:

Java 复制代码
SingleServerConfig singleConfig = config.useSingleServer();

具体的参数配置:github.com/redisson/re...

哨兵模式

程序化配置哨兵模式的方法如下:

Java 复制代码
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...

主从模式

程序化配置主从模式的用法:

Java 复制代码
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...

集群模式

程序化配置主从模式的用法:

Java 复制代码
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 版。

Spring Boot 整合

  • 添加 redisson-spring-boot-starter 依赖
XML 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.5</version>
</dependency>
  • 属性配置
.properties 复制代码
spring:
  data:
    redis:
      # 数据库
      database: 0
      # 主机
      host: localhost
      # 端口
      port: 6379
      # 密码
      password:123456
      # 读超时
      timeout: 5s
      # 连接超时
      connect-timeout: 5s
  • 添加配置类
Java 复制代码
@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);
    }
}

这里只是一些简单的使用方法,更加详细的内容,各位小伙伴自行 Google。

Redisson 中的锁

Redisson 可重入锁

基于 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 秒后自动释放锁。

我们用示例验证它的可重入逻辑:

Java 复制代码
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 公平锁

Redisson 支持公平锁和非公平锁,上面的重入锁就是非公平锁。公平锁与 JUC 中的公平锁一致,遵循先到先得的原则。

Redisson 提供了 getFairLock() 来创建公平锁:

Java 复制代码
RLock fairLock = redisson.getFairLock("myFairLock");

获取公平锁后,调用 lock() 即可获取锁:

Java 复制代码
fairLock.lock();

公平锁一般适用于对锁的公平性要求较高的场景,例如任务调度、消息处理等。

Redisson 联锁

联锁(RedissonMultiLock)是指同时对多个资源进行加锁操作,只有所有资源都加锁成功的时候,联锁才会成功。

Redisson 中的联锁是将多个 RLock 对象关联为一个联锁对象,实现加锁和解锁功能。每个 RLock 对象实例可以来自于不同的 Redisson 实例。

Java 复制代码
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);
}

Redisson 读写锁

与 Java 一样,Redisson 也提供了读写锁。读写锁是 Redisson 中的高级分布式锁,它分为读锁和写锁两种锁:

  • 读锁:允许多个线程同时获取锁并进行读操作。
  • 写锁:要求独占。

使用 Redisson 的 getReadWriteLock() 创建读写锁对象:

Java 复制代码
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");

调用 readLock() 或者 writeLock() 获取读写锁:

Java 复制代码
// 获取读锁
RLock readLock = readWriteLock.readLock();

// 获取写锁
RLock writeLock = readWriteLock.writeLock();

Redisson Redlock

Redlock 是 Redis 作者对分布式锁提出的一种加锁算法,其核心是:假设 Redis 集群中有 N 个 Redis 节点,只有当客户端成功在 N/2+1 个实例中成功加锁成功,才算成功持有分布式锁。

Java 复制代码
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 的看门狗机制

如果任务的执行时间比锁的超时时间还长,这种情况会导致锁过早被释放了,从而会让其他线程在当前线程的任务完成之前获取到锁,这就会引发线程安全问题。为了解决这个问题,我们一般有如下几种解决方案:

  • 续租机制【推荐方案】

最常见有效的方案是实现一个锁续租机制。也就是在任务执行期间,会定期更新锁的过期时间。确保锁在整个任务执行期间保持有效。Redisson 提供了 watch dog 机制(看门狗),该机制具备锁自动续期功能,用于避免分布式锁在业务处理过程中因执行时间过长而被提前释放。watch dog会自动检测用户线程是否还活着,如果活着,它会在锁快要自动释放之前自动续期,直到用户线程完成工作。

  • 使用更长的锁超时时间

我们预估一个任务的最长执行时间,然后将所的超时时间设置更长一点,已覆盖这个时间范围。但是这种方案有几个缺陷:绝大部分任务的执行时间都会比预估的最长超时时间短,如果某个线程中途崩溃了,导致锁无法正常释放,这就会降低系统的并发性。

  • 检查任务状态

再获取锁后,检查任务的执行状态,如果仍然有任务在运行,则在那里等待。

  • 任务拆分

我们可以将一个长时间执行的任务拆分为多个独立的较短的小任务,每个步骤都有自己独立的分布式锁,这样就可以减少锁定资源的时间,同时确保每个阶段都能在适当的时间内完成。

这里大明哥详细介绍 Redisson 的看门狗机制。

Redisson 的 watch dog 的核心思想是在 Redisson 客户端获取到锁后,会自动启动一个监控任务,该任务会定期检查锁的状态,并在需要时自动延长锁的过期时间。其核心机制有如下几点:

  • 自动续期 :当 Redisson 客户端获取锁后,默认情况下,watch dog 会每隔一段时间(默认是锁有效期的 1/3,即 10 秒)自动将锁的有效期重新设置为最初的有效期(默认 30 秒),直到锁被释放。这个操作是通过一个后台线程完成的,它确保了即使客户端处理逻辑较长也不会因为锁自动过期而导致锁被提前释放。
  • 停止续期 :由于某种原因导致客户端崩溃,watch dog 会停止续期,锁会在最后一次续期后的有效期内自动释放掉。
  • 续期时长:默认情况下,watch dog 每 10 秒续期一次,每次续期 30 秒。

下面我们看看 Redisson 的 watch dog 源码。

源码路径如下:lock() ---> tryAcquire() ---> tryAcquireAsync()

ini 复制代码
    private RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
        RFuture<Long> 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<Long> s = handleNoSync(threadId, ttlRemainingFuture);
        ttlRemainingFuture = new CompletableFutureWrapper<>(s);

        CompletionStage<Long> 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 秒:

ini 复制代码
private long lockWatchdogTimeout = 30 * 1000;

当然也可以调用 setLockWatchdogTimeout() 设置 watch dog 默认时间。

只有当 leaseTime == -1 时才会调用 scheduleExpirationRenewal() 开启自动续期进程:

scss 复制代码
    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()开启自动续期定时任务:

ini 复制代码
    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<Boolean> 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() 完成续期:

typescript 复制代码
    protected CompletionStage<Boolean> 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() 来续期的,这里会有两个判断:

ini 复制代码
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
    return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
  return;
}

ent == null 表示该自动续期任务已经被释放了,当我们调用 unlock() 时,Redisson 会 remove 掉这个任务:

ini 复制代码
    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 实例挂掉,那么所有这个 Redis 实例的节点都将无法获取到锁,会严重阻碍业务。
  • 主从同步问题:当我们使用集群部署 Redis,如果一个客户端在 Master 节点上获取到了锁,然后没有来得及将数据同步到 Slave 节点上,它就挂了。就算此时选举出来了一个新的 Master 节点,它里面也没有对应的锁信息,这个时候其他客户端就会获取锁成功,会导致并发问题。

Redis 官网也提到了这些问题:

那怎么解决呢?Redis 作者提出 RedLock 解决方案。

RedLock 解决单体故障问题

RedLock 是 Redis 作者提出的一个多节点分布式锁算法,它主要是解决单节点 Redis 分布式锁可能存在的单点故障问题。其核心思想是:不在单个 Redis 实例上进行加锁,而是在多个互相独立的 Redis 节点加锁,只有在大多数节点上解锁成功,锁才算获取成功。其核心原理如下:

  • 多个独立节点RedLock 不再是在单个 Redis 节点加锁,而是在多个互相独立的 Redis 节点加锁(通常是基数个,避免脑裂),这些节点彼此直接不是主从关系,也不是集群。
  • 尝试加锁:在获取锁时,客户端会向所有 Redis 节点发送加锁请求,每个请都有着相同的锁 ID 和相同的过期时间,注意该过期时间是毫秒级要远远小于锁的有效时间。
  • 大多数节点获取锁成功 :客户端需要判断获取锁成功的节点数,如果获得锁的节点数大于约定节点数(N/2+1),则认为获取锁成功。

如下:

  • 释放锁:当客户端不需要锁后,就会释放锁,释放锁时,客户端会向所有的 Redis 节点发送释放锁的请求,不管这些节点是否成功获取了锁。

RedLock 获取锁过程如下(假如有 5 个 Redis 节点):

  1. 客户端先获取当前时间戳 T1。
  2. 客户端依次向 5 个 Redis 实例发送获取锁的请求,且每个请求都会设置超时时间(该超时时间是毫秒级,它要远远小于锁的有效期),如果某一个 Redis 实例加锁失败,则立刻向下一个 Redis 实例发起获取锁请求。
  3. 当有 ≥ 3 个 Redis 节点获取锁成功,客户端再次获取当前时间戳 T2,如果 T2 - T1 < 锁的过期时间,则获取 RedLock 成功。

如何使用 RedLock

Redisson 为我们提供了 RedLock 的实现,我们直接用 RedissonRedLock 即可:

ini 复制代码
    @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 对于 Relock 的质疑

Martin 大神的文章中主要是阐述了 4 点:

  • 使用分布式锁的目的
  • 锁在分布式系统中遇到的问题
  • 时钟不正确导致的问题
  • fecing token 方案

使用分布式锁的目的

Martin 表示我们使用 Redis 来实现分布式锁的主要目的是两点。

  • 效率 :使用分布式锁的互斥能力,避免多次做重复的工作。这种情况即使锁失效,也不会带来「恶性」的后果。例如多发了 1 次邮件、多计算一次都是无伤大雅的场景。但是 Martin 认为,如果是为了效率,单机版的 Redis 效率更高,即使发生偶尔的宕机也不会产生很严重的问题。使用 RedLock 太重了,没有必要。
  • 正确性 :使用锁是为了防止多个线程互相竞争,保证线程安全,如果锁失效,则会发生线程不安全,导致数据不一致,影响比较恶劣。然而,Martin 认为 RedLock 根本无法达到安全的效果,会存在锁失效的情况。

所以,无论是效率还是正确性,Martin 认为 RedLock 都达不到。

锁在分布式系统中遇到的问题

Martin 表示,一个分布式系统,存在着各种异常情况,这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC

  • N:Network Delay,网络延迟
  • P:Process Pause,进程暂停
  • C:Clock Drift,时钟漂移

Martin 使用了一个进程暂停的例子来说明,具体过程如下:

  1. 客户端 1 请求获取锁节点 ABCDE
  2. 客户端 1 获取锁成功,这是系统暂停(比如 STW),这个暂停时间会比较长。
  3. 客户端 1 获取的锁全部过期
  4. 客户端 2 请求获取锁节点 ABCDE
  5. 客户端 2 获取锁成功,执行业务逻辑
  6. 此时,客户端 1 GC 结束,因为客户端 1 在开始的时候已经获取锁成功了,所以它就不会再次请求获取锁了,而是直接执行执业务逻辑,这就导致客户端 1 和 客户端 2 并行执行同业务逻辑,则会发生冲突。

如下图:

需要注意的是,不仅仅只是 GC 导致的暂停,任何可以造成系统停顿的因素都会导致这种情况产生,比如 I/O 、网络阻塞等等。

时钟不正确导致的问题

Martin 指出一个优秀的分布式系统应该基于异步模型 ,简单概括就是不对时间做任何假设,不能使用时间来作为安全保障。因为在分布式系统中会有程序暂停、数据包延迟、系统时间错误。而一个好的分布式系统不会因为这些因素影响锁的安全性,只可能影响到它的活性(liveness property)。也就是说在极端情况下优秀的分布式锁顶多是不能在有限的时间内给出结果,但不能给出一个错误的结果 ,这样的算法是真实存在的如RaftZabPaxos等等。

但是,RedLock 严重依赖依赖系统时钟,因为在 RedLock 的实现中,它是依赖锁的过期时间的,如果多个 Redis 实例的时钟不一致,则会导致如下这种情况:

  1. 有 5 个 Redis 节点 ABCDE
  2. 客户端 1 成功获取节点 ABC 三个节点的锁,获得分布式锁
  3. 节点 A 时钟向前跳跃,导致 A 节点的锁提前释放
  4. 客户端 2 成功获取节点 ADE,获得分布式锁
  5. 这是客户端 1 和客户端 2 同时持有分布式锁,导致冲突

而机器发生时钟漂移的概率还是有的,比如:

  • 运维手动修改
  • 机器时钟在同步 NTP 时间时,发生了大的跳跃

fecing token 方案

针对 RedLock 的缺陷,Martin 提出了自己的解决方案:fecing token

Martin 的解决方案是为锁资源增加一个递增的 token 用来保证分布式锁的安全性:

  1. 客户端在获取锁时,锁服务提供一个递增的 token。如在上图 Client1 除了获取锁外,还获得了一个值为 33 的 token
  2. 客户端拿着这个 token 去操作共享资源。
  3. 共享资源可以根据 token 拒绝后来者的请求。例如上图中,Client1 因为 STW 暂停导致锁被释放了,Client2 获取锁后使用 token = 34 去操作共享资源

Martin 认为 fecing token 方案无论是碰到分布式中 NPC 的那种情况,都能够保证分布锁的安全性,因为它是建立在异步模型的。

Antirez 的反驳

针对 Martin 的质疑,Antirez 做出来以下几点反驳。

时钟问题

针对 Martin 提出的时钟错误问题,Antirez 反驳道:

  1. 人为手动修改:不要这么做就可以了。如果可以认为破坏的话,无论采用哪种手段都是不安全的。
  2. 时钟跳跃:NTP受到一个阶跃时钟更新,对于这个问题,需要通过运维来保证。需要将阶跃的时间更新到服务器的时候,应当采取小步快跑的方式。多次修改,每次更新时间尽量小。

严格上来说,RedLock 是建立在可信的时钟模型上的,在现实情况下确实是会存在一些时钟错误的情况,但是我们可以通过一些运维手段或者工程机制最大限度保证时钟可信。

线程暂停问题

针对线程暂停的问题,我们再次回顾 RedLock 获取锁的过程:

  1. 客户端先获取当前时间戳 T1。
  2. 客户端依次向 5 个 Redis 实例发送获取锁的请求,且每个请求都会设置超时时间(该超时时间是毫秒级,它要远远小于锁的有效期),如果某一个 Redis 实例加锁失败,则立刻向下一个 Redis 实例发起获取锁请求。
  3. 当有 ≥ 3 个 Redis 节点获取锁成功,客户端再次获取当前时间戳 T2,如果 T2 - T1 < 锁的过期时间,则获取 RedLock 成功。

在这个步骤中,RedLock 会两次获取时间戳。如果线程暂停是发生在获取 T 时间戳前,那么是可以通过 T2 - T1 < 锁的过期时间 检测出来的。如果超出了锁的过期时间,则会被认为获取锁失败,所以这种情况是可以避免的。

如果线程暂停是发生客户端 1 获取分布锁成功后,导致其他线程能够获取分布式锁产生锁冲突。那这就不是 RedLock 所负责的范畴了,RedLock 只提供的正确的分布式锁,而且这种情况其他的分布式锁服务(如Zookeeper)也是无法避免的。

fecing token 方案

Martin 提供的fecting token 方案需要共享资源具备拒绝旧 token 的能力,试想下,如果共享资源就具备这种互斥能力,那还需要分布式锁干嘛?

RedLock 被弃用了?

由于 RedLock 存在争议,很遗憾,Redis 官方已经标记 RedLock 算法为 "discouraged":

更新记录如下:

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


对于 Redis 面试题,大明哥一共总结了 60+ 篇,总字数 7万字,有兴趣的可【私大明哥

相关推荐
Abladol-aj1 小时前
并发和并行的基础知识
java·linux·windows
清水白石0081 小时前
从一个“支付状态不一致“的bug,看大型分布式系统的“隐藏杀机“
java·数据库·bug
吾日三省吾码6 小时前
JVM 性能调优
java
弗拉唐7 小时前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
oi778 小时前
使用itextpdf进行pdf模版填充中文文本时部分字不显示问题
java·服务器
少说多做3438 小时前
Android 不同情况下使用 runOnUiThread
android·java
知兀8 小时前
Java的方法、基本和引用数据类型
java·笔记·黑马程序员
蓝黑20209 小时前
IntelliJ IDEA常用快捷键
java·ide·intellij-idea
Ysjt | 深9 小时前
C++多线程编程入门教程(优质版)
java·开发语言·jvm·c++