본문 바로가기

프로젝트/개발

Redisson 분산락

728x90

들어가며

학부 시절 마인크래프트 서버 포럼을 개발해 보면서 각 서버별 인원수 체크를 위한 검사를 위해 크론잡을 수행한 경험이 있다. 당시 개발 경험을 위해 클러스터링 환경으로 구성했는데, 문제는 배치가 수행되면서 모든 서버가 일괄적으로 크론이 실행되는 문제가 있었다. 너무 당연하지만, 당시 하나의 서버만 구성해 운영하는 것을 생각하던 버릇이 있던 나에겐 꽤 신선한 충격이었다.

 

당시엔 레디스의 클러스터링 환경 분산락 구현체인 RedLock으로 문제를 해결했는데, 조금 많이 과하기도 하고, RedLock 알고리즘의 경우 각 레디스가 위치한 서버 간에 시간 동기화와 락 획득에 실패한 경우 실패한 클라이언트는 획득한 부분 락을 모두 푸는 작업을 요청하는 등 매우 특별한 상황에서 문제를 해결하는 알고리듬인 것을 알았다.

Why Redisson?

Redisson

Redisson은 "Redis Java client with features of In-Memory Data Grid"이라고 자신을 소개한다. 특히 여기서 나에게 관심을 끈 대목은 pub/sub을 이용한 락 구현체이다. 일반적으로 Redis를 이용해 가장 간단하게 구현할 수 있는 분산락은 SET 명령어(SETNX/SETEX는 deprecated 됨)를 이용해 락 획득 시도/실패를 구현할 수 한다. 이때 문제는 이 과정이 Spinlock 형태를 띠는데, 이런 경우 클라이언트 서버와 레디스 서버 모두 CPU burst 한 작업이 되게 된다.

 

대부분의 웹 서버는 I/O 처리에 특화된 상황에서 CPU burst 한 상황이 지속되면 전체적인 서버 성능 저하가 발생하고, 레디스가 워낙 빠르지만, 락 획득 시도에 대한 처리를 위해 다른 작업이 지연될 수 있다.

How to work

tryLock

redisson.getLock("rockAndRoll").tryLock(10, 10000, java.util.concurrent.TimeUnit.SECONDS);

Redisson을 통해 lock을 획득하는 방법은 매우 간단하다. 위 한 줄로 "rockAndRoll"에 대해 락 획득을 시도한다.

Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
    return true;
}

내부적으로 호출되면 바로 락을 획득하기 위해 시도한다. 성공적으로 락을 얻으면, 바로 참(true)을 반환한다.

try {
    subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
    if (!subscribeFuture.completeExceptionally(new RedisTimeoutException(
            "Unable to acquire subscription lock after " + time + "ms. " +
                    "Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
        subscribeFuture.whenComplete((res, ex) -> {
            if (ex == null) {
                unsubscribe(res, threadId);
            }
        });
    }
    acquireFailed(waitTime, unit, threadId);
    return false;
} catch (ExecutionException e) {
    acquireFailed(waitTime, unit, threadId);
    return false;
}

이 시점에서 락을 획득하지 못하면 락 해제 통보를 받기 위해서 SUBSCRIBE를 한다. 이때 대기열에 'subscriptionPerConnection' or 'subscriptionConnectionPoolSize' 만큼 이미 대기하고 있다면, 첫 번째 인자값만큼 대기한다.

while (true) {
    long currentTime = System.currentTimeMillis();
    ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
        return true;
    }

    time -= System.currentTimeMillis() - currentTime;
    if (time <= 0) {
        acquireFailed(waitTime, unit, threadId);
        return false;
    }

    // waiting for message
    currentTime = System.currentTimeMillis();
    if (ttl >= 0 && ttl < time) {
        commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
    } else {
        commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
    }

    time -= System.currentTimeMillis() - currentTime;
    if (time <= 0) {
        acquireFailed(waitTime, unit, threadId);
        return false;
    }
}

대기열에 대기 중인 락 획득 요청들은 waitTime 범위 내에서 지속적으로 락 획득을 시도한다. 이 과정에서 대기열이 너무 클 경우 락 획득 재요청이 한 번에 레디스에 요청이 보내지는 것과, 락 획득을 위해 연결을 점유하고 있기 때문에 레디스의 최대 연결수 또한 고려해야 한다.

20:06:33.665 [0 10.244.1.1:27443] "EVAL" "if ((redis.call('exists', KEYS[1]) == 0) or (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]);" "1" "rockAndRoll" "9000" "03cea617-ace1-4bbe-8dec-f575fe67acf1:141"
20:06:33.666 [0 lua] "exists" "rockAndRoll"
20:06:33.666 [0 lua] "hincrby" "rockAndRoll" "03cea617-ace1-4bbe-8dec-f575fe67acf1:141" "1"
20:06:33.666 [0 lua] "pexpire" "rockAndRoll" "9000"
20:06:33.675 [0 10.244.1.1:39271] "EVAL" "local val = redis.call('get', KEYS[3]); if val ~= false then return tonumber(val);end; if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); redis.call('set', KEYS[3], 0, 'px', ARGV[5]); return 0; else redis.call('del', KEYS[1]); redis.call(ARGV[4], KEYS[2], ARGV[1]); redis.call('set', KEYS[3], 1, 'px', ARGV[5]); return 1; end; " "3" "rockAndRoll" "redisson_lock__channel:{rockAndRoll}" "redisson_unlock_latch:{rockAndRoll}:5f2ba4d2eb281bbff65eb4c778efc5a8" "0" "9000" "03cea617-ace1-4bbe-8dec-f575fe67acf1:141" "PUBLISH" "13500"
20:06:33.676 [0 lua] "get" "redisson_unlock_latch:{rockAndRoll}:5f2ba4d2eb281bbff65eb4c778efc5a8"
20:06:33.676 [0 lua] "hexists" "rockAndRoll" "03cea617-ace1-4bbe-8dec-f575fe67acf1:141"
20:06:33.676 [0 lua] "hincrby" "rockAndRoll" "03cea617-ace1-4bbe-8dec-f575fe67acf1:141" "-1"
20:06:33.676 [0 lua] "del" "rockAndRoll"
20:06:33.676 [0 lua] "PUBLISH" "redisson_lock__channel:{rockAndRoll}" "0"
20:06:33.676 [0 lua] "set" "redisson_unlock_latch:{rockAndRoll}:5f2ba4d2eb281bbff65eb4c778efc5a8" "1" "px" "13500"
20:06:33.680 [0 10.244.1.1:28381] "DEL" "redisson_unlock_latch:{rockAndRoll}:5f2ba4d2eb281bbff65eb4c778efc5a8"

MONITOR 명령어를 통해 레디스 락을 획득하기 위한 시도를 모니터링할 수 있다. 테스트 코드에서 100개의 스레드에서 동시 요청한 경우 락 획득을 위한 과정에서 매우 많은 요청들이 들어오는 것을 확인할 수 있다.

 

여기서 알 수 있는 점은 Redisson은 내부적으로 락 획득 과정에 대한 원자성을 달성하기 위해서 lua script를 사용하는 것을 알 수 있다.

> hkeys rockAndRoll
1) "4b547c34-56f0-4989-935c-94149ac5a2b6:1"

락을 획득하면 위처럼, {Redisson이 생성한 id}:{스레드 ID} 형태의 값을 가진 락을 생성한다.

} finally {
    this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}

마지막으로 락을 획득한 또는 획득하지 못하고, 시간 초과가 된 경우에 구독을 해지하고, 다음으로 수행한다.

'프로젝트 > 개발' 카테고리의 다른 글

동시성 이슈 없는 조회수⏳ 기능 개발 고민  (0) 2023.12.05
I am메모리에요~ 🤗  (0) 2023.11.10
첫 번째, TL;DR  (0) 2023.11.10
프로젝트 회고 #작성중  (0) 2022.02.02