동시성 제어를 위해 가장 확실한 방법 중 하나는 한 번에 한 로직만 작동하도록 하는 것 입니다.
레디스를 활용한 분산락(Distribuited Lock)이 바로 그 방법입니다.
레디스는 싱글 스레드 기반 인메모리 DB로 모든 요청은 순차적으로 실행됩니다.
- 레디스는 유저 레벨의 명령은 싱글 스레드, 실제 작업인 커널 IO는 멀티플렉싱으로 실행되기 때문에 순차적임과 동시에 높은 성능을 자랑합니다.
- 이때 요청은 하나의 레디스 명령어를 기준으로 실행되기 때문에 여러 명령어를 함께 사용한다면 동시성에 대한 고민이 필요합니다.
이번에는 R lock의 동작 방식에 대해 알아보겠습니다.
스핀락에 대해 알고싶다면 아래 링크를 참고해주세요!.
https://velog.io/@a01021039107/%EB%B6%84%EC%82%B0%EB%9D%BD%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EC%9D%B4%EB%A1%A0%ED%8E%B8
스핀락 동작 방식
Redisson Clinet로 부터 RLock을 얻을 때 key를 주입합니다.
RedissonClient rc = Redisson.create(config); String key = "key";//커스텀 키 Rlock rLock = rc.getLock(key);
주입한 키는 RedissonBaseLock 객체의 entryName과 RedissonLock channelName 만들 때 사용됩니다.
channelName : 세마포어의 channelName(key)
entryName : CompletableFuture 객체를 담은 entry(HashMap)의 key로 사용됩니다. 생성된 entry는 세마포어에 들어가 대기 합니다.
이 때 스핀락 처럼 지속적인 수행을 하는 것이 아니라 큐로된 세마포어로 들어가 레디스의 PubSub을 이용해 응답을 기다립니다.//RedissonBaseLock - entryName public RedissonBaseLock(CommandAsyncExecutor commandExecutor, String name) { super(commandExecutor, name); this.entryName = this.id + ":" + name;//id는 uuid } //RedissonLock - ChannelName String getChannelName() { return prefixName("redisson_lock__channel", this.getRawName()); }
tryLock에서는 waitTime시간동안 대기와 락 획득 시도를 합니다.
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { long time = unit.toMillis(waitTime); long current = System.currentTimeMillis(); long threadId = Thread.currentThread().getId(); Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId); if (ttl == null) { return true; } else {
락 획득 과정의 기본적인 구조는 아래와 같습니다.
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { long time = unit.toMillis(waitTime); long current = System.currentTimeMillis(); long threadId = Thread.currentThread().getId(); Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId); if (ttl == null) { return true; } else {
- 최초 락 걸기 시도(tryLock)
락 획득(tryAcquire) 시도 과정 1. 레디스 서버에서 Lua Script 실행 2. 락Name의 키가 없으면 키 획득 -> TTL 설정 -> nil 리턴 or 1. 락Name의 키가 있으면 락Name의 남은 TTL 리턴
- Was에서 이전 로직의 리턴 값에 따라
nil을 받은 경우 -> 비지니스 로직 수행 후 락 반납
TTL을 받은 경우 -> 남은 시간(waitTime - ttl)동안 retry 수행
- Redsson은 스레드가 key를 얻지 못했을 때 atomic한 동작을 위해 큐로 된 세마포어에 해당 스레드를 넣고 레디스에 PubSub을 이용해 점유 중인 키를 release할 때 까지 기다립니다.//RedissonLock - tryLock //ttl이 아직 남아있을 경우 threadId로 레디스 subscribe 합니다. //if (time > 0L) { CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId); ... }
//PublishSubscribe - subscribe //subscribes는 큐로 된 세마포어[channelName] 중 하나를 받아 //CompletableFuture로 된 entry(hashMap)를 담고 레디스 서버에 subscribe 합니다. AsyncSemaphore semaphore = this.service.getSemaphore(new ChannelName(channelName)); CompletableFuture<E> newPromise = new CompletableFuture(); ... E value = this.createEntry(newPromise); ... RedisPubSubListener<Object> listener = this.createListener(channelName, value); CompletableFuture<PubSubConnectionEntry> s = this.service.subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, new RedisPubSubListener[]{listener}); ...
스핀락이 키를 얻기위해 무제한 요청을 한다면, Redisson은 레디스의 PubSub을 이용해 키를 점유할수 있을 때 까지 대기하다가 가능할 때 점유하기 때문에 자원을 효율적으로 사용합니다.
락 획득 시도는 레디스의 lua script로 실행됩니다.
Lua script는 레디스 서버 내부에서 atomic한 동작을 위해 사용됩니다.레디스 명령어 한 개는 atomic 하게 동작하지만 여러 명령어의 묶음이라면 트랜잭션, watch 혹은 Lua script로 원자적 연산을 보장할 수 있습니다.
if ((redis.call('exists', 락Name == 0) or (redis.call('hexists', 락Name, UUID + threadId) == 1)) then redis.call('hincrby', 락Name, UUID + threadId, 1); redis.call('pexpire', 락Name, 락 점유 시간); return nil; end; return redis.call('pttl', 락Name);
- 첫 번째 조건
(redis.call('exists', 락Name == 0)
레디스 서버에 락Name 이름의 키가 1(있음), 0(없음) 확인
0일때만 키 획득 가능
String 타입으로 등록한 키가 아니라 hashes(hashMap)에 등록한 키를 검색한 것 입니다.
- 두 번째 조건
(redis.call('hexists', 락Name, UUID + threadId) == 1)
레디스 서버에 Hashes에 점유 중인 스레드(UUID + 스레드ID)를 나타냅니다.
이상한 부분
ttl을 지정하기 때문에 시간이 지나면 키가 사라집니다. 키가 존재하지 않아야 락 점유를 시도하는 것은 당연하니 이 부분은 당연합니다.그런데 왜 key에 UUID + 스레드ID 필드가 존재하면 락 점유를 시도하는지 모르겠습니다.
또 해당 스레드가 retry를 시도 하더라도 UUID + 스레드ID로 필드(ARGV[2])를 지정하기 때문에 필드가 1이면(있으면) 락 점유를 시도하는지 모르겠네요.
- then 이후
redis.call('hincrby', 락Name, UUID + threadId, 1); redis.call('pexpire', 락Name, 락 점유 시간); return nil;
hashes에 락 Name의 키에 UUID + threadId 필드를 +1 합니다.
키에 점유시간 만큼 ttl을 설정 후 nil을 리턴 합니다.
-> 락을 획득한 것으로 해당 스레드는 비지니스 로직을 수행할 수 없습니다.
- 아닌 경우
redis.call('pttl', 락Name);
이미 점유된 키의 ttl을 리턴 합니다.
다시 tryLock으로 돌아오겠습니다.
락 획득을 성공한 스레드는 이미 비지니스 로직을 수행했습니다.public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { long time = unit.toMillis(waitTime); long current = System.currentTimeMillis(); long threadId = Thread.currentThread().getId(); Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId); if (ttl == null) { return true; } else {
락 획득에 실패한 스레드는 subscribe 상태에 들어갔습니다.
//if (time > 0L) { CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
먼저 락을 점유했던 스레드가 락을 release하면
큐로된 세마포어에서는 가장 우선순위가 있는 스레드가 튀어나와 timeout 상태인지 확인 합니다.try { subscribeFuture.get(time, TimeUnit.MILLISECONDS); } catch (TimeoutException | ExecutionException var20) { ... this.acquireFailed(waitTime, unit, threadId); return false; }
timeout에 걸리지 않았다면 현재 남은 시간이 있는지 계산 후 다시 락 획득 시도를 합니다.
현재 세마포어에서 나온 스레드는 한 개이기 때문에(Was 1 대 기준) 남은 시간동안 경쟁 없이 오롯이 한 스레드만 남은 시간동안 락 획득 시도를 합니다.try { time -= System.currentTimeMillis() - current; if (time <= 0L) { this.acquireFailed(waitTime, unit, threadId); ... } else { boolean var16; do { long currentTime = System.currentTimeMillis(); ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId); ... time -= System.currentTimeMillis() - currentTime; } while(time > 0L);
그래도 획득을 못했다면 fail을 내고 마지막에는 unsubscribe를 합니다.
... this.acquireFailed(waitTime, unit, threadId); var16 = false; return var16; } } finally { this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId); }
- RLock이 여러 노드를 가진 레디스에서 동작하는 방법
- 여러 WAS에서 RLock이 동기화를 보장할 수 있나?