Redis 트랜잭션
Redis 트랜잭션 (Transactions)
Redis 트랜잭션은 명령 그룹을 한 단계로 실행하게 해줘요. 트랜잭션의 중심에는 MULTI, EXEC, DISCARD, WATCH 명령이 있습니다. Redis 트랜잭션은 두 가지 중요한 보장을 합니다:
-
트랜잭션 안의 모든 명령은 직렬화되어 순차적으로 실행됩니다. 다른 클라이언트가 보낸 요청은 Redis 트랜잭션 실행 중간에 절대 처리되지 않아요. 덕분에 명령들이 하나의 격리된 연산으로 실행된다는 것이 보장돼요.
-
EXEC명령이 트랜잭션 안의 모든 명령 실행을 촉발합니다. 그래서 클라이언트가 트랜잭션 중에EXEC를 호출하기 전에 서버와의 연결을 잃으면 어떤 연산도 수행되지 않고, 반대로EXEC가 호출되면 모든 연산이 수행됩니다. append-only file을 쓸 때 Redis는 트랜잭션을 디스크에 쓰기 위해 단일write(2)시스템 콜을 사용하도록 보장합니다. 다만 Redis 서버가 크래시하거나 시스템 관리자가 어떤 강제적인 방법으로 죽이면 일부 연산만 등록될 가능성이 있어요. Redis는 재시작 시 이 상태를 감지하고 오류와 함께 종료합니다.redis-check-aof도구를 쓰면 부분 트랜잭션을 제거하도록 append only file을 고쳐서 서버를 다시 시작할 수 있어요.
버전 2.2부터 Redis는 위 두 가지에 추가로, check-and-set(CAS) 연산과 매우 유사한 형태의 낙관적 잠금(optimistic locking)이라는 추가 보장을 제공합니다. 이건 이 페이지 나중에 설명할게요.
출처: 공식문서
사용법
Redis 트랜잭션은 MULTI 명령으로 시작합니다. 이 명령은 항상 OK로 응답해요. 이 시점부터 사용자는 여러 명령을 내보낼 수 있습니다. Redis는 이 명령들을 실행하지 않고 큐에 쌓습니다. 모든 명령은 EXEC가 호출되면 실행됩니다.
대신 DISCARD를 호출하면 트랜잭션 큐를 비우고 트랜잭션을 종료해요.
다음 예시는 키 foo와 bar를 원자적으로 증가시킵니다.
> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1
위 세션에서 보듯 EXEC는 응답의 배열을 반환하는데, 각 요소는 트랜잭션 안의 각 명령 응답이며 명령이 발행된 순서와 같아요.
Redis 연결이 MULTI 요청 컨텍스트 안에 있으면 모든 명령이 QUEUED 문자열로 응답합니다(Redis 프로토콜 관점에서는 Status Reply로 보내져요). 큐에 쌓인 명령은 EXEC가 호출될 때 실행되도록 예약된 것뿐입니다.
트랜잭션 안의 오류
트랜잭션 중에 두 종류의 명령 오류를 만날 수 있어요:
- 어떤 명령이 큐에 쌓이지 못해서
EXEC가 호출되기 전에 오류가 날 수 있어요. 예를 들어 명령이 문법적으로 틀렸거나(인자 수가 잘못됨, 명령 이름이 틀림 등) 메모리 부족 같은 심각한 조건이 있을 수 있어요(maxmemory지시어로 메모리 한도를 설정한 서버의 경우). - 어떤 명령이
EXEC가 호출된 후에 실패할 수 있어요. 예를 들어 잘못된 값 타입의 키에 연산을 수행한 경우죠(문자열 값에 리스트 연산을 호출하는 것처럼).
Redis 2.6.5부터 서버는 명령 축적 중 오류를 감지합니다. 그러면 EXEC 중에 오류를 반환하며 트랜잭션을 실행하기를 거부하고, 트랜잭션을 버려요.
Redis < 2.6.5 참고: Redis 2.6.5 이전에는 클라이언트가 큐에 쌓인 명령의 반환값을 확인해
EXEC전에 발생한 오류를 감지해야 했어요. 명령이QUEUED로 응답하면 정상적으로 큐에 쌓인 것이고, 그렇지 않으면 Redis가 오류를 반환합니다. 명령을 큐에 넣는 동안 오류가 있으면 대부분의 클라이언트는 중단하고 트랜잭션을 버립니다. 반대로 클라이언트가 트랜잭션을 진행하기로 했다면,EXEC명령은 이전 오류와 무관하게 성공적으로 큐에 쌓인 모든 명령을 실행해요.
반면 EXEC 후에 발생하는 오류는 특별히 처리되지 않아요. 트랜잭션 중 어떤 명령이 실패해도 다른 모든 명령은 실행됩니다.
이건 프로토콜 레벨에서 더 명확해요. 다음 예시에서 하나의 명령은 문법이 맞아도 실행 시 실패합니다:
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
MULTI
+OK
SET a abc
+QUEUED
LPOP a
+QUEUED
EXEC
*2
+OK
-WRONGTYPE Operation against a key holding the wrong kind of value
EXEC은 두 요소 짜리 bulk string reply를 반환했는데, 하나는 OK 코드이고 다른 하나는 오류 응답이에요. 클라이언트 라이브러리가 오류를 사용자에게 알기 좋은 방식으로 제공하는 것은 클라이언트 라이브러리의 몫입니다.
여기서 중요한 것은 명령 하나가 실패해도 큐 안의 다른 모든 명령은 처리된다는 점이에요. Redis는 명령 처리를 중단하지 않습니다.
telnet으로 와이어 프로토콜을 쓰는 또 다른 예시는 문법 오류가 ASAP(가능한 한 빨리) 보고되는 모습을 보여줘요:
MULTI
+OK
INCR a b c
-ERR wrong number of arguments for 'incr' command
이번에는 문법 오류 때문에 잘못된 INCR 명령이 아예 큐에 쌓이지 않습니다.
롤백은 없을까?
Redis는 트랜잭션 롤백을 지원하지 않아요. 롤백을 지원하면 Redis의 단순함과 성능에 큰 영향을 주기 때문입니다.
명령 큐 버리기
DISCARD를 쓰면 트랜잭션을 중단할 수 있어요. 이 경우 어떤 명령도 실행되지 않고, 연결 상태는 정상으로 복원됩니다.
> SET foo 1
OK
> MULTI
OK
> INCR foo
QUEUED
> DISCARD
OK
> GET foo
"1"
check-and-set을 쓴 낙관적 잠금
WATCH는 Redis 트랜잭션에 check-and-set(CAS) 동작을 제공하는 데 쓰입니다.
WATCH된 키는 그 키에 대한 변경을 감지하도록 모니터링됩니다. EXEC 명령 전에 감시 중인 키가 하나라도 수정되면 트랜잭션 전체가 중단되고, EXEC가 Null reply을 반환해 트랜잭션이 실패했음을 알려줘요.
예를 들어 키 값을 1만큼 원자적으로 증가시켜야 한다고 상상해 봅시다(Redis에 INCR이 없다고 가정해요).
첫 시도는 이럴 수 있어요:
val = GET mykey
val = val + 1
SET mykey $val
이 방법은 주어진 시간에 단일 클라이언트만 연산을 수행한다면 안정적으로 동작해요. 하지만 여러 클라이언트가 거의 동시에 키를 증가시키려 하면 경쟁 조건(race condition)이 생깁니다. 예를 들어 클라이언트 A와 B가 이전 값 10을 읽고, 두 클라이언트 모두 값을 11로 증가시킨 뒤 마지막에 SET으로 키 값을 설정합니다. 그래서 최종 값은 12가 아니라 11이 돼요.
WATCH 덕분에 이 문제를 아주 잘 모델링할 수 있어요:
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
위 코드를 쓰면, WATCH 호출과 EXEC 호출 사이에 경쟁 조건이 있어 다른 클라이언트가 val의 결과를 수정한다면 트랜잭션이 실패합니다.
이번에는 새 경쟁이 없기를 바라며 연산을 반복하기만 하면 돼요. 이런 형태의 잠금을 낙관적 잠금(optimistic locking) 이라고 합니다. 많은 사용 사례에서 여러 클라이언트가 서로 다른 키에 접근하기 때문에 충돌이 일어날 가능성이 낮아요. 그래서 보통 연산을 반복할 필요가 없습니다.
버전 8.4부터 Redis는 문자열 키에 대한 새로운 원자적 compare-and-set·compare-and-delete 명령을 제공합니다. 클라이언트는 단일 SET 명령에 IFEQ/IFNE/IFDEQ/IFDNE 옵션을 붙여, 키의 값이 가져온 이후 바뀌지 않은 경우에만 문자열 키를 원자적으로 갱신할 수 있어요. 이게 훨씬 단순하고 빠릅니다. 비슷하게 클라이언트는 compare-and-delete를 위해 단일 DELEX 명령을 쓸 수 있어요. 값이 가져온 이후 바뀌지 않았을 때만 문자열 키를 원자적으로 삭제하는 거죠.
WATCH 설명
그래서 WATCH가 실제로 무엇일까요? EXEC를 조건부로 만드는 명령이에요. 즉 감시 중인 키 중 어느 것도 수정되지 않은 경우에만 트랜잭션을 수행하라고 Redis에 요청하는 겁니다. 여기에는 클라이언트가 수행한 쓰기 명령 같은 수정과, 만료나 축출 같은 Redis 자체의 수정이 모두 포함돼요. 키가 WATCH된 시점과 EXEC가 수신된 시점 사이에 수정되면, 대신 트랜잭션 전체가 중단됩니다.
노트
- Redis 6.0.9 이전 버전에서는 만료된 키가 트랜잭션 중단을 일으키지 않았어요. 더 보기
- 트랜잭션 안의 명령은
EXEC가 보내질 때까지 큐에만 쌓이므로WATCH조건을 촉발하지 않습니다.
WATCH는 여러 번 호출할 수 있어요. 모든 WATCH 호출은 각각 호출 시점부터 EXEC가 호출되는 순간까지의 변경을 감시하는 효과가 있습니다. 단일 WATCH 호출에 여러 키를 보낼 수도 있어요.
EXEC가 호출되면 트랜잭션이 중단됐는지와 무관하게 모든 키가 UNWATCH됩니다. 또한 클라이언트 연결이 닫히면 모든 것이 UNWATCH됩니다.
UNWATCH 명령을 (인자 없이) 써서 감시 중인 모든 키를 비울 수도 있어요. 몇 개 키를 낙관적으로 잠글 때 유용한데, 그 키들을 수정하는 트랜잭션을 수행해야 할 수도 있지만 키의 현재 내용을 읽어보니 더 진행하고 싶지 않을 수 있거든요. 이때 UNWATCH를 호출하면 연결을 새 트랜잭션에 자유롭게 쓸 수 있습니다.
WATCH로 ZPOP 구현하기
WATCH를 써서 Redis가 지원하지 않는 새로운 원자 연산을 만드는 방법을 보여주는 좋은 예는 ZPOP 구현이에요(ZPOPMIN, ZPOPMAX과 그 블로킹 변형은 5.0 버전에 추가됐어요). ZPOP는 정렬 집합(sorted set)에서 스코어가 가장 낮은 요소를 원자적으로 꺼내는 명령이에요. 가장 단순한 구현은 다음과 같습니다:
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
EXEC가 실패하면(즉 Null reply를 반환하면) 연산을 그냥 반복하면 됩니다.
Redis 스크립팅과 트랜잭션
Redis에서 트랜잭션형 연산을 위해 고려할 또 다른 것은 트랜잭션적인 redis 스크립트예요. Redis 트랜잭션으로 할 수 있는 모든 것을 스크립트로도 할 수 있고, 보통은 스크립트가 더 단순하고 빨라요.