Redisson의 RLock을 알아보자

홍지범·2023년 10월 20일
1

동시성 제어를 위해 가장 확실한 방법 중 하나는 한 번에 한 로직만 작동하도록 하는 것 입니다.

레디스를 활용한 분산락(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

스핀락 동작 방식

RLock 시작

RLock - getLock

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());
    }

RLock - tryLock

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 {

RLock - tryLock - tryAcquire

락 획득 과정의 기본적인 구조는 아래와 같습니다.

    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 {
  1. 최초 락 걸기 시도(tryLock)
락 획득(tryAcquire) 시도 과정
1. 레디스 서버에서 Lua Script 실행
2.Name의 키가 없으면 키 획득 -> TTL 설정 -> nil 리턴
or
1.Name의 키가 있으면 락Name의 남은 TTL 리턴

  1. 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을 이용해 키를 점유할수 있을 때 까지 대기하다가 가능할 때 점유하기 때문에 자원을 효율적으로 사용합니다.

tryAcquire - Lua script

락 획득 시도는 레디스의 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을 리턴 합니다.

RLock - tryLock

다시 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);
        }

TODO

  • RLock이 여러 노드를 가진 레디스에서 동작하는 방법
  • 여러 WAS에서 RLock이 동기화를 보장할 수 있나?
profile
왜? 다음 어떻게?

0개의 댓글