재배포할 때마다 Redis Stream 메시지 몇 건이 영영 처리되지 않은 이유
문제 발생
Pub/Sub으로 보내던 주문 처리 메시지가 가끔 유실돼서 Redis Streams와 소비자 그룹으로 옮겼습니다. 워커 여러 대가 같은 그룹에 붙고, 소비자 이름은 겹치지 않게 호스트 이름과 PID로 만들었습니다. 워커는 아래 명령을 반복하면서 처리가 끝난 메시지마다 XACK을 보냈습니다.
XGROUP CREATE orders workers $ MKSTREAM
XREADGROUP GROUP workers worker-12345 COUNT 10 BLOCK 5000 STREAMS orders >
XACK orders workers 1791898637345-0몇 주 동안은 잘 돌았습니다. 그러다 정산을 맞추던 중에 결제는 끝났는데 '처리 중'에서 멈춘 주문이 몇 건 나왔습니다. 하나같이 워커를 재배포한 시각에 들어온 주문이었습니다. Stream에는 메시지가 그대로 남아 있었는데, 그 뒤로 어느 워커도 그 메시지를 받은 적이 없었습니다. 유실을 막으려고 옮겼는데 결과만 보면 똑같이 사라진 셈이었습니다.
원인 분석
>는 아직 아무에게도 전달된 적 없는 메시지만 줍니다. XREADGROUP 문서는 >를 쓰면 다른 어떤 소비자에게도 전달된 적 없는 메시지, 즉 새 메시지만 받는다고 설명합니다. 재배포 직전에 worker-12345가 주문 두 건을 받아 놓고 XACK을 보내기 전에 종료됐습니다. 이 두 건은 이미 '전달된' 메시지라서, 새로 뜬 워커가 >로 아무리 읽어도 다시 오지 않습니다.
받기만 하고 ack하지 않은 메시지는 PEL에 계속 남습니다. 문서에 따르면 XREADGROUP으로 읽으면 서버가 그 메시지를 누구에게 줬는지 기억해 두고, 이 목록을 PEL(Pending Entries List)이라고 부릅니다. 전달됐지만 아직 확인되지 않은 메시지의 목록이고, XACK을 받아야만 빠집니다. 그러니 메시지가 사라진 게 아니라, 다시는 돌아오지 않을 소비자의 PEL에 갇혀 있던 겁니다. 같은 상황을 재현하면 이렇게 보입니다.
> XPENDING orders workers - + 10
1) 1) "1791898637345-0"
2) "worker-12345"
3) (integer) 2025
4) (integer) 1
2) 1) "1791898637352-0"
2) "worker-12345"
3) (integer) 2025
4) (integer) 1네 칸은 차례대로 메시지 ID, 그 메시지를 들고 있는 소비자, 마지막으로 전달된 뒤 흐른 시간(ms), 전달된 횟수입니다. XINFO CONSUMERS를 보면 worker-12345가 pending 2를 들고 남아 있습니다.
소비자 이름을 매번 바꾼 게 문제를 키웠습니다. 같은 문서는 >가 아닌 ID(0 같은)로 읽으면 그 소비자에게 전달됐지만 아직 확인되지 않은 메시지를 다시 준다고 설명합니다. 재시작한 워커가 같은 이름으로 0부터 읽었다면 자기 몫을 되찾았을 겁니다. 그런데 이름에 PID가 들어가 있으니 재배포할 때마다 새 소비자가 생겼고, 이전 이름의 PEL은 주인이 영영 돌아오지 않는 상태가 됐습니다.
해결 방안
- 워커는 시작할 때 자기 pending부터 비웁니다. 소비자 이름은 재시작해도 같은 값이 되도록 고정된 번호나 파드 이름으로 정합니다. 시작하면 먼저
0부터 읽어서 남은 걸 처리하고, 빈 결과가 나오면 그때>로 넘어갑니다.
XREADGROUP GROUP workers worker-1 COUNT 10 STREAMS orders 0재현에서 같은 이름으로 다시 읽자 주문 두 건이 그대로 돌아왔습니다. 문서대로 이렇게 읽을 때는 BLOCK이 무시되니, 이 단계는 블로킹 없이 빠르게 끝납니다.
- 죽은 워커의 몫은
XAUTOCLAIM으로 가져옵니다. 워커 수를 줄이면 이름이 고정돼 있어도 영영 안 돌아오는 소비자가 생깁니다. Redis 6.2부터 있는XAUTOCLAIM은 지정한 시간 이상 대기 중인 메시지의 소유권을 지금 소비자에게 넘겨 줍니다. 저는 살아 있는 워커가 주기적으로 아래 명령을 돌리게 했습니다.
XAUTOCLAIM orders workers worker-1 300000 0-0 COUNT 1005분(300,000ms) 넘게 묵은 pending을 가져오라는 뜻입니다. 응답은 다음 호출에 쓸 시작 ID, 가져온 메시지, 그리고 Redis 7.0부터는 Stream에서 이미 지워져 PEL에서만 정리한 ID 목록입니다. 시작 ID가 0-0이면 PEL을 끝까지 훑은 겁니다. 대기 시간은 정상 처리에 걸리는 최대 시간보다 넉넉히 잡아야 합니다. 느리지만 살아 있는 워커의 메시지를 뺏어 오면 같은 주문이 두 번 처리되니, 처리 로직은 같은 메시지가 두 번 와도 괜찮게 만들어 둡니다. Redis 8.4부터는 XREADGROUP에 CLAIM 옵션이 생겨서, 읽으면서 오래된 pending을 먼저 가져올 수도 있습니다.
- 계속 실패하는 메시지는 따로 뺍니다.
XPENDING문서에 따르면 네 번째 칸인 전달 횟수는 다른 소비자가 메시지를 claim하거나 자기 이력을 다시 읽을 때마다 올라갑니다. 재현에서도0으로 다시 읽자 1에서 2로,XAUTOCLAIM뒤에는 3으로 올랐습니다. 처리할 때마다 워커를 죽이는 메시지라면 이 숫자만 계속 커지면서 워커들 사이를 돕니다. 저는 5번을 넘기면 별도 Stream에 옮겨 두고 원래 메시지는 ack했습니다.
XADD orders:dead * sourceId 1791898637345-0 orderId 1001 reason "delivery count exceeded"
XACK orders workers 1791898637345-0-
소비자를 지우기 전에 pending부터 처리합니다.
XINFO CONSUMERS에 죽은 이름이 쌓여 있으면XGROUP DELCONSUMER로 지우고 싶어집니다. 그런데 문서는 소비자를 지우면 그 소비자가 갖고 있던 pending 메시지는 더 이상 claim할 수 없게 된다고 경고하고, 지우기 전에 claim하거나 ack하라고 강하게 권합니다. 재현해 보니 pending 1건을 가진 소비자를 지우자 반환값으로 1이 나왔고, 그 메시지는 PEL에서 사라져XAUTOCLAIM으로도 다시 가져올 수 없었습니다. -
확인은
XPENDING으로 합니다.IDLE옵션을 주면 일정 시간 이상 묵은 pending만 볼 수 있습니다. 이 결과가 비어 있지 않으면 알림이 오게 해 두니, 같은 일이 생겨도 정산 때가 아니라 몇 분 안에 알 수 있었습니다.
XPENDING orders workers IDLE 300000 - + 10
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.