코딩관계론

레디스 동시성 개선 및 성능 향상 포인트 본문

개발

레디스 동시성 개선 및 성능 향상 포인트

개발자_티모 2024. 8. 25. 20:29
반응형

최근 프로젝트에서 Redis를 사용하는 분산 시스템에서 동시성 이슈가 발생하는 것을 확인했습니다. 단일 worker 환경에서는 동시성 문제를 크게 신경 쓰지 않아도 되었지만, 시스템을 scale-out 하면서 여러 가지 동시성 문제가 발생하게 되었습니다.

 

예를 들어, 아래와 같은 코드가 있을 때 단일 Thread로 동작한다면 동시성 이슈가 발생할 수 있을까요?

RScoredSortedSetAsync<String> scoredSortedSet = client.getScoredSortedSet(key);

RFuture<Double> scoreFuture = scoredSortedSet.firstScoreAsync();
RFuture<String> nameFuture = scoredSortedSet.pollFirstAsync();

 

 

정답은 "발생할 수 없다"입니다. 하나의 Thread만이 scoredSortedSet에 접근하는 것이 보장된다면, 모든 연산은 순차적으로 실행되기 때문에 동시성 이슈가 발생하지 않습니다.

 

그러나 scoredSortedSet에 접근하는 Thread가 두 개 이상이면 어떻게 될까요? 아래 그림처럼 동시성 문제가 발생할 수 있습니다.

 

위 코드를 실행했을 때  원하는 결과는 (1, USER1), (2, USER2)를 받는 것이지만, 아래의 그림처럼 Redis에 명령어가 비순차적으로 도착하게 된다면 응답결과는 (1, USER1), (1, USER2)가 됩니다. 이를 해결하기 위해서는 "락을 획득하는 방법"과 "연산을 일괄 수행하는 방법"을 고려할 수 있습니다

 

 

락을 이용한 동시성 문제 해결

락을 이용하면 동시성 문제를 해결할 수 있지만, 멀티쓰레딩과 scale-out의 이점을 누리기는 어려워집니다. 락을 획득해야만 연산을 수행할 수 있기 때문에 병목 지점이 생기게 되며, 결과적으로 모든 쓰레드가 순차적으로 실행되게 됩니다. 이는 사실상 단일 쓰레드 환경과 다를 바 없게 만듭니다.

 

또한 Redis에서 락을 얻는 작업은 빠르지만, 네트워크 왕복 시간이 세 번 발생하게 되어 네트워크 비용이 증가합니다.

RScoredSortedSetAsync<String> scoredSortedSet = client.getScoredSortedSet(key);

Redis.tryLock()
RFuture<Double> scoreFuture = scoredSortedSet.firstScoreAsync();
RFuture<String> nameFuture = scoredSortedSet.pollFirstAsync();
Redis.releaseLock()

 

 

따라서 이 문제를 해결하기 위해서는 우리가 연산의 순서가 중요한 것인지 연산의 결과가 중요한 것인지에 대해서 생각해봐야 합니다.

연산의 순서라 하면 Thread1 이후에 Thread2가 수행되는 것이 연산의 순서가 되겠고, 연산의 결과는 (1, USER1), (2, USER2)를 반환하지만 Thread1과 Thread2중 어떤 결과 값을 받을지 모르는 상태를 말합니다.

MULTI/EXEC와 Pipelining을 활용한 해결 방안

이 문제를 보다 효율적으로 해결하기 위해, Redis의 MULTI/EXEC와 Pipelining 기능을 활용할 수 있습니다. MULTI 명령이 전송되면 그 이후에 전송되는 명령어들은 즉시 실행되지 않고, Redis 명령어 큐에 쌓입니다. 이후 EXEC 명령이 전송되면 큐에 있는 명령어들이 일괄적으로 실행됩니다. 중요한 점은 Redis가 MULTI 트랜잭션이 시작된 상태에서도 다른 클라이언트의 요청을 계속 처리할 수 있다는 점입니다. Redis는 EXEC 명령이 호출되어 트랜잭션이 커밋될 때만 잠시 동안 다른 클라이언트의 명령 처리를 중지합니다.

 

Pipelining 기능은 여러 명령어를 클라이언트 측에서 수집한 후 서버로 일괄 전송하는 방식입니다. 이 방식은 네트워크 왕복 시간을 줄이고 Redis와 클라이언트 간의 성능을 크게 향상시킬 수 있습니다.

 

따라서 위의 코드는 아래와 같이 변경되어야 합니다.

batch = client.createBatch(BatchOptions.defaults().executionMode(BatchOptions.ExecutionMode.REDIS_WRITE_ATOMIC));

RScoredSortedSetAsync<String> scoredSortedSet = batch.getScoredSortedSet(key);
RFuture<Double> scoreFuture = scoredSortedSet.firstScoreAsync();
RFuture<String> nameFuture = scoredSortedSet.pollFirstAsync();

batch.excute()

 

이후의 Redis-cli를 통해서 명령어 모니터링 결과는 다음과 같습니다.

[MULTI]
[ZRANGE] "EVENT:QUEUE:1" "0" "0" "WITHSCORES"
[EVAL] "local v = redis.call('zrange', KEYS[1], ARGV[1], ARGV[2]); if #v > 0 then redis.call('zremrangebyrank', KEYS[1], ARGV[1], ARGV[2]); return v; end return v;" "1" "EVENT:QUEUE:1" "0" "0"
[lua] "zrange" "EVENT:QUEUE:1" "0" "0"
[lua] "zremrangebyrank" "EVENT:QUEUE:1" "0" "0"
[EXEC]

 

 

이와 같은 접근 방식은 Redis의 장점을 극대화하며, 분산 시스템의 복잡한 동시성 문제를 해결하는 데 효과적입니다. 특히, 대규모 트래픽을 처리하는 환경에서 Redis의 성능을 유지하면서도 데이터의 일관성을 보장할 수 있습니다.

 

[참고자료]

https://redis.io/docs/latest/develop/use/pipelining/

 

Redis pipelining

How to optimize round-trip times by batching Redis commands

redis.io

 

https://redis.io/blog/you-dont-need-transaction-rollbacks-in-redis/

 

You Don’t Need Transaction Rollbacks in Redis - Redis

Loris Cro explains the differences and similarities between Redis and SQL databases when it comes to transactions.

redis.io

 

반응형