0%

Redis 동시성 해결하기 이론 with. MULIT(Transaction), pipeline, Lua

redis
이전 글(DB에서 해결)의 시리즈 입니다. 따라서 예시의 자세한 상황은 이전 글을 참고해주세요.

간단한 상황

주문 번호의 스펙을 다시 설명하면 다음과 같습니다.

  • 주문 번호는 매일 초기화
  • 주문 번호는 가게별로 다름
  • 같은 날 같은 가게에는 동일한 주문 번호가 없음
  • 주문 번호는 1부터 시작해서 1씩 증가

레디스 도입 이유

MySQL에서 Redis로 변경하려는 이유는 다음과 같습니다.

  • 빠른 속도
  • DB에 부하를 줄이기 위함
  • 이미 레디스를 사용 중

레디스의 동시성 문제

레디스는 싱글 스레드로 동작하기 때문에 동시성 문제가 발생하지 않습니다. 하지만 이것은 단일 레디스만 사용하는 경우입니다. 보통 레디스를 멀티 스레드 환경에서 사용하기 때문에 동시성 문제가 발생할 수 있습니다.

INCR을 사용하면 현재 상황에서는 동시성이 발생하지 않습니다. 하지만 동시성 해결이 주제라 일부로 동시성 문제를 발생 시키겠습니다.

동시성이 발생하는 상황

주문번호를 가져오고 증가시키는 코드는 다음과 같습니다.

1
2
3
4
127.0.0.1:6379> get store1
"0"
127.0.0.1:6379> set store1 1
OK

store1의 값을 가져오고 증가시키는 코드입니다. 현재의 주문 번호는 1입니다.

문제 발생

문제 상황
위의 그림을 보면 get store1을 한 후에 컨텍스트 스위칭이 발생하면, get store1의 값이 동일한 값이 나올 수 있습니다.

Transaction사용

Transaction을 사용하면 원자성을 보장할 수 있습니다. MULTI를 통해서 트랜잭션을 시작하고 EXEC/DISCARD를 통해서 트랜잭션을 실행/취소합니다.

원리는 MULTI로 트랜잭션을 시작하면 queue에 명령어를 넣고 EXEC를 하면 queue에 존재하는 명령어가 순차적으로 실행됩니다. 그래서 트랜잭션이 실행되는 동안 다른 명령어가 실행되지 않습니다.

특이한 점은 트랜잭션이 실행되는 동안 중간 결과를 확인할 수 없습니다.

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> get store1
QUEUED
127.0.0.1:6379(TX)> set store1 "????"
QUEUED
127.0.0.1:6379(TX)> EXEC
1) "0"
2) OK

EXEC를 하기 전에는 get store1의 결과를 알 수 없습니다. 따라서 get store1의 결과에 +1의 값을 set store1 [value]에 넣을 수 없습니다.

문제 상황

문제 상황

그림처럼 트랜잭션을 사용하면 트랜잭션 동안 race condition이 발생하지 않지만, 트랜잭션이 끝나고 get store1을 했을 때 컨텍스트 스위칭이 발생하면 race condition이 발생할 수 있습니다.

Pipeline

redis pipeline의 기본적인 아이디어는 한번에 여러 명령어를 보내는 것입니다. 클라이언트가 명령어에 대한 응답을 받기 전에 다음 명령어를 보내기 때문에 round-trip time(RTT)가 적습니다. 따라서 여러 명령어를 보낼 때 pipeline을 사용하면 성능이 향상됩니다.

pipeline
http pipeline

한번에 여러 명령어를 보내서 원자성을 보장하는 것 처럼 보이지만 pipeline원자성을 보장하지 않습니다. 왜냐하면 다른 pipeline이 끼어들어서 명령어를 실행할 수 있기 때문입니다.

출처
pipeline

redis Lua Script

Lua Script를 사용해서 문제를 해결할 수 있습니다. Lua ScriptRedis에서 제공하는 스크립트 언어입니다. Lua Script를 사용하면 스크립트가 실행되는 동안 다른 명령어가 실행되지 않기 때문에 race condition이 발생하지 않습니다.

예제

  • lua script

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    local key = KEYS[1]
    local currentValue = redis.call('GET', key)
    if currentValue == false then
    currentValue = 0
    else
    currentValue = tonumber(currentValue)
    end
    local newValue = currentValue + 1
    redis.call('SET', key, tostring(newValue))
    return newValue
  • redis

    1
    2
    127.0.0.1:6379> EVAL "local key = KEYS[1] local currentValue = redis.call('GET', key) if currentValue == false then currentValue = 0 else currentValue = tonumber(currentValue) end local newValue = currentValue + 1 redis.call('SET', key, tostring(newValue)) return newValue" 1 store1
    (integer) 1

이렇게 하면 race condition을 방지할 수 있고, 중간 결과를 확인할 수 있습니다.

Lua Script 주의사항

앞서 말했듯이 Lua script가 실행 된다면 Lua script가 실행되는 동안 다른 명령어가 실행되지 않습니다. 그래서 오래 걸리는 작업을 할 때는 Lua script를 사용하지 않는 것이 좋습니다.

pipeline vs Lua Script

사실 Redis 2.6버전 이상에서는 여러 명령어를 보낼 때도 대부분의 상황에서 Lua Scriptpipeline보다 낫습니다.

공식 문서를 보면 응답값을 활용하는 경우 pipeline은 읽기 명령어를 클라이언트가 받은 다음에 응답을 하지만, Lua ScriptRedis 서버에서 실행되기 때문에 pipeline보다 빠릅니다.

결론(특징)

Transaction

  • 원자성이 필요할 때
  • 다음 명령어가 이전 명령어의 응답이 필요 없을 때

Pipeline

  • Redis 서버로 여러 명령어를 보낼 때
  • 다음 명령어가 이전 명령어의 응답이 필요 없을 때

Lua Script

  • 원자성이 필요할 때
  • 명령어를 실행하는 동안 중간 값이 필요할 때
  • 조건부 명령어를 실행할 때
  • 단 오래 걸리는 작업은 주의