최근 진행하고 있는 프로젝트 Gitanimals에서는 분산된 트랜잭션간의 데이터 정합성을 맞추기위해 redis-stream을 이용해 Saga를 구현하고 있습니다.
메시지 스트림 구현체로 redis-stream을 사용하면서, 내부적으로 redis-stream의 Group을 사용하고 있는데, redis-stream의 Group에 대해서 자세히 설명하고 있는글이 없더라구요. Group의 내부동작을 모른채 사용하면 메모리 누수로까지 이어질 수 있는만큼 중요한 부분이라 생각하기 때문에, 제가 정리하고자 마음을 먹고 글을 작성하게 되었습니다.
Redis-stream 개요
공식문서에 따르면, redis-stream은 append-only log 와 같이 동작하는 자료구조이지만, 몇가지 기능을 추가로 제공해서 append-only log의 한계를 극복했다고 합니다.
실제로, redis-stream은 아래와 같은 오퍼레이션들을 제공하는데요,
- XREAD
- XRANGE
- XLEN
- XADD
- XREADGROUP
- XGROUP
- XACK
등등
여기서 눈여볼 오퍼레이션들은 XREADGROUP, XGROUP, XACK 입니다.
이 오퍼레이션들을 통해서 redis-stream은 kafka처럼 "그룹 내 정확히 한 노드만 메시지를 전달받는것"을 보장 해줍니다. 또한, 위 목록에는 없지만, Kafka와 마찬가지로 자동으로 ACK를 해줄수도, ACK되지 않은 메시지를 찾아서 (XCLAIM) 처리할 수 도 있습니다.
이것 외에도, redis-stream은 내부적으로 radix-tree로 이루어져있는등 찾아보면 재밌는 정보가 많지만, 이번 글의 주제인 GROUP에서 벗어나므로 넘어가도록 하겠습니다.
GROUP의 동작원리
redis-stream의 GROUP은 내부적으로 PEL(Pending Entries List) 자료구조와 last-delivered-id라는 필드를 사용해 클라이언트에게 메시지가 반드시 전송되는것을 보장해줍니다.
last-delivered-id는 매우 명확한데, 각 Group이 stream의 어느 메시지까지 수신했는지를 기록하는 필드입니다.
예를들어, gitanimals의 redis-stream GROUP을 보면, 1번에 해당하는 api 그룹은 "1716816317690-0" id까지를 읽었고, render 그룹또한 마찬가지임을 알 수 있습니다.
(redis-stream은 id 자동생성을 지원하는데, 이때, "timestamp-sequence" 와 같이 id가 생성됩니다.)
즉, 각 group은 last-delivered-id 필드를 이용해서 자신이 스트림의 어디까지 메시지를 수신했는지 기록하여, 중복해서 메시지를 수신하는것을 방지합니다.
하지만, last-delivered-id만을 사용할 경우, 메시지가 서버에서 처리되다 유실되는 경우 해당 메시지는 영영 유실되게 되는데요, redis-stream은 이런 한계를 극복하고자 내부적으로 PEL 자료구조를 추가로 사용합니다.
PEL이란, Pending 상태가 되었지만, 처리되지 않은 메시지의 id를 저장하고있는 list를 의미합니다.
Redis-stream의 메시지는 크게 DELIVERED, PENDING, ACK의 상태를 가지고 있는데요, 각 상태의 의미는 아래와 같습니다.
DELIVERED : 메시지가 redis-stream에 전달 된 상태
PENDING : 메시지가 컨슈머에게 전달 된 상태 <- 컨슈머가 redis-stream에서 데이터를 읽으면 PENDING 상태가 됩니다.
ACK : 메시지가 컨슈머에 의해 처리가 완료된 상태 <- 컨슈머가 redis-stream에게 ACK 메시지를 보내면 ACK 상태가 됩니다. (ACK를 보내지 않으면 영원히 PENDING 상태로 남아요)
예를들어 아래와 같이 XREADGROUP 을 통해서 stream안의 모든 데이터를 읽고 ACK를 하지 않으면 모든 데이터가 PENDING 상태로 남아있게 됩니다.
이렇게 XPENDING을 통해서 PENDING 상태의 메시지를 조회하면,
아래와 같이, Pending 상태의 메시지가 69812개 생기는것을 볼 수 있습니다.
그렇다면, 이렇게 PEL을 생성하는게 메모리에 부담이 되지는 않을까요? 사실 이 글을 작성한 목적이기도 한데요. 한번 테스트 해보겠습니다.
테스트에 사용한 redis 버전은 7.2.4 이며, redis 모니터링은 redis-stat으로 해주었습니다.
우선, redis-stream에 69812개의 메시지를 넣어주었고, 어떠한 PEL도 만들지 않은 상태의 사진입니다.
약 2.5MB정도를 차지하는걸 볼 수 있습니다.
그렇다면, 69812개의 메시지를 Pending 상태로 만들고 ACK하지 않는다면, 메모리는 어떻게 변할까요?
아래 명령어를 입력해서 69812개의 메시지를 Pending 상태로 만들어주었습니다.
아래 결과를 보시면, 메모리가 약, 3.5배가량 튄것을 볼 수 있습니다.
이는, PEL이 처리되지 않은 메시지의 id뿐 아니라, 처리중인 consumer의 이름, 시도횟수, Pending된 시간을 함께 저장하고 있기 때문입니다.
즉, redis-stream과 Group 기능을 함께 사용할때는 PEL을 염두해두고 사용하는것이 안정성에 매우 중요합니다.
마치며
긴 글 읽어주셔서 감사합니다.
Gitanimals에서 사용하고있는 redis-stream 구현체는 다음 링크에서 확인할 수 있습니다.
https://github.com/devxb/Netx
'Redis' 카테고리의 다른 글
[Redis] Sentinel failover 테스트 (1) | 2023.10.15 |
---|---|
[Redis] Cluster 환경에서 Transaction 에러 해결하기 (0) | 2023.10.11 |
[Redis] Transaction으로 갱신손실 문제 해결하기 (0) | 2023.10.07 |