분산 잠금은 일반적으로 다음에서 구현됩니다.
실제 개발에서는 The 가장 일반적인 것은 Redis와 Zookeeper이므로 이 기사에서는 이 두 가지에 대해서만 설명합니다.
이 문제를 논의하기 전에 먼저 비즈니스 시나리오를 살펴보겠습니다.
시스템 A는 전자 상거래 시스템으로 현재 시스템에 배포되어 있습니다. 사용자가 시스템에 주문할 수 있는 인터페이스가 있습니다. 사용자가 주문하기 전에 재고가 충분한지 확인해야 합니다.
시스템에는 특정 동시성이 있으므로 상품의 재고가 Redis
中,用户下单的时候会更新Redis
의 재고에 미리 저장됩니다.
현재 시스템 아키텍처는 다음과 같습니다.
그러나 이로 인해 문제가 발생합니다: 특정 순간에 Redis에 있는 특정 제품의 인벤토리가 1이라면 이때 두 개의 요청이 옵니다. 그 중 하나는 위 그림의 3단계를 실행한 후 데이터베이스의 인벤토리가 0으로 업데이트되지만 4단계는 아직 실행되지 않았습니다.
다른 요청은 2단계에 도달했는데 인벤토리가 여전히 1인 것을 확인하여 3단계로 계속 진행했습니다.
결과는 2개 상품이 판매됐으나, 실제로는 재고가 1개 밖에 없습니다.
분명히 뭔가 잘못된 것 같습니다! 이것은 전형적인 재고 과잉 판매 문제입니다
이 시점에서 우리는 쉽게 해결책을 생각할 수 있습니다. 2, 3, 4단계를 잠금으로 잠그면 완료 후에 다른 스레드가 들어와 2단계를 수행할 수 있습니다.
위 그림에 따르면 2단계 실행 시에는 Java에서 제공하는 syncinized나 ReentrantLock을 이용하여 잠그고, 4단계가 실행된 후 잠금을 해제하면 됩니다.
이런 방식으로 2, 3, 4의 세 단계가 "잠겨" 있으며 여러 스레드는 순차적으로만 실행될 수 있습니다.
그러나 좋은 시절은 오래가지 않았고, 전체 시스템의 동시성이 급증했고, 한 대의 기계가 더 이상 이를 처리할 수 없었습니다. 이제 아래와 같이 머신을 추가해야 합니다.
머신을 추가하면 시스템은 위 그림과 같이 됩니다. 맙소사!
두 사용자의 요청이 동시에 도착했지만 서로 다른 시스템에 있다고 가정해 보겠습니다. 이 두 요청이 동시에 실행될 수 있습니까, 아니면 재고 초과 판매 문제가 발생합니까?
왜? 위 그림의 두 A 시스템은 서로 다른 두 JVM에서 실행되기 때문에 이들이 추가하는 잠금은 자체 JVM의 스레드에만 유효하고 다른 JVM의 스레드에는 유효하지 않습니다.
여기서 문제는 Java에서 제공하는 기본 잠금 메커니즘이 다중 시스템 배포 시나리오에서 실패한다는 것입니다.
이는 두 시스템에서 추가한 잠금이 동일한 잠금이 아니기 때문입니다(두 잠금은 서로 다른 JVM에 있음). .
그렇다면 두 기계에 추가된 잠금 장치가 동일한지 확인하기만 하면 문제는 해결되지 않을까요?
이 시점에서 분산 잠금 장치가 웅장한 모습을 보일 때입니다. 분산 잠금 장치의 아이디어는 다음과 같습니다.
전체 시스템에서 잠금을 획득하기 위한 글로벌하고 고유한 "사물"을 제공하고 각 시스템에 제공합니다. 필요할 때 잠글 수 있으므로 모두 이 "사물"에게 잠금을 요청하여 서로 다른 시스템이 이를 동일한 잠금으로 생각할 수 있도록 합니다.
이 "사물"은 Redis, Zookeeper 또는 데이터베이스일 수 있습니다.
텍스트 설명은 그다지 직관적이지 않습니다. 아래 그림을 살펴보겠습니다.
위 분석을 통해 우리는 초과 판매된 재고 시나리오의 경우 Java의 기본 잠금 메커니즘을 사용하면 분산 환경에서 스레드 안전을 보장할 수 없다는 것을 알고 있습니다. 따라서 우리는 분산 잠금 솔루션을 사용해야 합니다.
그렇다면 분산 잠금을 구현하는 방법은 무엇일까요? 그럼 계속 읽어보세요!
위에서는 분산 잠금을 사용해야 하는 이유를 분석했습니다. 여기서는 분산 잠금을 구현할 때 어떻게 처리해야 하는지 자세히 살펴보겠습니다.
가장 일반적인 솔루션은 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 배포도 고려해야 합니다.
Redis에는 3가지 배포 방법이 있습니다.
분산 잠금에 Redis를 사용할 때의 단점은 다음과 같습니다. 단일 머신 사용 배포 모드에서는 Redis가 실패하는 한 단일 문제 지점이 있습니다. 잠그면 작동하지 않습니다.
마스터-슬레이브 모드를 채택합니다. 잠금 시 하나의 노드만 잠깁니다. 센티넬을 통해 고가용성을 달성하더라도 마스터-슬레이브 전환이 발생하면 잠금이 손실되는 문제가 발생할 수 있습니다.
위 고려 사항을 바탕으로 실제로 redis 작성자도 이 문제를 고려하여 RedLock 알고리즘을 제안했습니다. 이 알고리즘의 의미는 대략 다음과 같습니다.
Redis의 배포 모드가 Redis 클러스터라고 가정합니다. 총 5개의 마스터가 있습니다. 노드는 다음 단계를 통해 잠금을 획득합니다.
但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。
此外,实现Redis的分布式锁,除了自己基于redis client原生api来实现之外,还可以使用开源框架:Redission
Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持。我也非常推荐大家使用,为什么呢?
回想一下上面说的,如果自己写代码来通过redis设置一个值,是通过下面这个命令设置的。
这里设置的超时时间是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之后就会自动过期了,其他线程可以获取到锁)
这里稍微贴出来其实现代码:
// 加锁逻辑 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('exists', KEYS[1]) == 0) then " + "redis.call('hset', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "return redis.call('pttl', 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'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很多细节。
常见的分布式锁实现方案里面,除了使用redis来实现之外,使用zookeeper也可以实现分布式锁。
在介绍zookeeper(下文用zk代替)实现分布式锁的机制之前,先粗略介绍一下zk是什么东西:
Zookeeper是一种提供配置管理、分布式协同以及命名的中心化服务。
zk的模型是这样的:zk包含一系列的节点,叫做znode,就好像文件系统一样每个znode表示一个目录,然后znode有一些特性:
Ordered 노드: 현재 상위 노드가 있는 경우 /lock
, 이 상위 노드 아래에 하위 노드를 생성할 수 있습니다. /lock
,我们可以在这个父节点下面创建子节点;
zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号
也就是说,如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000
,下一个节点则为/lock/node-0000000001
/lock/node-0000000000
, 다음 노드는 /lock/node-0000000001
등.
比如当前线程获取到的节点序号为/lock/003
,然后所有的节点列表为[/lock/001,/lock/002,/lock/003]
,则对/lock/002
这个节点添加一个事件监听器。
如果锁释放了,会唤醒下一个序号的节点,然后重新执行第3步,判断是否自己的节点序号是最小。
比如/lock/001
释放了,/lock/002
监听到时间,此时节点集合为[/lock/002,/lock/003]
,则/lock/002
为最小序号节点,获取到锁。
整个过程如下:
具体的实现思路就是这样,至于代码怎么写,这里比较复杂就不贴出来了。
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实现分布式锁的底层原理和上面分析的是差不多的。这里我们用一张图详细描述其原理:
小结:
本节介绍了Zookeeperr实现分布式锁的方案以及zk的开源客户端的基本使用,简要的介绍了其实现原理。
두 가지 분산 잠금 구현 솔루션을 학습한 후 이 섹션에서는 redis 및 zk 구현 솔루션의 각각의 장단점을 논의해야 합니다.
redis의 분산 잠금에는 다음과 같은 단점이 있습니다.
그러나 Redis를 사용하여 분산 잠금을 구현하는 것은 많은 기업에서 매우 일반적이며 대부분의 경우 소위 "매우 복잡한 시나리오"에 직면하지 않습니다.
따라서 Redis를 분산 잠금으로 사용하는 것은 나쁘지 않은 생각입니다. 좋은 솔루션에서 가장 중요한 점은 Redis가 고성능을 가지며 높은 동시성 획득 및 잠금 해제 작업을 지원할 수 있다는 것입니다.
zk 분산 잠금 장치의 경우:
그러나 zk에는 단점도 있습니다. 자주 잠금을 신청하고 잠금을 해제하는 클라이언트가 더 많으면 zk 클러스터에 대한 압력이 더 커질 것입니다.
요약:
요약하자면 redis와 Zookeeper는 모두 장점과 단점이 있습니다. 기술을 선택할 때 이러한 문제를 참조 요소로 사용할 수 있습니다.
위 내용은 분산 잠금에 Redis나 Zookeeper를 사용해야 합니까?의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!