출처

https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html

 

레디스와 분산 락(1/2) - 레디스를 활용한 분산 락과 안전하고 빠른 락의 구현

레디스를 활용한 분산 락에 대해 알아봅니다. 그리고 성능을 높이고 일관성을 보장하는 방법에 대해 알아봅니다.

hyperconnect.github.io

출처 참고 부탁드립니다.

 

 

락이란?

 

Database 에서 사용하는 개념으로 트랜잭션 처리의 순차성을 보장하기 위한 방법 중 하나이다.

트랜잭션이란 DB의 나누어지지 않는 최소한의 처리 단위이다.

이런 DB의 특징을 Atomic (원자적)하다고 부르는데 쉽게 말해서 한 번에 하나의 행동이 되는 것을 보장한다는 의미이다.

락을 획득한다는 것은 자원을 사용해도 된다는 의미이며, 다른 프로세스는 현재 락을 획득한 프로세스가 잠금을 건 자원에 대해서 사용할 수 없음을 의미합니다.

이런 방법으로 순차성을 보장합니다.

 

 

스핀락이란?

 

락을 걸지 못하면 무한 루프를 돌면서 계속 락을 얻으려고 시도하는 동기화 기법이다.

만약 락을 얻지 못할 경우, 쉬지 않고 계속 락을 얻으려고 시도하기 때문에 만약 다른 lock 이 하는 일이 많아 대기가 길어진다면 시간 낭비가 엄청나게 생길 수 있다는 단점이 있다

또한 분산락과 다르게, 락을 획득하지 못했을 경우 1회성으로 요청을 하며 락을 얻을 때까지 계속 요청을 보내면서 대기 하기 때문에 서버에 많은 부하를 줍니다.

부하를 낮추기 위해서, 락을 요청하는 시간을 길게한다면, 락을 얻을 수 있는 시간임에도 불구하고 시스템에서 설정해놓은 시간만큼 더 기다려야 한다.

이런 문제점을 해결하기 위해서 분산락을 사용한다.

 

 

스핀락을 사용하지 않고 구현한 락

 

스핀락을 사용하지 않는 레디스 클라이언트 Redisson 은 서버 측에서 subscribe 한 클라이언트에게 "락을 사용해도 된다" 라고 알림을 주어서 락의 획득 가능 여부를 일일이 클라이언트가 요청해서 확인하지 않아도 됩니다.

따라서 레디스 서버에 스핀락보다 훨씬 적은 부담을 줍니다.

 

락을 사용해야 하는 상황

 

여러 서버를 운영하는 분산환경에서 락 처리에 관한 고민을 할 필요성이 있다.

분산 락은 데이터베이스 등 공통된 저장소를 이용해서 자원이 사용중인지를 체크하고 그렇기 때문에 전체 서버에 동기화된 처리가 가능하다.

분산락을 사용해야 하는 상황은 다음과 같다.

  • 선착순 이벤트가 있는 경우
  • 한 사람이 여러번의 클릭으로 동일한 상태를 방지하고 싶은 경우 (한번 클릭한 기록이 있으면 더이상 클릭 해도 서버측에서 무언가를 처리하지 않도록 하고 싶은 경우)

 

간단한 분산락 구현

 

아래 코드에 일반적인 로컬 스핀락을 구현하는 것과 아주 유사하게 분산락을 구현한다.

레디스 클라이언트로는 Lettuce 를 사용한 코드이다.

  1. 락을 획득한다는 것은 "락이 존재하지는지 확인한다", "존재하지 않는다면 락을 획득한다" 이 두 연산이 atomic 하게 이루어 져야 합니다. 레디스는 "값이 존재하지 않으면 세팅한다"라는 setnx 명령어를 지원합니다. 이 setnx 를 이용하여 레디스에 값이 존재하지 않으면 세팅하게 하고, 값이 세팅 되었는지 여부를 리턴 값으로 받아 락을 획득하는데 성공합니다.
  2. try 구문 안에서 락을 획득할때까지 계속 락 획득을 시도합니다. 이 때 레디스에 너무 많은 요청이 가지 않도록 약간의 sleep 을 걸어줬습니다.
  3. 락을 획득한 후에 연산을 수행합니다.
  4. 락을 사용한 후에는 꼭 해제하도록 finalliy 에서 락을 해제 해줍니다.
void doProcess() {
    String lockKey = "lock";

    try {
        while (!tryLock(lockKey)) { // (2)
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        
        // (3) do process
    } finally {
        unlock(lockKey); // (4)
    }
}

boolean tryLock(String key) {
    return command.setnx(key, "1"); // (1)
}

void unlock(String key) {
    command.del(key);
}

 

문제점 해결

위 코드에는 여러가지 문제점이 있습니다.

 

1. Lock 의 타임아웃이 지정되어 있지 않습니다.

위 코드와 같이 스핀락을 구현했을시에 락을 획득하지 못하면 무한 루프를 돌게 됩니다. 만약 특정한 어플리케이션에서 tryLock 을 설공했는데 불운하게도 어떤 오류 때문에 어플리케이션이 종료되어 버리면 어떻게 될까요? 다른 모든 어플리케이션까지 영원히 락을 획득하지 못한 채 락이 해제되기만을 기다리는 무한정 대기 상태가 되어 전체 서비스 장애가 발생하게 될 것입니다.

그래서 일반적인 로컬 스핀 락과는 다르게 일정 시간이 지나면 락이 만료 되도록 구현해야 합니다. 그러려면 expire time을 설정해주어야 합니다. 하지만, 위 코드에서는 "락을 사용중인지 확인", "락을 획득" 연산을 하나로 묶기 위해 setnx 명령어를 사용했는데 이 명령어는 expire time을 지정할 수 없기에 문제를 해결할 수 없습니다.

또한 무한정으로 락의 획득을 시도한다면 문제가 될 수 있습니다. 만약 연산이 오래 걸릴 경우 대부분의 스레드가 락을 대기하는 상태가 되어 클라이언트에 응답하는 속도가 늦어지고 동시에 레디스에 엄청난 트래픽을 보낼 수 있기 때문입니다.

그래서 락을 획득하는 최대 시간을 허용해주거나, 최대 허용 횟수를 정해주는 것이 좋습니다.

만약 락을 획득하는데에 실패한다면 연산을 수행할 수 없는 상태이기에 Exception 을 던집니다.

2. tryLock 로직은 try-finally 구문 밖에서 수행해야 합니다.

1번을 해결했다고 가정한다면 특정 시간 혹은 횟수 내에 락을 획득하지 못하면 Exception 이 발생하게 됩니다.

Exception 이 발생하면 finally 구분의 unlock() 이 실행되어 락을 해제할 타이밍이 아닌데도 락을 해제시키기 때문에 작업이 수행중이더라도 다른 곳에서도 연산을 수행할 수 있게 되어 동기화를 보장할 수 없게 됩니다.

이 문제는 단순히 try-finally 구문 밖에서 락 획득을 시도함으로써 해결할 수 있습니다.

 

int maxRetry = 3;
int retry = 0;

while (!tryLock(lockKey)) {
    if (++retry == maxRetry) {
        throw new LockAcquisitionFailureException();
    }
    
    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

try {
    // do process
} finally {
    unlock(lockKey);
}

 

 

3. 레디스에 많은 부하를 가하게 됩니다.

위의 코드는 스핀락을 사용했지만 사실 스핀락을 사용하면 레디스에 엄청난 부담을 주게 됩니다. 스핀 락은 지속적으로 락의 획득을 시도하는 작업이기 때문에 레디스에 계속 요청을 보내게 되고 레디스는 이런 트래픽을 처리하느라 부담을 받게 됩니다.

스핀 락을 사용하면서 레디스에 부담을 덜 주기 위해 50ms 만큼 sleep 하면서 tryLock 을 수행하도록 했지만, 이 또한 50ms 마다 계속 레디스에 요청을 보내는 것이므로 작업이 오래 걸릴 수록, 요청의 수가 많을 수록 더 큰 부하를 가하게 됩니다.

만약 300ms가 걸리는 동기화된 작업에 동시에 100개의 요청이 왔다고 가정합니다 (분삭 락이므로 서버의 대수는 무관합니다)

처음으로 락을 획득하는데 성공한 1개의 요청을 제외하고 나머지 99개의 요청은 작업이 완료 되는 300ms 동안 레디스에 594회의 락 획득 요청을 하게 됩니다.

즉 1초에 약 2000 회라는 많은 요청을 레디스에 보내게 됩니다.

또한 일회성이 아니라 모든 작업이 완료 될때까지 지속적으로 레디스에 부하를 가하기 때문에 요청이 지속적으로 들어오는 환경이 아니라면 이런 비효율성은 더욱 커집니다.

만약 레디스에 부담을 덜 주기 위해서 sleep 시간을 300ms 로 늘린다면 50ms 가 걸리는 작업에 이 동기화를 적용하면 락을 획득하지 못할 경우 50ms 걸리는 작업을 하기 위해 300ms 를 대기해야 하는 비효율적인 상황이 생깁니다.

이런 문제를 해결하기 위해 Redisson 이 분산 락을 어떻게 설계했는지 소개하며 어떻게 문제점을 해결했고 보다 빠른 성능을 내게 되었는지 설명합니다.

 

 

Redisson 은 분산 락을 어떻게 구현했을까?

 

Redisson은 Jedis, Lettuce 와 같은 자바진영의 레디스 클라이언트입니다.

Lettuce와 비슷하게 Netty 를 사용하며 non-blocking I/O 를 사용합니다. Redisson 의 특이한 점은 직접 레디스의 명령어를 제공하지 않고 , Bucket 이나 Map 과 같은 자료구조나 Lock 같은 특정한 구현체의 형태로 제공한다는 점입니다.

1. Lock 에 타임아웃이 구현되어 있습니다.

Redission 은 tryLock 메소드에 타임아웃을 명시하도록 되어 있습니다. 첫 번째 파라미터는 락 획득을 대기할 타임아웃이고, 두 번째 파라미터는 락이 만료되는 시간입니다.

첫 번째 파라미터 만큼의 시간이 지나면 false 가 반환되며 락 획득에 실패했다고 알려줍니다. 그리고 두 번째 파라미터 만큼의 시간이 지나면 락이 만료되어 사라지기 때문에 어플리케이션에서 락을 해제해주지 않더라도 다른 스레드 혹은 어플리케이션에서 락을 획득할 수 있습니다.

이로 인해 락이 해제되지 않는 문제로 무한 루프에 빠질 위험이 사라졌기 때문에 위의 1번 문제를 해결할 수 있습니다.

// RedissonLock의 tryLock 메소드 시그니쳐
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException

 

2. 스핀 락을 사용하지 않습니다.

Redisson은 기본적으로 스핀 락을 사용하지 않기 때문에 레디스에 부담을 주지 않습니다. 그럼 어떻게 락의 획득 가능 여부를 판단할까요?

Redisson은 pubsub 기능을 사용하여 스픽 락이 레디스에 주는 엄청난 트래픽을 줄였습니다. 락이 해제될 때 마다 subscribe 하는 클라이언트들에게 "이제 락 획득을 시도해도 됩니다"라는 알림을 주어 일일이 레디스에 요청을 보내 락의 획득 가능 여부를 체크하지 않아도 되도록 개선했습니다.

또한 Redisson 은 최대한 레디스와 어플리케이션에 부하를 주지 않도록 신경 쓴 모습이 보입니다. 아래는 Redisson 의 Lock 획득 프로세스 입니다.

  1. 대기 없는 tryLock 오퍼레이션을 하여 락 획득에 성공하면 true 를 반환합니다. 이는 경합이 없을 때 아무런 오버헤드 없이 락을 획득할 수 있도록 해줍니다.
  2. pubsub을 이용하여 메세지가 올 때까지 대기하다가 락이 해제되었다는 메세지가 오면 대기를 풀고 다시 락 획득을 시도합니다. 락 획득에 실패하면 다시 락 해제 메세지를 기다립니다. 이 프로세스를 타임아웃시까지 반복합니다.
  3. 타임아웃이 지나면 최종적으로 false 를 반환하고 락 획득에 실패했음을 알립니다. 대기가 풀릴 때 타임 아웃 여부를 체크 하므로 타임아웃이 발생하는 순간은 파라미터로 넘긴 타임아웃 시간과 약간의 차이가 있을 수 있습니다.

3. Lua 스크립트를 사용합니다.

위와 같이 락의 기능을 제공하더라도 락에 사용되는 여러 연산은 atomic 해야 합니다. 그 이유는 각 명령어를 따로 보내게 되면 두 연산이 atomic 하지 않게 수행되기 때문에 명령어의 실행 순서가 섞일 수 있어 예상과 다른 결과가 나올 수 있기 때문입니다.

  • 락의 획득가능 여부 확인과 획득은 atomic 해야 합니다. 그렇지 않으면 락 획득이 가능하다고 응답 받은 다음, 락 획득을 시도했는데 그 사이 이미 다른 스레드에서 락을 획득해버려서 락 획득을 실패하는 경우가 생길 수 있기 때문 입니다.
  • 락의 해제와 pubsub 알림은 atomic 해야 합니다. 그렇지 않으면 락이 해제되고 바로 다른 스레드에서 락을 획득했을 때에도 락 획득을 시도해도 된다는 알람이 갈 수 있습니다.

레디스는 싱글 스레드 기반으로 연산하기 때문에 이러한 atomic 연산을 비교적 쉽게 구현할 수 있습니다. 그래서 레디스는 트랜잭션, Lua 스크립트로 atomic 연산을 지원합니다.

트랜잭션은 명령어를 트랜잭션으로 묶는 기능이기에 명령어의 결과를 받아서 다른 연산에 활용하는 atomic 한 연산을 구현하기 어렵습니다. 하지만 Lua 스크립트를 사용하면 atomic 을 보장하는 스크립트를 쉽게 구현할 수 있습니다.

Redisson 는 이러한 Lua 스크립트를 많이 활용하고 있습니다. RedissonLock 에서도 Lua 스크립트를 사용하여 연산의 atomic 을 보장하면서도, 레디스에 보내는 요청 수를 현저하게 줄여 성능을 높이고 있습니다.

 

// in RedissonLock.java
<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));
}