본문 바로가기

생각

성능과 일관성을 무게추에 달아보자 On Redis !

 

 

redis 는 동작이 매우 빠릅니다. replica 와 함께 동작하는 경우에도 그렇습니다. 

redis 는 왜 빠른가?

해당 포스팅에서는 이 궁금증을 따라가보는데에서 출발합니다. 그리고 아래 질문들의 답을 찾아가는 과정입니다.

 

  • redis 는 왜 kafka 처럼 일관성을 위한 지원이 두둑하지않는가?
  • kafka 대용으로 redis 의 PUB/SUB 은 왜 거론되지않는가? 
  • redis 의 replica(복제)는 동기인가 비동기인가?
  • 동기 (또는 비동기) 라면 왜 그럴까?
  • 동기/비동기의 방식을 사용자가 컨트롤할 수 있을까? 마치 kafka 의 ISR 설정처럼?
  • redis 에서 쓰기 작업을 손실할 수 있는 케이스는 무엇이 있을까?
  • 쓰기가 손실되면 어떤 영향이 있을까?
  • 그럼 cluster 는 어떻게 동작하고 있을까? 동기일까 비동기일까?
  • 극강의 효율X, redis 가 데이터 일관성에 대해 제공하는 안정망은 뭐가 있을까?
  • multi process, 그러니까 분산환경에서 cluster 설정을 가져가는 경우 레디스의 lock 은 어떻게 지켜질까?

 

먼저, 보통의 replica 플로우입니다.

 

replica flow

 

 

1. 클라이언트가 마스터에게 쓰기 요청한다. 
2. 마스터는 요청을 수행한 뒤, OK 응답한다.
3. 마스터는 자신의 레플리카인 B1, B2 및 B3 에 쓰기를 전파한다.

redis replica 방식은 kafka ISR(In Sync Replica) 의 복제 확인 응답을 기다리는 것처럼 동작하지 않습니다. 다르게 말하면, replica 는 비동기로 동작한다는 의미입니다. redis 에서는 높은 지연 시간이 강한 패널티가 되기 때문입니다. 그래서 발생할 수 있는 문제는 쓰기 손실입니다. 3번 단계에서 자신의 레플리카로 쓰기를 전파하기 전에 충돌이 발생하면 (쓰기를 전달받지 못한 레플리카 중 하나가 새로운 마스터로 승격되는 등의 시나리오) 그 쓰기는 영원히 손실될 수 있습니다. 

이런 경우에 대비하여 2번 단계에서 OK 응답 전 3단계를 먼저 진행하면 어떨까 생각했습니다. 先 쓰기 전파 後 응답 프로세스가 되게끔요. 이런 sync write 플로우는 WAIT 명령어를 통해 구현할 수 있습니다. 근데 이 경우도 복잡한 실패 시나리오라면 손실은 발생할 수 있습니다.  파티션 분할 + 노드 장애, 네트워크 장애 등의 이유로도 쓰기 손실은 발생하기 때문입니다.

여기까지 이르다보니 이런 의문이 또 들었습니다.

redis 는 왜 kafka 처럼 일관성을 위한 지원이 두둑하지않는가?

이렇게 질문해볼 수도 있을 것 같습니다.

 

kafka 대용으로 redis 의 PUB/SUB 은 왜 거론되지않는가? 

우선 redis PUB/SUB 의 메세지는 어딘가에 저장되지 않습니다. 메세지를 처리하다 실패나면 영원히 재시도가 불가능하다는 의미입니다. "못 받을 수도 있다" 를 염두에 두어야합니다. redis 가 일관성보다 성능 우선 시스템임을 고려하면 이해가 어렵지 않았습니다. redis 는 주로 캐싱, 세션 관리, 실시간 분석과 같은 경우에 많이 사용되는 이유도 언뜻 이 지점에서 이해가 갑니다. "빠른 응답" 이 우선이면서 메세지 손실이 비교적 관대한 경우가 어떤 게 있을까 생각해보았습니다. 다음과 같은 경우가 있을 것 같습니다.

 

 

 

 

1. 웹 서버 A 는 애플리케이션이 뜰 때, redis 에서 db 의 IP 와 Port 를 가져온다.

 

 

 

 

2. redis 에 있는 db 의 IP 와 Port 가 변경되면 해당 정보를 publish 한다.
3. 해당 메세지를 subscribe 하고 있는 A 서버들은 정보를 통해 DB connection 을 재생성한다. 

 

 

위 예시에서 추측할 수 있다시피, redis 의 PUB/SUB 은 실시간 대용량 트래핑에는 적합하지않으며 간헐적 극소량인 경량 트래픽에 적합한 것으로 판단됩니다. 저는 본 질문으로 돌아가 redis 는 본래 태생이 빠른 응답을 위해 태어났으므로 일관성을 높게 보장하지 않는 것이 자연스럽다고 이해했습니다.

하지만 그게 자연스러운 일이다 하더라도, 쓰기 손실이 어떤 영향을 가져오는지 짚고 가야할 것 같았습니다. redis 를 포함한 모든 쓰기 손실에서 하나의 현상으로 stale data 가 있습니다. 이 부분만 짚고 가겠습니다.

stale data?

오래된 데이터

writemaster 에서 하고 read 는 replica 에서 한다면 read 는 오래된 데이터를 읽을 수 있습니다. 이 경우 가시성을 확보하려면 wait 명령어를 사용하여 모두 복제가 되었는지 확인할 수 있긴합니다. 근데 이미 언급했다시피 느리고 완전하지않습니다. 


| stale data 관련 주의사항 |

write 에 대한 응답하기 위해 replica 에서 읽어오면 안된다.

master 에 write 한 요청에게 변경된 값을 return 하기 위해서는 replica 에서 읽은 값으로 응답하면 안됩니다. 아직 마스터로부터 복제가 완료되지 않았을 수 있기 때문입니다. 변경된 값을 redis 가 리턴해주면 해당 값을 사용해야합니다.


단순 read request 는 오래된 데이터를 읽을 수 있다는 걸 인지해야한다.
replica 에 완전히 복제되기까지 어느정도의 텀이 필요합니다. 보통의 경우 write 는 master 에, read 는 replica 에서 이루어지기 때문입니다. replica 에서 무조건 최신 데이터를 읽도록 구현하려면 구현 방법이 복잡해지고 느릴 수 밖에 없습니다. replica 에서 최신 데이터를 읽어야한다는 시나리오는 없다고 생각해두는 게 누이도 좋고 매부도 좋다. 클라이언트도 좋고 서버도 좋다.(?)

 

 

지금까지 replica 를 살펴보았습니다. cluster 도 살펴보았는데요. 우선 각각의 chatGPT 의 설명입니다.

 

node cluster 

여러 대의 독립적인 노드로 구성된 분산 시스템입니다. 각 노드에 데이터 및 작업이 분산됩니다. 높은 가용성을 위해 설정되며 redis 데이터베이스를 분산 환경으로 운영하기 위한 구현체입니다. 기본적으로 redis 는 단일 노드에서 동작하지만, 노드 클러스터를 사용하면 여러 노드 간의 데이터를 분산시키고 관리할 수 있습니다.

 

replica

원본 데이터 또는 노드의 복사본입니다. 일반적으로 마스터 노드의 상태를 복제하여 데이터의 일관성을 유지하거나, 부하 분산을 위해 사용됩니다. 레플리카는 마스터 노드의 데이터를 복제하여 데이터 손실 및 시스템 장애에 대비합니다. 주로 읽기 작업에서 레플리카를 바라보도록 되어있기 때문에 데이터의 안정성과 더불어 가용성을 높이는데에 기여합니다. 

 

 

cluster 는 높은 가용성을, replica 는 일관성과 안정성에 초점을 짚어볼 수 있을 것 같습니다. 그럼 동작하는 방식도 다를까요?


| Write safety Redis Cluster uses asynchronous replication between nodes, and last failover wins implicit merge function.
| Because of the use of asynchronous replication, nodes do not wait for other nodes' acknowledgment of writes (if not explicitly requested using the [`WAIT`]command).

 

redis 에서 cluster 와 replica 은 모두 비동기로 동작합니다. 데이터 처리와 복제 과정 전부 효율성과 성능에 포커싱되어 있는 것으로 이해됩니다. 그럼 분산환경 + cluster + replica 까지 세팅된 고가용성 중심 애플리케이션에서 데이터의 일관성을 위해 redis 가 제공하는 안전망은 무엇일까 생각해보았습니다. 데이터 일관성은 데이터가 여러 사용자 또는 프로세스에 의해 동시에 수정되거나 엑세스 될 때 유지되어야 하는 속성입니다. 이를 위해 데이터 접근을 컨트롤하는 요소를 우리는 "Locking 한다" 라고 표현합니다. redis 의 locking 매커니즘을 찾아보았습니다.

먼저 가장 간단하게 redisTemplate 의 setIfAbsent 명령어로 잠금 매커니즘을 흉내내볼 수 있습니다.

class RedisLock(
    private val redisTemplate: RedisTemplate<String, String>
) : Closeable {
    companion object {
        const val LOCK_PREFIX = "lock:"
        const val LOCK_TIMEOUT = 5000L // 락 타임아웃 시간 (밀리초)
    }

    private var lockName: String? = null

    fun acquire(lockName: String): Boolean {
        val lockKey = LOCK_PREFIX + lockName
        val lockValue = UUID.randomUUID().toString()
        val locked = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, LOCK_TIMEOUT, TimeUnit.MILLISECONDS)
        if (locked == true) {
            this.lockName = lockName
        }
        return locked ?: false
    }

    override fun close() {
        val lockName = this.lockName
        if (lockName != null) {
            val lockKey = LOCK_PREFIX + lockName
            redisTemplate.delete(lockKey)
        }
    }

    fun <T> withLock(lockName: String, action: () -> T, onLockFailed: () -> T): T {
        return RedisLock(redisTemplate).use { lock ->
            if (lock.acquire(lockName)) {
                action()
            } else {
                onLockFailed()
            }
        }
    }
}

@Service
class MyService(
    private val redisTemplate: RedisTemplate<String, String>
) {
    fun doSomething() {
        val lockName = "myLock"
        RedisLock(redisTemplate).withLock(
            lockName = lockName,
            action = {
                // 락 획득 성공 시 수행할 작업
                // ...
            },
            onLockFailed = {
                // 락 획득 실패 시 처리할 로직
                // ...
            }
        )
    }
}

 

 

 

setIfAbsent 는 내부적으로 Redis의 `SETNX` 명령어를 사용합니다. "Set if Not eXists"의 약어로, 주어진 키가 존재하지 않을 때만 해당 키에 값을 설정하는 명령어입니다. 원자적인 작업이라고 볼 수 있지만, 이 경우에도 경합이 발생할 수 있는 여지가 여전히 존재합니다. 분산환경의 애플리케이션에서 동시에 acquire() 에 접근할 수 있기 때문입니다. 결과적으로 동시성 제어가 완벽하게 이루어지지않는다는 의미입니다. 그럼 어떻게 하나... 방법은 두 가지인데 결론은 하나 입니다. Lock Mechanism 을 직접 구현하거나, redission 같은 라이브러리를 사용하거나!

저는 재고 차감 로직에서 redission 을 사용했었는데요, 이번 기회에 이 lock 을 획득하는 코드를 자세히 살펴볼 수 있었습니다.

 

 

 

 

getLock()사용하여 Lock 객체를 생성하는 대략적인 코드입니다. 이 Lock 객체는 분산된 환경에서 여러 클라이언트가 공유하는 Lock 입니다. 여러 Redis 인스턴스 간에도 이 Lock 은 동기화되고 있습니다. tryLock() 을 통해 Lock을 획득할 수 있는데요. 내부를 파고 들어가보겠습니다.

 

 

해당 tryLock 의 핵심 로직은 tryAcquire() 입니다. 

 

 

락 획득을 시도합니다. 이때 쓰레드 별로 lock 을 잡아야하니까 threadId 를 전달합니다. 더 따라 들어가다보면 tryLockInnerAsync() 메소드가 있습니다. waitTime은 Lock을 얻기 위해 대기할 최대 시간을, leaseTime은 Lock을 보유할 시간을 나타냅니다. syncedEval 메서드를 사용하여 Redis Lua 스크립트를 실행합니다. 이 스크립트는 Lock을 시도하고 성공하면 null을 반환하고, 실패하면 Lock이 이미 보유되어 있는 경우 남은 유효 시간을 반환합니다. 

LuaScript 를 통해 redis 에 날리고 있는 명령어는 다음과 같습니다.

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]);

 

 

간략하게 다음과 같은 동작을 수행하는 듯 보입니다.

- 만약 Lock이 존재하지 않거나 현재 Lock을 보유하고 있는 스레드인 경우에는 Lock을 획득한다.
- Lock을 획득한 경우, 해당 Lock의 유효 시간을 설정하고 null을 반환한다.
- Lock을 획득하지 못한 경우, Lock의 남은 유효 시간을 반환한다.

 

 

반환받은 값으로 락 획득 여부를 판단하는데, 획득하지 못한 경우는 pub/sub 방식으로 락 획득을 시도합니다.  threadId 로 lock subscribe 을 시작하는 이유입니다. 락 획득 대기 시간동안 계속 루프를 돌면서 락획득 시도하다가 결국 대기 시간이 끝나면 lock 획득 실패됩니다.

 

 

lock 획득을 실패하거나, lock 획득 후 처리가 완료되면 RedissionLockEntry 를 구독 채널에서 구독 해지해주고 있습니다. 다른 클라이언트가 lock 획득할 수 있는 시그널을 받을 수 있도록 하기 위해서입니다!

 

 

해당 매커니즘이 지원하는 안전 요소를 정리해보면 다음과 같습니다.

1. 원자성: Lua 스크립트는 Redis의 EVAL 명령어를 사용하여 실행되며, Redis는 스크립트를 원자적으로 실행합니다. 따라서 Lock을 시도하는 과정은 다른 클라이언트의 영향을 받지 않고 원자적으로 수행됩니다.
    
2. 동시성 제어: Lua 스크립트에서 Lock을 시도하는 과정은 Redis의 단일 명령어로 수행되기 때문에 여러 클라이언트가 동시에 Lock을 시도해도 경쟁 조건이 발생하지 않습니다. Redis가 제공하는 원자성 보장 메커니즘을 통해 동시성 문제를 해결합니다.
    
3. 유효 시간 설정: Lock을 획득한 후 Lua 스크립트에서는 해당 Lock의 유효 시간을 설정합니다. 이를 통해 Lock이 일정 시간 동안만 유지되며, Lock을 보유한 클라이언트가 비정상적으로 종료되는 등의 상황에서도 데이터의 일관성을 유지할 수 있습니다.
    
4. 실패 시 안전한 처리: Lock을 시도하는 과정에서 실패한 경우, Lua 스크립트는 현재 Lock의 남은 유효 시간을 반환합니다. 이를 통해 Lock을 획득하지 못한 클라이언트는 Lock이 해제되기를 기다릴 수 있습니다.
    

마치 100% 완전무결한 것 처럼 정리해버렸긴하지만 .. 이 방법도 모든 케이스를 커버하진 못합니다. 과도한 lock 경쟁이 발생하는 상황이라면 또 다른 겹의 안정망을 덧대야겠죠? 결국 락을 획득하지 못한 경우에도 재시도를 할 것인지, 에러로 정의할 것인지 같은 결정사항도 정해져야겠네요.

redis 성능에 대한 호기심부터 시작해서 kafka 와의 비교, 그리고 쓰기가 손실될 수 있는 케이스와 그 영향을 생각해보게되었습니다. 그 과정에서 성능과 일관성을 무게추에 두고 redis 의 태생을 생각해보며 redis 라는 기술 자체에 대해 이해해보는 시간을 가질 수 있었습니다. 결국에는 redisson 의 Lock 매커니즘 구현 코드까지 까보게 되었네요. 글을 다시 훑어보니 시작과 끝이 다른 갈래에 있는 것 같습니다. 근데 뭐 공부는 원래 꼬리의 꼬리를 물어가는 과정이라고 생각합니다. ㅋㅋㅋ  그 물음을 쫓아가다보니 막다른 길까지 오게 된 것 같지만 덕분에 저는 또 다른 물음을 얻을 수 있었습니다. 

  • Lock 매커니즘 구현 코드 내부의 CompletableFuture 는 Lock 매커니즘에서 어떤 기조를 지지하고 있는가?
  • 쓰레드와 쓰레드 풀에 대한 이해가 왜 필요한가?


막다른 길의 터닝 포인트로 CompletableFuture 를 공부해봐야겠네요. 그럼 20000