본문 바로가기

Database

Redis 를 이용한 분산 락 구현

 

분산락?

서비스가 점점 커지다보면 서버의 갯수가 늘고, 요즘에는 대부분이 클라우드환경에서 자동 수평확장 설정을 통하여 얼마든지 서버의 기동대수가 늘 수 있도록 확장에 용이한 구조로 설계를 합니다. 이런 환경에서 서비스를 운영하다보면 여러서버에서 특정한 요청 처리에 대하여 공통된 락이 필요한 경우가 있습니다. 이때 사용하는 락을 분산락이라고 합니다.

 

저의 경우에는 서버의 대수와는 별개로 이체, 송금 도메인에서 클라이언트 단의 오류로 인하여 동시성 처리가 되어 있지 않은 이체, 송금 서버에 동시에 여러번의 요청이 들어와 여러번의 이체가 되는 대형 장애가 발생한 적이 있었고 이를 해결하기 위해 

Redis 를 활용하여 여러대의 서버에 낙관적락을 분산락으로 잡아 같은 요청이 중복으로 요청되는 상황에 대한 처리를 하려고 했던 과정을 공유해보겠습니다.

 

 

간단한 분산 락 구현

Redis 에는 sentx 라는 atomic한 연산이 존재합니다.

sentx 연산은 키에 값이 존재하지 않으면, set 해주는 연산입니다. 

이를 이용해 lock 키가 존재하면 오류를 뱉고, 존재하지 않으면 lock 을잡는 형식의 낙관적락을 구성해보겠습니다.

 

    override fun <T> lock(lockId: String, operation: () -> T): T {

        try {
           while (!tryLock(lockKey)) {
                try {
                    Thread.sleep(50)
                } catch (InterruptedException e) {
                    throw new RuntimeException(e)
                }
            }
            
            operation.invoke()
        } finally {
            lock.unlock()
        }
    }

    fun tryLock(String lockId) = return command.setnx(lockId, "1")
    
    fun unlock(String lockId) = command.del(lockId);

setnx 명령이 성공할때 까지 루프를 돌면서 lock 획득을 시도하고, lock 획득에 성공하면 전달받은 operation 을 실행하는 LockProvider 를 구성해보았습니다.

 

이런식으로 락을 획득할때까지 루프를 돌면서 lock 을 계속 획득하려고 시도하는 방법을 스핀락이라고 합니다.

스핀락에는 큰 단점들이 존재합니다.

 

1. Lock 의 타임아웃을 지정할 수 없습니다.

물론 락 획득 메서드가 실행된후 어느정도 이후에 락을 해제되게 하도록 어플리케이션에서 구현할 수 있지만, 

만약 해당 로직이 돌기 전에 락을 잡은 서버가 다운된다면? 해당 락은 영구히 존재하게 됩니다. 

락의 ttl 을 설정할수 없다는건 매우 치명적입니다.

 

2. 오버헤드가 큽니다.

보시다시피 루프를 돌면서 일정 주기마다 계속 락을 획득하려고 시도해야하기 때문에 redis 에 가해지는 부하가 큽니다.

그렇다고 해서 주기를 늘려서 redis 에 가해지는 부하를 줄이자고 한다면, 오히려 쓰레드가 계속 대기해야하는 상황에 의해서 어플리케이션에 리소스 낭비가 커집니다. 

 

이런 문제를 해결하기 위해서는 레디스의 Pub/Sub 을 활용하여 ttl이 있는 Lock Key 를 잡고, ttl 이 만료되었을때 어플리케이션에 해당 이벤트에 대한 메시지를 발송하여 락을 시도하는 방법이 있습니다.

 

Netty 기반의 non-blocking I/O 를 사용하는 오픈소스 Redis 클라이언트인 Redisson 은

이런 과정을 인터페이스화 하여 구현해두었습니다.

 

이를 활용하여 개선한 LockProvider 를 소개합니다.

 

Redisson 을 활용한 분산락 구현

@Component
interface DistributedLockProvider {
    fun <T> lock(lockId: String, lockWaitSeconds: Long, lockTtl: Long, operation: () -> T): T
} // RDBS 등 다른 분산락을 활용하는 경우를 위하여 인터페이스화

@Component
class RedissonDistributeLockProvider(
    val redisson: RedissonClient
): DistributedLockProvider {

    override fun <T> lock(lockId: String, lockWaitSeconds: Long, lockTtl: Long, operation: () -> T): T {
        val lock = redisson.getLock(lockId) // 1

        try {
            val isLocked = lock.tryLock(lockWaitSeconds, lockTtl, TimeUnit.SECONDS) // 2

            if (!isLocked) { // 3
                throw IllegalAccessException("남은 락 타임: ${lock.remainTimeToLive()}")
            }

            return operation.invoke() // 4

        } catch (e: InterruptedException) {
            throw e // custom exception or handling error
        } finally {
            lock.unlock() //5
        }
    }

}

 

 

코드는 간단합니다

1 - Redis 에서 특정 key에 대한 락을 컨트롤할 객체를 생성합니다.

2 - lockWaitSeconds 는 락을 획득하기 위한 최대 대기 시간, lockTTL 은 락이 지속되는 시간을 뜻합니다. lockTTL 만큼의 시간이 지나면 락은 자동으로 해제됩니다. 그러므로 lockWaitSeconds 가 lockTTL 보다 약간 길게 설정하는 것이 좋습니다.

이때 lockWaitSeconds 이상으로 시간이 지났는데도 락을 획득하지 못하면 false 를 반환하며, 락을 획득했다면 true 를 반환합니다.

3 - lock 을 결국 획득 하지 못했을 경우, 로직이 실행되면 안되므로 남은 락 시간과 함께 예외를 발생시킵니다.

4 = lock 을 획득했다면, 전달받은 로직을 실행시킵니다.

5 - 작업 중 예외가 발생했든, 성공했든 작업 수행 완료 후엔 락을 해제시켜줍니다.

 

 

테스트

    @Test
    fun testLock() {
        //given
        val lockId = "lockId"
        val lockWaitSeconds = 1L
        val lockTtl = 100L

        val number = Number(0)

        //when
        val executors = Executors.newFixedThreadPool(10)

        for (i in 1 .. 10) {
           executors.execute {
               redissonDistributeLockProvider.lock(lockId, lockWaitSeconds, lockTtl) { number.plus() } }
        }

        executors.shutdown();
        executors.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);

        // then
        assertThat(number.number).isEqualTo(10)
    }

    @Test
    fun testLockTimeOut() {
        //given
        val lockId = "lockId"
        val lockWaitSeconds = 1L
        val lockTtl = 100L

        val number = Number(0)

        //when
        val executors = Executors.newFixedThreadPool(10)

        for (i in 1 .. 10) {
            executors.execute { redissonDistributeLockProvider.lock(lockId, lockWaitSeconds, lockTtl) {
                number.sleepAndPlus(2000) } }
        }

        executors.shutdown();
        executors.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);

        // then
        assertThat(number.number).isEqualTo(1)
    }
    
    private class Number(
        var number: Int
    ) {

        fun plus() {
            this.number += 1
        }

        fun sleepAndPlus(ms: Long) {
            Thread.sleep(ms)
            plus()
        }

    }

 

소스코드는 아래에서 확인 할 수 있습니다.

https://github.com/3jin-p/trade