포스트

순서와 격리는 같은 자원을 반대로 당긴다 - 핫 키가 워커 하나를 넘는 순간 세 설계가 갈라지는 지점

엔지니어링 요약

Problem

같은 키의 순서를 지키려면 그 키를 한 곳에 모아야 하고, 느린 것을 격리하려면 다른 곳으로 보내야 한다. 리뷰를 쓰면서 이 긴장을 여러 번 만났는데, 어느 지점에서 실제로 갈라지는지와 각 설계가 무엇을 얼마나 내주는지는 재 본 적이 없었다.

Decision

워커 8개에 같은 도착 스트림을 주고 디스패치 방식만 셋으로 바꿔 쟀다. 공유 큐, 키 해시 파티션, 파티션 + 오래 기다린 큐를 쪼개 훔치기. 핫 키 하나의 부하를 워커 0.4개·1.0개·2.0개분으로 스윕해 갈라지는 지점을 찾고, 작업 비용에 ±50% 지터를 넣어 동시에 도는 같은 키 항목이 실제로 뒤집힐 수 있게 했다. 측정값은 순서 위반 수와 핫 키가 아닌 키들의 지연이다.

Result

핫 부하 0.4까지는 셋이 사실상 같았다(피해자 p99 9.6·116.6·109.4ms, 위반 전부 0). 1.0을 넘자 파티션의 피해자 p99가 4,430ms, 2.0에서 22,894ms로 발산했고 순서 위반은 아홉 번 실행 내내 0이었다. 훔치기는 그 지연을 507ms로 45배 줄이면서 위반 188건을 만들었고 항목 1,394개를 옮겼다. 공유 큐는 모든 부하에서 p99 10ms를 유지했고 위반은 0·0·56이었다. 순서 위반은 전략의 성질이 아니라 한 키의 도착률이 처리율을 넘는지의 함수였다.

카카오뱅크 알림 플랫폼 리뷰에서 “순서와 격리는 같은 자원을 두고 반대 방향으로 당긴다”고 썼다. monticker의 파티션 실험에서는 핫 종목이 같은 파티션 이웃을 p99 13초로 함께 밀어내는 것을 봤다. 두 번 다 현상을 확인했지만 어느 지점에서 갈라지는지는 재지 않았다. 이 글이 그 측정이다.

저장소는 data-ops-lab이고 재실행은 한 줄이다.

1
./.venv/bin/python experiments/ordering/run.py --seconds 20 --repeat 3

세 가지 설계

워커 8개에 같은 도착 스트림을 주고 나눠 주는 방식만 바꾼다.

설계동작기대
shared큐 하나, 아무 워커나 집는다격리 최대, 순서 보장 없음
partition키 해시로 큐를 정하고 큐마다 워커 하나키별 순서 보장, 이웃이 인질
steal파티션 + 500ms 넘게 기다린 큐를 쪼개 유휴 워커에게격리 회복, 순서는?

키 200개 중 하나가 핫이다. 보통 항목은 5ms, 핫 항목은 100ms의 IO 대기이고 비용에 ±50% 지터를 줬다. 지터가 없으면 같은 키 항목이 동시에 돌아도 같은 시간이 걸려 순서가 유지되고, 공유 큐가 실제보다 얌전해 보인다.

핫 키의 부하를 워커 몇 개분인지로 스윕했다. 도착률 400/s에서 핫 비율 1%면 초당 4건 × 100ms = 워커 0.4개분이다.

결과 (3회 중앙값)

핫 부하설계순서 위반훔친 항목피해자 p50피해자 p95피해자 p99
0.4shared006.2 ms8.99.6 ms
0.4partition008.128.3116.6
0.4steal008.122.9109.4
1.0shared006.18.99.6
1.0partition008.32,257.44,430.4
1.0steal261928.4274.4439.9
2.0shared5606.28.910.0
2.0partition008.211,770.222,894.5
2.0steal1881,3949.1408.1506.9

워커 하나를 넘기 전에는 아무 차이가 없다

핫 부하 0.4에서 셋의 피해자 p99는 9.6, 116.6, 109.4ms다. 순서 위반은 전부 0이다.

이것이 첫 번째 결론이다. 한 키가 워커 하나보다 적게 쓰는 동안에는 디스패치 설계를 고민할 이유가 없다. 설계 논쟁이 의미를 갖는 것은 그 임계를 넘는 순간부터다.

파티션은 정확히 그 지점에서 발산한다

1
2
3
핫 부하 0.4  →  피해자 p99    116.6 ms
핫 부하 1.0  →  피해자 p99  4,430.4 ms
핫 부하 2.0  →  피해자 p99 22,894.5 ms

핫 키를 담당하는 파티션은 워커 하나다. 그 키가 워커 하나분보다 많이 필요해지는 순간 그 큐는 영원히 따라잡지 못하고, 큐에 같이 들어 있는 무관한 키들이 그 뒤에 줄을 선다.

여기서 중요한 것은 피해자가 핫 키가 아니라는 점이다. 이 키들은 5ms짜리 일이고 도착률도 낮다. 자기 잘못이 없는데 해시가 겹쳤다는 이유로 22초를 기다린다. monticker에서 “파티션이 격리 단위이자 운명 공동체”라고 적은 것이 이 표다.

그리고 파티션은 아홉 번 실행 내내 순서 위반 0이었다. 그것을 사려고 낸 값이 위 지연이다.

훔치기는 지연을 45배 줄이고 순서를 판다

1
2
3
핫 부하 2.0
  partition  피해자 p99 22,894.5 ms   위반   0
  steal      피해자 p99    506.9 ms   위반 188   (항목 1,394개 이동)

큐를 쪼개 유휴 워커에게 넘기면 이웃이 풀려난다. 그 순간 그 큐 안의 순서가 사라진다. 앞부분은 원래 워커가, 뒷부분은 훔친 워커가 동시에 처리하므로 완료 순서가 뒤집힌다.

카카오뱅크 원문이 “동일 사용자의 연속된 요청은 순서 보장이 필요할 수 있다”를 미래 요구사항으로 적어 둔 지점이 정확히 여기다. 훔치기는 순서 요구가 없을 때만 자유롭게 할 수 있고, 생기는 순간 “같은 키는 나누지 않는다”는 제약이 붙으며, 그러면 그 키의 큐는 다시 head-of-line이 된다.

공유 큐의 청구서는 늦게 온다

공유 큐는 모든 부하에서 피해자 p99 9.6~10.0ms로 가장 좋다. 그런데 순서 위반이 0, 0, 56이다.

이 세 숫자가 이 실험에서 가장 배울 것이 많았다. 공유 큐는 “순서가 없는” 설계가 아니다. 한 키의 항목이 동시에 두 개 이상 처리되지 않는 한 순서는 유지된다. 뒤집히려면 그 키가 처리되는 속도보다 빨리 도착해야 한다.

그리고 그 임계는 파티션이 발산하는 임계와 같은 값이다. 한 키가 워커 하나분을 넘는 순간, 파티션에서는 큐가 자라고 공유 큐에서는 순서가 깨진다. 같은 사실을 양쪽에서 본 것이다.

그래서 “공유 큐는 순서를 보장하지 않으니 못 쓴다”는 판단은 절반만 맞다. 정확한 문장은 이렇다. 어떤 키의 도착률이 처리율을 넘지 않는다면 공유 큐도 순서를 지킨다. 그 조건을 아는지 모르는지가 설계 판단을 가른다.

고르는 기준

측정에서 나오는 기준은 단순하다.

  1. 어떤 키도 워커 하나분을 넘지 않는가? 넘지 않으면 무엇을 써도 된다. 공유 큐가 가장 단순하고 가장 빠르다.
  2. 넘는 키가 있고 순서가 필요한가? 파티션이 순서를 주지만 그 키의 이웃이 대가를 낸다. 이웃을 줄이려면 파티션을 늘려야 하고, 그러면 워커도 늘어야 한다.
  3. 넘는 키가 있는데 순서는 필요 없는가? 공유 큐다. 파티션으로 격리 문제를 만들 이유가 없다.
  4. 둘 다 필요한가? 훔치기는 답이 아니다. 지연을 사고 순서를 판다. 진짜 답은 그 키를 더 잘게 쪼개는 것(순서 단위를 사용자에서 요청으로 낮추기)이거나 그 키의 처리 비용을 줄이는 것이다.

4번이 실무에서 가장 자주 마주치는 자리이고, 디스패치 설계로는 해결되지 않는다는 것이 이 표의 결론이다. ParityPay 5편에서 “한 Aggregate에 몰린 적체는 초당 26.6건이 상한”이라고 적은 것도 같은 벽이었다.

측정에서 고친 것 둘

작업 비용이 전부 같아서 공유 큐가 얌전해 보였다. 첫 실행에서 공유 큐의 순서 위반이 0이었다. 같은 키 항목이 동시에 돌아도 비용이 같으면 시작 순서대로 끝나기 때문이다. 실제 핸들러는 그렇지 않으므로 ±50% 지터를 넣었고, 그제서야 핫 부하 2.0에서 56건이 나왔다. 지터가 없는 부하는 실제보다 순서가 잘 지켜지는 것처럼 보인다.

조건이 하나뿐이면 “파티션은 나쁘다”밖에 못 말한다. 처음에는 핫 부하 2.0만 쟀다. 그러면 파티션의 지연이 실행 길이에 비례해 커지는 값이고, 어느 조건에서 그런지가 빠진다. 부하를 0.4·1.0·2.0으로 스윕하자 갈라지는 지점이 결론이 됐다. 하나의 나쁜 수치보다 전이 곡선이 훨씬 많은 것을 말한다.

한계

  • 작업이 IO 대기 시뮬레이션이다. time.sleep은 DB나 HTTP를 기다리는 핸들러를 모사하고, GIL 아래에서 워커 스레드가 실제로 동시에 돌게 해준다. CPU 바운드 핸들러는 다르게 동작하고 여기서 재지 않았다.
  • 브로커가 아니라 디스패치 하니스다. Kafka의 리밸런스·페치 배칭·커밋 동작을 일부러 배제해 디스패치 전략만 남겼다. 브로커 쪽 버전은 monticker에서 따로 쟀다.
  • 핫 키가 하나다. 여러 개가 다른 파티션에 흩어지면 피해가 분산되고, 같은 파티션에 몰리면 집중된다.
  • 훔치기 임계 500ms를 스윕하지 않았다. 그 값이 지연과 순서를 얼마씩 교환하는지를 직접 정하는데, 그 곡선이 없다.
  • 순서 위반을 완료 순서로 센다. 하류에서 시퀀스 번호로 재정렬하는 소비자라면 보이지 않는다.
  • 3회 반복이다. 결과의 순서는 안정적이었고, 발산한 파티션 수치는 포화된 큐만큼의 폭을 갖는다.

정리

  • 한 키가 워커 하나분보다 적게 쓰는 동안에는 세 설계가 사실상 같다. 그 전에는 고민할 필요가 없다.
  • 파티션은 그 임계에서 발산한다. 피해자 p99가 116ms → 4,430ms → 22,894ms이고, 대가를 내는 것은 해시가 겹친 무관한 키들이다.
  • 파티션은 아홉 번 실행 내내 순서 위반 0이었다. 그것을 사려고 낸 값이 위 지연이다.
  • 훔치기는 지연을 45배 줄이고 순서 위반 188건을 만든다. 큐를 쪼개는 순간 그 안의 순서가 사라진다.
  • 공유 큐는 “순서가 없는” 설계가 아니다. 한 키가 처리되는 속도보다 빨리 도착할 때만 뒤집힌다.
  • 그 임계는 파티션이 발산하는 임계와 같은 값이다. 같은 사실을 양쪽에서 보는 것이다.
  • 순서와 격리가 둘 다 필요하면 디스패치 설계로는 해결되지 않는다. 순서 단위를 낮추거나 처리 비용을 줄여야 한다.

참고

Kafka와 메시징
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다