포스트

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

엔지니어링 요약 같은 키의 순서를 지키려면 그 키를 한 곳에 모아야 하고, 느린 것을 격리하려면 다른 곳으로 보내야 한다. 리뷰를 쓰면서 이 긴장을 여러 번 만났는데, 어느...

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 넘게 기다린 큐를 쪼개 유휴 워커에게격리 회복, 순서는?

partition은 Kafka가 순서를 보장하는 방식과 같은 모양이다. Kafka 문서는 같은 키의 이벤트가 같은 파티션에 쓰이고, 한 파티션의 소비자는 쓰인 순서 그대로 읽는다고 적는다. 원문은 “Kafka guarantees that any consumer of a given topic-partition will always read that partition’s events in exactly the same order as they were written”이다(Apache Kafka: Introduction). 순서의 단위가 파티션이므로, 같은 파티션에 들어간 다른 키도 같은 줄에 선다.

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

핫 키의 부하를 워커 몇 개분인지로 스윕했다. 도착률 400/s에서 핫 비율 1%면 초당 4건 × 100ms = 워커 0.4개분이다. 도착률과 처리율로 부하를 읽는 방법은 큐잉 이론 한 조각에 정리했다.


결과 (3회 중앙값)

피해자는 핫 키가 아닌 키들이고, p50·p95·p99는 그 지연의 백분위다(백분위 통계).

핫 부하설계순서 위반훔친 항목피해자 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

핫 키를 담당하는 파티션은 워커 하나다. 그 키가 워커 하나분보다 많은 일을 요구하면 그 큐는 따라잡지 못하고 계속 자란다. 그리고 큐에 같이 들어 있는 무관한 키들이 그 뒤에 줄을 선다. 맨 앞 항목이 끝나야 뒤가 처리되는 이 대기를 head-of-line 대기라고 부른다.

피해자는 핫 키가 아니다. 이 키들은 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개 이동)

밀린 큐를 쪼개 유휴 워커에게 넘기면(work stealing) 이웃이 풀려난다. 그런데 그 순간 그 큐 안의 순서가 사라진다. 앞부분은 원래 워커가, 뒷부분은 훔친 워커가 동시에 처리하므로 완료 순서가 뒤집힐 수 있다. 아래는 같은 키의 항목 둘이 그렇게 뒤집히는 경우를 이 실험의 설정으로 그린 것이다.

sequenceDiagram
    participant Q as 밀린 큐
    participant W1 as 원래 워커
    participant W2 as 유휴 워커
    Q->>W1: 핫 키 항목 1 (100ms, 지터로 더 길어짐)
    Note over Q: 대기 500ms 초과
    Q->>W2: 뒷부분을 넘김 (핫 키 항목 2 포함)
    W2-->>W2: 항목 2 완료
    W1-->>W1: 항목 1 완료
    Note over W1,W2: 완료 순서가 2, 1로 뒤집힘 = 순서 위반 1건

카카오뱅크 원문은 “순차 처리 (미래 요구사항)” 항목에 “동일 사용자의 연속된 요청 → 순서 보장 필요”라고 적어 뒀다(원문). 원문이 순서를 미래 요구사항으로 남겨 둔 지점이 바로 여기라고 나는 읽는다. 훔치기는 순서 요구가 없을 때만 제약 없이 할 수 있다. 순서 요구가 생기면 “같은 키는 나누지 않는다”는 제약이 붙고, 그러면 그 키의 큐는 다시 head-of-line이 된다.


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

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

이 세 숫자는 공유 큐에 대한 흔한 이해를 고친다. 공유 큐는 “순서가 없는” 설계가 아니다. 한 키의 항목이 동시에 두 개 이상 처리되지 않는 한 순서는 유지된다. 뒤집히려면 그 키가 처리되는 속도보다 빨리 도착해야 한다.

그리고 그 임계는 파티션이 발산하는 임계와 같은 값이다. 한 키가 워커 하나분을 넘는 순간, 파티션에서는 큐가 자라고 공유 큐에서는 순서가 깨진다. 같은 조건이 두 설계에서 다른 증상으로 나타난다.

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


고르는 기준

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

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

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


측정에서 고친 것 둘

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

조건이 하나뿐이면 “파티션은 나쁘다”밖에 말할 수 없다. 처음에는 핫 부하 2.0만 쟀다. 그 조건에서 파티션의 지연은 실행 길이에 비례해 커지는 값이고, 어느 조건부터 그런지는 알 수 없다. 부하를 0.4·1.0·2.0으로 스윕하자 갈라지는 지점이 보였다. 나쁜 수치 하나보다 부하에 따른 변화 곡선이 설계 판단에 더 많은 정보를 준다.


한계

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

정리

  • 한 키가 워커 하나분을 넘기 전에는 세 설계가 사실상 같다. 넘는 순간 파티션의 피해자 p99는 116ms → 4,430ms → 22,894ms로 발산하고, 대가는 해시가 겹친 무관한 키들이 낸다.
  • 훔치기는 지연을 45배 줄이지만 순서 위반 188건을 만든다. 큐를 쪼개는 순간 그 안의 순서가 사라진다.
  • 공유 큐의 순서가 깨지는 임계와 파티션이 발산하는 임계는 같은 값이다. 한 키의 도착률이 처리율을 넘는지가 둘 다를 정한다.
  • 순서와 격리가 둘 다 필요하면 디스패치 설계로는 해결되지 않는다. 순서 단위를 낮추거나 처리 비용을 줄여야 한다.
참고한 자료외부 출처 3 · 블로그 글 3

외부 출처

이 블로그의 관련 글

Kafka와 메시징
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다