>Java >java지도 시간 >분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

Java后端技术全栈
Java后端技术全栈앞으로
2023-08-22 15:48:46733검색

분산 잠금은 일반적으로 다음에서 구현됩니다.

  • database
  • 캐시(예: Redis)
  • Zookeeper
  • etcd

실제 개발에서는 The 가장 일반적인 것은 Redis와 Zookeeper이므로 이 기사에서는 이 두 가지에 대해서만 설명합니다.

이 문제를 논의하기 전에 먼저 비즈니스 시나리오를 살펴보겠습니다.

시스템 A는 전자 상거래 시스템으로 현재 시스템에 배포되어 있습니다. 사용자가 시스템에 주문할 수 있는 인터페이스가 있습니다. 사용자가 주문하기 전에 재고가 충분한지 확인해야 합니다.

시스템에는 특정 동시성이 있으므로 상품의 재고가 Redis中,用户下单的时候会更新Redis의 재고에 미리 저장됩니다.

현재 시스템 아키텍처는 다음과 같습니다.

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

그러나 이로 인해 문제가 발생합니다: 특정 순간에 Redis에 있는 특정 제품의 인벤토리가 1이라면 이때 두 개의 요청이 옵니다. 그 중 하나는 위 그림의 3단계를 실행한 후 데이터베이스의 인벤토리가 0으로 업데이트되지만 4단계는 아직 실행되지 않았습니다.

다른 요청은 2단계에 도달했는데 인벤토리가 여전히 1인 것을 확인하여 3단계로 계속 진행했습니다.

결과는 2개 상품이 판매됐으나, 실제로는 재고가 1개 밖에 없습니다.

분명히 뭔가 잘못된 것 같습니다! 이것은 전형적인 재고 과잉 판매 문제입니다

이 시점에서 우리는 쉽게 해결책을 생각할 수 있습니다. 2, 3, 4단계를 잠금으로 잠그면 완료 후에 다른 스레드가 들어와 2단계를 수행할 수 있습니다.

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

위 그림에 따르면 2단계 실행 시에는 Java에서 제공하는 syncinized나 ReentrantLock을 이용하여 잠그고, 4단계가 실행된 후 잠금을 해제하면 됩니다.

이런 방식으로 2, 3, 4의 세 단계가 "잠겨" 있으며 여러 스레드는 순차적으로만 실행될 수 있습니다.

그러나 좋은 시절은 오래가지 않았고, 전체 시스템의 동시성이 급증했고, 한 대의 기계가 더 이상 이를 처리할 수 없었습니다. 이제 아래와 같이 머신을 추가해야 합니다.

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

머신을 추가하면 시스템은 위 그림과 같이 됩니다. 맙소사!

두 사용자의 요청이 동시에 도착했지만 서로 다른 시스템에 있다고 가정해 보겠습니다. 이 두 요청이 동시에 실행될 수 있습니까, 아니면 재고 초과 판매 문제가 발생합니까?

왜? 위 그림의 두 A 시스템은 서로 다른 두 JVM에서 실행되기 때문에 이들이 추가하는 잠금은 자체 JVM의 스레드에만 유효하고 다른 JVM의 스레드에는 유효하지 않습니다.

여기서 문제는 Java에서 제공하는 기본 잠금 메커니즘이 다중 시스템 배포 시나리오에서 실패한다는 것입니다.

이는 두 시스템에서 추가한 잠금이 동일한 잠금이 아니기 때문입니다(두 잠금은 서로 다른 JVM에 있음). .

그렇다면 두 기계에 추가된 잠금 장치가 동일한지 확인하기만 하면 문제는 해결되지 않을까요?

이 시점에서 분산 잠금 장치가 웅장한 모습을 보일 때입니다. 분산 잠금 장치의 아이디어는 다음과 같습니다.

전체 시스템에서 잠금을 획득하기 위한 글로벌하고 고유한 "사물"을 제공하고 각 시스템에 제공합니다. 필요할 때 잠글 수 있으므로 모두 이 "사물"에게 잠금을 요청하여 서로 다른 시스템이 이를 동일한 잠금으로 생각할 수 있도록 합니다.

이 "사물"은 Redis, Zookeeper 또는 데이터베이스일 수 있습니다.

텍스트 설명은 그다지 직관적이지 않습니다. 아래 그림을 살펴보겠습니다.

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

위 분석을 통해 우리는 초과 판매된 재고 시나리오의 경우 Java의 기본 잠금 메커니즘을 사용하면 분산 환경에서 스레드 안전을 보장할 수 없다는 것을 알고 있습니다. 따라서 우리는 분산 잠금 솔루션을 사용해야 합니다.

그렇다면 분산 잠금을 구현하는 방법은 무엇일까요? 그럼 계속 읽어보세요!

Redis 기반 분산 잠금 구현

위에서는 분산 잠금을 사용해야 하는 이유를 분석했습니다. 여기서는 분산 잠금을 구현할 때 어떻게 처리해야 하는지 자세히 살펴보겠습니다.

가장 일반적인 솔루션은 Redis를 분산 잠금으로 사용하는 것입니다

분산 잠금에 Redis를 사용하는 아이디어는 대략 다음과 같습니다. Redis에 잠금이 추가되었음을 나타내는 값을 설정한 다음 잠금이 해제되면 키를 삭제합니다.

구체적인 코드는 다음과 같습니다.

// 获取锁
// NX是指如果key不存在就成功,key存在返回false,PX可以指定过期时间
SET anyLock unique_value NX PX 30000

// 释放锁:通过执行一段lua脚本
// 释放锁涉及到两条指令,这两条指令不是原子性的
// 需要用到redis的lua脚本支持特性,redis执行lua脚本是原子性的
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

이 방법에는 몇 가지 중요한 점이 있습니다.

  • SET 키 값 NX PX 밀리초 명령을 사용해야 합니다.

    그렇지 않은 경우 먼저 값을 설정한 다음 만료 시간을 설정하세요. 만료 시간을 설정하기 전에 충돌이 발생할 수 있으며 이로 인해 교착 상태가 발생할 수 있습니다(키가 영구적으로 존재함)

  • 값은 고유해야 합니다

    이것은 확인하기 위한 것입니다. 그 값은 잠금 해제 시 잠금이 일관된 경우에만 키가 삭제됩니다.

    이것은 상황을 방지합니다. A가 잠금을 획득하고 만료 시간이 35초 후에 A가 잠금을 해제하러 갔지만 이때 B가 잠금을 획득할 수 있다고 가정합니다. 클라이언트 A는 B의 잠금을 삭제할 수 없습니다.

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

클라이언트가 분산 잠금을 구현하는 방법을 고려하는 것 외에도 Redis 배포도 고려해야 합니다.

Redis에는 3가지 배포 방법이 있습니다.

  • 단일 머신 모드
  • 마스터-슬레이브 + 센티널 선택 모드
  • redis 클러스터 모드

분산 잠금에 Redis를 사용할 때의 단점은 다음과 같습니다. 단일 머신 사용 배포 모드에서는 Redis가 실패하는 한 단일 문제 지점이 있습니다. 잠그면 작동하지 않습니다.

마스터-슬레이브 모드를 채택합니다. 잠금 시 하나의 노드만 잠깁니다. 센티넬을 통해 고가용성을 달성하더라도 마스터-슬레이브 전환이 발생하면 잠금이 손실되는 문제가 발생할 수 있습니다.

위 고려 사항을 바탕으로 실제로 redis 작성자도 이 문제를 고려하여 RedLock 알고리즘을 제안했습니다. 이 알고리즘의 의미는 대략 다음과 같습니다.

Redis의 배포 모드가 Redis 클러스터라고 가정합니다. 총 5개의 마스터가 있습니다. 노드는 다음 단계를 통해 잠금을 획득합니다.

  • 获取当前时间戳,单位是毫秒
  • 轮流尝试在每个master节点上创建锁,过期时间设置较短,一般就几十毫秒
  • 尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2 +1)
  • 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了
  • 要是锁建立失败了,那么就依次删除这个锁
  • 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁

但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

另一种方式:Redisson

此外,实现Redis的分布式锁,除了自己基于redis client原生api来实现之外,还可以使用开源框架:Redission

Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持。我也非常推荐大家使用,为什么呢?

回想一下上面说的,如果自己写代码来通过redis设置一个值,是通过下面这个命令设置的。

  • SET anyLock unique_value NX PX 30000

这里设置的超时时间是30s,假如我超过30s都还没有完成业务逻辑的情况下,key会过期,其他线程有可能会获取到锁。

这样一来的话,第一个线程还没执行完业务逻辑,第二个线程进来了也会出现线程安全问题。所以我们还需要额外的去维护这个过期时间,太麻烦了~

我们来看看redisson是怎么实现的?先感受一下使用redission的爽:

Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://192.168.31.101:7001")
.addNodeAddress("redis://192.168.31.101:7002")
.addNodeAddress("redis://192.168.31.101:7003")
.addNodeAddress("redis://192.168.31.102:7001")
.addNodeAddress("redis://192.168.31.102:7002")
.addNodeAddress("redis://192.168.31.102:7003");

RedissonClient redisson = Redisson.create(config);


RLock lock = redisson.getLock("anyLock");
lock.lock();
lock.unlock();

就是这么简单,我们只需要通过它的api中的lock和unlock即可完成分布式锁,他帮我们考虑了很多细节:

  • redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行

  • redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s怎么办?

    redisson中有一个watchdog的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s

    这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了。

  • redisson的“看门狗”逻辑保证了没有死锁发生。

    (如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁)

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

这里稍微贴出来其实现代码:

// 加锁逻辑
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    // 调用一段lua脚本,设置一些key、过期时间
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    ttlRemainingFuture.addListener(new FutureListener<Long>() {
        @Override
        public void operationComplete(Future<Long> future) throws Exception {
            if (!future.isSuccess()) {
                return;
            }

            Long ttlRemaining = future.getNow();
            // lock acquired
            if (ttlRemaining == null) {
                // 看门狗逻辑
                scheduleExpirationRenewal(threadId);
            }
        }
    });
    return ttlRemainingFuture;
}


<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
              "if (redis.call(&#39;exists&#39;, KEYS[1]) == 0) then " +
                  "redis.call(&#39;hset&#39;, KEYS[1], ARGV[2], 1); " +
                  "redis.call(&#39;pexpire&#39;, KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              "if (redis.call(&#39;hexists&#39;, KEYS[1], ARGV[2]) == 1) then " +
                  "redis.call(&#39;hincrby&#39;, KEYS[1], ARGV[2], 1); " +
                  "redis.call(&#39;pexpire&#39;, KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              "return redis.call(&#39;pttl&#39;, KEYS[1]);",
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}



// 看门狗最终会调用了这里
private void scheduleExpirationRenewal(final long threadId) {
    if (expirationRenewalMap.containsKey(getEntryName())) {
        return;
    }

    // 这个任务会延迟10s执行
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {

            // 这个操作会将key的过期时间重新设置为30s
            RFuture<Boolean> future = renewExpirationAsync(threadId);

            future.addListener(new FutureListener<Boolean>() {
                @Override
                public void operationComplete(Future<Boolean> future) throws Exception {
                    expirationRenewalMap.remove(getEntryName());
                    if (!future.isSuccess()) {
                        log.error("Can&#39;t update lock " + getName() + " expiration", future.cause());
                        return;
                    }

                    if (future.getNow()) {
                        // reschedule itself
                        // 通过递归调用本方法,无限循环延长过期时间
                        scheduleExpirationRenewal(threadId);
                    }
                }
            });
        }

    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

    if (expirationRenewalMap.putIfAbsent(getEntryName(), new ExpirationEntry(threadId, task)) != null) {
        task.cancel();
    }
}

另外,redisson还提供了对redlock算法的支持,

它的用法也很简单:

RedissonClient redisson = Redisson.create(config);
RLock lock1 = redisson.getFairLock("lock1");
RLock lock2 = redisson.getFairLock("lock2");
RLock lock3 = redisson.getFairLock("lock3");
RedissonRedLock multiLock = new RedissonRedLock(lock1, lock2, lock3);
multiLock.lock();
multiLock.unlock();

小结

本节分析了使用Redis作为分布式锁的具体落地方案,以及其一些局限性,然后介绍了一个Redis的客户端框架redisson。这也是我推荐大家使用的,比自己写代码实现会少care很多细节。

基于zookeeper实现分布式锁

常见的分布式锁实现方案里面,除了使用redis来实现之外,使用zookeeper也可以实现分布式锁。

在介绍zookeeper(下文用zk代替)实现分布式锁的机制之前,先粗略介绍一下zk是什么东西:

Zookeeper是一种提供配置管理、分布式协同以及命名的中心化服务。

zk的模型是这样的:zk包含一系列的节点,叫做znode,就好像文件系统一样每个znode表示一个目录,然后znode有一些特性:

  • Ordered 노드: 현재 상위 노드가 있는 경우 /lock, 이 상위 노드 아래에 하위 노드를 생성할 수 있습니다. /lock,我们可以在这个父节点下面创建子节点;

    zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号

    也就是说,如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000,下一个节点则为/lock/node-0000000001

    zookeeper는 선택적 순서 지정 기능을 제공합니다. 예를 들어 하위 노드 "/lock/node-"를 생성하고 순서를 지정한 다음 Zookeeper를 지정할 수 있습니다. 하위 노드를 생성할 때 현재 하위 노드 수를 기반으로 정수 일련 번호를 자동으로 추가합니다
  • 즉, 처음 생성된 하위 노드인 경우 생성된 하위 노드는 /lock/node-0000000000, 다음 노드는 /lock/node-0000000001 등.
  • 임시 노드: 클라이언트는 임시 노드를 생성할 수 있습니다. Zookeeper는 세션이 종료되거나 세션 시간이 초과된 후 자동으로 노드를 삭제합니다.
  • 이벤트 모니터링: 데이터를 읽을 때 동시에 노드에서 이벤트 모니터링을 설정할 수 있습니다. 노드 데이터나 구조가 변경되면 ZooKeeper가 클라이언트에 알립니다. 현재 Zookeeper에는 다음과 같은 4가지 이벤트가 있습니다.

    • 노드 생성
    • 노드 삭제
    • 노드 데이터 수정
    하위 노드 변경

  1. 일부 기준 위의 zk의 특성으로 인해 우리는 zk를 사용하여 분산 잠금을 구현하는 구현 계획을 쉽게 생각해 낼 수 있습니다.

  2. zk의 임시 노드와 정렬된 노드를 사용하면 잠금을 획득하는 각 스레드는 임시 주문을 생성하는 것을 의미합니다. 예를 들어 zk의 노드는 /lock/ 디렉터리에 있습니다.
  3. 노드 생성에 성공한 후 /lock 디렉터리에 있는 모든 임시 노드를 획득한 후 현재 스레드에서 생성된 노드가 모든 노드 중 일련 번호가 가장 작은 노드인지 확인합니다
  4. 현재 쓰레드가 생성한 노드가 전체 노드 중 시퀀스 번호가 가장 작은 노드라면 잠금 획득에 성공한 것으로 간주한다.
  5. 🎜🎜현재 스레드에서 생성된 노드가 전체 노드 중 일련번호가 가장 작은 노드가 아닌 경우 해당 노드 일련번호 앞에 이벤트 리스너를 추가합니다. 🎜

    比如当前线程获取到的节点序号为/lock/003,然后所有的节点列表为[/lock/001,/lock/002,/lock/003],则对/lock/002这个节点添加一个事件监听器。

如果锁释放了,会唤醒下一个序号的节点,然后重新执行第3步,判断是否自己的节点序号是最小。

比如/lock/001释放了,/lock/002监听到时间,此时节点集合为[/lock/002,/lock/003],则/lock/002为最小序号节点,获取到锁。

整个过程如下:

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?

具体的实现思路就是这样,至于代码怎么写,这里比较复杂就不贴出来了。

Curator介绍

Curator是一个zookeeper的开源客户端,也提供了分布式锁的实现。

他的使用方式也比较简单:

InterProcessMutex interProcessMutex = new InterProcessMutex(client,"/anyLock");
interProcessMutex.acquire();
interProcessMutex.release();

其实现分布式锁的核心源码如下:

private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
{
    boolean  haveTheLock = false;
    boolean  doDelete = false;
    try {
        if ( revocable.get() != null ) {
            client.getData().usingWatcher(revocableWatcher).forPath(ourPath);
        }

        while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock ) {
            // 获取当前所有节点排序后的集合
            List<String>        children = getSortedChildren();
            // 获取当前节点的名称
            String              sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash
            // 判断当前节点是否是最小的节点
            PredicateResults    predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
            if ( predicateResults.getsTheLock() ) {
                // 获取到锁
                haveTheLock = true;
            } else {
                // 没获取到锁,对当前节点的上一个节点注册一个监听器
                String  previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
                synchronized(this){
                    Stat stat = client.checkExists().usingWatcher(watcher).forPath(previousSequencePath);
                    if ( stat != null ){
                        if ( millisToWait != null ){
                            millisToWait -= (System.currentTimeMillis() - startMillis);
                            startMillis = System.currentTimeMillis();
                            if ( millisToWait <= 0 ){
                                doDelete = true;    // timed out - delete our node
                                break;
                            }
                            wait(millisToWait);
                        }else{
                            wait();
                        }
                    }
                }
                // else it may have been deleted (i.e. lock released). Try to acquire again
            }
        }
    }
    catch ( Exception e ) {
        doDelete = true;
        throw e;
    } finally{
        if ( doDelete ){
            deleteOurPath(ourPath);
        }
    }
    return haveTheLock;
}

其实curator实现分布式锁的底层原理和上面分析的是差不多的。这里我们用一张图详细描述其原理:

분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?
图片

小结

本节介绍了Zookeeperr实现分布式锁的方案以及zk的开源客户端的基本使用,简要的介绍了其实现原理。

두 솔루션의 장단점 비교

두 가지 분산 잠금 구현 솔루션을 학습한 후 이 섹션에서는 redis 및 zk 구현 솔루션의 각각의 장단점을 논의해야 합니다.

redis의 분산 잠금에는 다음과 같은 단점이 있습니다.

  • 잠금을 획득하는 방법이 간단하고 조잡합니다. 잠금을 획득할 수 없으면 계속해서 잠금을 획득하려고 시도하므로 성능이 소모됩니다. .
  • 또한 Redis의 디자인 포지셔닝은 데이터가 강력하게 일관성이 없다고 판단하는 경우도 있습니다. 극단적인 경우에는 문제가 발생할 수도 있습니다. 잠금 모델은 충분히 강력하지 않습니다.
  • redlock 알고리즘을 사용하여 구현하더라도 일부 복잡한 시나리오에서는 해당 구현이 100% 문제가 없다는 보장이 없습니다. redlock에 대한 논의는 방법을 참조하세요. 분산 잠금을 수행하려면
  • redis 배포 잠금의 경우 실제로 지속적으로 잠금을 직접 획득하려고 시도해야 하므로 더 많은 성능이 소모됩니다.

그러나 Redis를 사용하여 분산 잠금을 구현하는 것은 많은 기업에서 매우 일반적이며 대부분의 경우 소위 "매우 복잡한 시나리오"에 직면하지 않습니다.

따라서 Redis를 분산 잠금으로 사용하는 것은 나쁘지 않은 생각입니다. 좋은 솔루션에서 가장 중요한 점은 Redis가 고성능을 가지며 높은 동시성 획득 및 잠금 해제 작업을 지원할 수 있다는 것입니다.

zk 분산 잠금 장치의 경우:

  • zookeeper의 자연스러운 디자인 위치는 분산 조정과 강력한 일관성입니다. 잠금 모델은 견고하고 사용하기 쉬우며 분산 잠금에 적합합니다.
  • 잠금을 얻을 수 없는 경우 리스너만 추가하면 됩니다. 항상 폴링할 필요가 없으며 성능 소모도 적습니다.

그러나 zk에는 단점도 있습니다. 자주 잠금을 신청하고 잠금을 해제하는 클라이언트가 더 많으면 zk 클러스터에 대한 압력이 더 커질 것입니다.

요약:

요약하자면 redis와 Zookeeper는 모두 장점과 단점이 있습니다. 기술을 선택할 때 이러한 문제를 참조 요소로 사용할 수 있습니다.

위 내용은 분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 Java后端技术全栈에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제